DEV Community

Nikhil Ranka
Nikhil Ranka

Posted on

USDC Escrow for AI Agents: How Trustless Freelancing Actually Works

USDC Escrow for AI Agents: How Trustless Freelancing Actually Works

Target audience: developers building autonomous AI agents that need to receive payment for services without relying on a centralized intermediary.


Why an escrow makes sense

AI agents often operate as “black‑box” workers: they receive a request, perform computation (e.g., LLM inference, data labeling, micro‑task execution), and return a result. In a purely peer‑to‑peer model the requester must trust that the agent will do the work before paying, while the agent must trust that the requester will pay after seeing the output. This mutual‑trust problem is solved by an escrow that holds funds until a verifiable condition is met.

Using USDC on a low‑cost L2 like Base gives us:

  • Stable value – 1 USDC ≈ $1 USD, avoiding volatility‑related pricing headaches.
  • Fast finality – ~2 seconds block time on Base, keeping latency low for interactive agents.
  • Low gas – Typical transaction costs are <$0.001, making micropayments feasible.

The escrow does not eliminate the need for some off‑chain verification of work; it merely shifts the trust from a counterparty to a deterministic contract plus a verification mechanism (oracle, arbiter, or proof).


System overview

+----------------+        +----------------+        +----------------+
|  Requester     |  <---> |  Escrow (SC)   |  <---> |  AI Agent      |
| (pays USDC)    |  deposit|  holds USDC    |  earns  | (does work)    |
+----------------+        +----------------+        +----------------+
        ^                         |                         |
        |   dispute / refund      |   proof of completion   |
        +-------------------------+-------------------------+
Enter fullscreen mode Exit fullscreen mode
  1. Funding – The requester deposits USDC into the escrow contract, specifying the agent’s address and a maximum price.
  2. Work trigger – The agent calls a startWork function (or simply watches for a deposit event) and begins the off‑chain task.
  3. Completion proof – When the work is done, the agent submits a cryptographic proof (e.g., a hash of the output stored on‑chain, or a signature from a trusted oracle) via submitProof.
  4. Release – If the proof validates within a challenge period, the escrow releases the funds to the agent.
  5. Refund – If the agent fails to submit a valid proof before the timeout, the requester can call refund to retrieve their deposit.

The contract is intentionally minimal; any sophisticated arbitration (e.g., a multisig jury) can be layered on top without changing the core escrow logic.


Solidity escrow contract (Base‑compatible)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
}

contract USDCewiseEscrow {
    IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    address public requester;
    address public agent;
    uint256 public amount;          // locked USDC
    uint256 public deadline;        // block.timestamp after which requester can refund
    bytes32 public proofHash;       // keccak256 of the expected output (set by agent)
    bool public released;
    bool public refunded;

    // Roles
    address public arbiter; // optional, can be 0x0 for trust‑less version

    event Deposited(address indexed requester, address indexed agent, uint256 amount);
    event ProofSubmitted(address indexed agent, bytes32 proofHash);
    event Released(address indexed agent, uint256 amount);
    event Refunded(address indexed requester, uint256 amount);

    constructor(
        address _usdc,
        address _requester,
        address _agent,
        uint256 _amount,
        uint256 _timeoutSeconds, // e.g., 1 hour
        address _arbiter
    ) {
        require(_usdc != address(0), "USDC addr");
        require(_requester != address(0), "Requester addr");
        require(_agent != address(0), "Agent addr");
        require(_amount > 0, "Zero amount");
        usdc = IERC20(_usdc);
        requester = _requester;
        agent = _agent;
        amount = _amount;
        deadline = block.timestamp + _timeoutSeconds;
        arbiter = _arbiter == address(0) ? address(this) : _arbiter;
        // Pull funds from requester (they must have approved the contract)
        require(usdc.transferFrom(requester, address(this), amount), "Transfer failed");
        emit Deposited(requester, agent, amount);
    }

    /// @notice Agent submits a hash of the work output. The requester (or arbiter)
    ///         later reveals the pre‑image to prove correctness.
    function submitProof(bytes32 _proofHash) external {
        require(msg.sender == agent, "Only agent");
        require(!released && !refunded, "Already settled");
        proofHash = _proofHash;
        emit ProofSubmitted(agent, _proofHash);
    }

    /// @notice Requester (or arbiter) provides the pre‑image; if it matches,
    ///         funds are released to the agent.
    function release(bytes calldata _preimage) external {
        require(msg.sender == requester || msg.sender == arbiter, "Unauthorized");
        require(!released && !refunded, "Already settled");
        require(keccak256(_preimage) == proofHash, "Invalid proof");
        released = true;
        usdc.transfer(agent, amount);
        emit Released(agent, amount);
    }

    /// @notice Requester can reclaim funds after deadline if no valid proof.
    function refund() external {
        require(msg.sender == requester, "Only requester");
        require(block.timestamp >= deadline, "Not timed out");
        require(!released && !refunded, "Already settled");
        refunded = true;
        usdc.transfer(requester, amount);
        emit Refunded(requester, amount);
    }

    /// @notice Fallback to reject plain ether.
    receive() external payable {
        revert("Only USDC accepted");
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract holds USDC via ERC‑20 transferFrom; the requester must approve the escrow beforehand (standard ERC‑20 flow).
  • The agent submits only a hash (proofHash). This keeps the actual result off‑chain, preserving privacy while still allowing the requester to verify by revealing the pre‑image.
  • A simple timeout enables refunds; an arbiter address can be set to a multisig or DAO for dispute resolution.
  • Gas cost on Base for a typical deposit → submitProof → release flow is roughly 150 k gas (~$0.001 at 5 gwei).

Agent‑side integration (TypeScript + ethers.js)


ts
import { ethers } from "ethers";
import usdcAbi from "./usdc-abi.json";   // minimal IERC20 ABI
import escrowAbi from "./escrow-abi.json";

// Configuration (Base mainnet)
const RPC_URL = "https://base-mainnet.infura.io/v3/<PROJECT_ID>";
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!; // funded with a little ETH for gas
const provider = new ethers.JsonRpcProvider(RPC_URL);
const signer = new ethers.Wallet(PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, signer);

// Helper: approve escrow to pull USDC (called once per session)
async function approveEscrow(escrow: string, amount: ethers.BigNumberish) {
    const tx = await usdc.approve(escrow, amount);
    await tx.wait();
}

// Agent workflow
async function runJob(escrowAddress: string, jobInput: any) {
    const escrow = new ethers.Contract(escrowAddress, escrowAbi, signer);

    // 1. Wait for funding (listening to Deposit event)
    const filter = escrow.filters.Deposited(null, signer.address);
    escrow.on(filter, async (requester, agent, amount) => {
        console.log(`Funded: ${ethers.formatUnits(amount, 6)} USDC from ${requester}`);

        // 2. Do the work off‑chain (example: LLM inference)
        const result = await performInference(jobInput); // returns string/bytes
        const proofHash = ethers.keccak256(ethers.toUtf8Bytes(result));

        // 3. Submit hash to escrow
        const submitTx = await escrow.submitProof(proofHash);
        await submitTx.wait();
        console
Enter fullscreen mode Exit fullscreen mode

Top comments (0)