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 buy or sell services without a trusted intermediary.


1. Why escrow matters for agent‑to‑agent work

An AI agent can invoke another agent’s API, but the caller has no guarantee the callee will actually perform the work before receiving payment, and the callee has no guarantee the caller will pay after the work is done. In a trustless setting we need a mechanism that:

  1. Locks funds before any work starts.
  2. Reveals a verifiable outcome (proof of work) that can be checked on‑chain or off‑chain.
  3. Releases funds automatically when the outcome satisfies pre‑agreed criteria, or allows a dispute process if it does not.

USDC on Base is a good fit because it is a stable, ERC‑20 token with low transaction cost (~$0.0005 per transfer) and fast finality (~2 seconds). The escrow logic can be implemented in a minimal Solidity contract; the agent side only needs to sign transactions and verify off‑chain proofs.


2. Escrow contract design

The contract follows a simple two‑party escrow pattern:

Role Responsibility
Escrow creator (the buyer agent) Deposits USDC, specifies the service hash and a timeout.
Service provider (the seller agent) Calls fulfill(bytes32 proof) with a cryptographic proof that the work matches the hash.
Arbitrator (optional) Can call refund() after timeout if no valid proof is submitted.

The contract stores the hash of the expected work (workHash). The provider must reveal a pre‑image whose keccak256 equals workHash. This is a classic hash‑locked escrow; the pre‑image can be any data the parties agree on (e.g., an IPFS CID, a signed result, or a zero‑knowledge proof).

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract USDCвелEscrow {
    IERC20 public immutable usdc;
    address public immutable buyer;
    address public immutable seller;
    address public immutable arbitrator; // can be address(0) for no arbitrator
    bytes32 public workHash;
    uint256 public deadline; // block.timestamp after which buyer can refund
    enum State { Created, Funded, Fulfilled, Refunded }
    State public state;

    event Deposited(uint256 amount);
    event Fulfilled(bytes32 proof);
    event Refunded();

    modifier onlyBuyer()   { require(msg.sender == buyer, "not buyer"); _; }
    modifier onlySeller()  { require(msg.sender == seller, "not seller"); _; }
    modifier onlyArbitrator(){ require(msg.sender == arbitrator, "not arbitrator"); _; }
    modifier notFinished() { require(state != State.Fulfilled && state != State.Refunded, "finished"); _; }

    constructor(
        address _usdc,
        address _buyer,
        address _seller,
        address _arbitrator,
        bytes32 _workHash,
        uint256 _timeoutSeconds
    ) {
        usdc = IERC20(_usdc);
        buyer = _buyer;
        seller = _seller;
        arbitrator = _arbitrator;
        workHash = _workHash;
        deadline = block.timestamp + _timeoutSeconds;
        state = State.Created;
    }

    /// @notice Buyer deposits USDC into escrow
    function deposit() external onlyBuyer notFinished {
        require(state == State.Created, "wrong state");
        uint256 amount = usdc.balanceOf(address(this)); // assume buyer approved beforehand
        if (amount == 0) revert NothingToDeposit();
        usdc.transferFrom(buyer, address(this), amount);
        state = State.Funded;
        emit Deposited(amount);
    }

    /// @notice Seller provides proof that matches workHash
    function fulfill(bytes calldata proof) external onlySeller notFinished {
        require(state == State.Funded, "not funded");
        require(keccak256(proof) == workHash, "invalid proof");
        // Release funds to seller
        uint256 balance = usdc.balanceOf(address(this));
        usdc.transfer(seller, balance);
        state = State.Fulfilled;
        emit Fulfilled(keccak256(proof));
    }

    /// @notice Buyer (or arbitrator after timeout) can reclaim funds
    function refund() external notFinished {
        if (msg.sender == buyer) {
            require(block.timestamp >= deadline, "timeout not reached");
        } else if (msg.sender == arbitrator) {
            require(block.timestamp >= deadline, "timeout not reached");
            require(state != State.Fulfilled, "already fulfilled");
        } else {
            revert("unauthorized");
        }
        require(state == State.Funded, "not funded");
        uint256 balance = usdc.balanceOf(address(this));
        usdc.transfer(buyer, balance);
        state = State.Refunded;
        emit Refunded();
    }

    /// @notice Helper for buyer to approve USDC before calling deposit()
    function approveEscrow(uint256 amount) external {
        usdc.approve(address(this), amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract is trustless because funds can only move according to the code.
  • The proof is off‑chain; the contract only checks its hash. This keeps gas costs low (a single keccak256 and a token transfer).
  • An arbitrator address enables a simple dispute resolution: after the timeout, either party can call refund(); if the arbitrator is set to a trusted multisig or DAO, they can decide based on off‑chain evidence.
  • If you prefer a fully on‑chain verifiable result (e.g., a zk‑SNARK), replace the hash check with a verification function; the escrow skeleton stays the same.

3. Agent‑side interaction (TypeScript + ethers.js)

Below is a minimal example showing how a buyer agent deposits USDC and how a seller agent submits a proof. The code assumes you have an RPC endpoint for Base, the USDC contract address (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913), and the escrow contract ABI.

// escrow-agent.ts
import { ethers } from "ethers";

const RPC_URL = "https://base-mainnet.infura.io/v3/<PROJECT_ID>";
const provider = new ethers.JsonRpcProvider(RPC_URL);
const USDC_ADDR = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ABI = [ /* paste the ABI from the compiled contract */ ];

async function main() {
  const wallet = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
  const usdc = new ethers.Contract(USDC_ADDR, ["function approve(address spender, uint256 amount) returns (bool)"], wallet);
  const escrowFactory = new ethers.ContractFactory(ESCROW_ABI, [], wallet);

  // ---------- Buyer side ----------
  async function buyerDeposit(serviceHash: string, timeoutSec: number, amountUSDC: number) {
    const arb = ethers.ZeroAddress; // no arbitrator for simplicity
    const escrow = await escrowFactory.deploy(
      USDC_ADDR,
      wallet.address, // buyer
      "0xSellerAddressHere", // seller (known ahead of time)
      arb,
      ethers.id(serviceHash), // keccak256 of the service description
      Math.floor(Date.now() / 1000) + timeoutSec
    );
    await escrow.waitForDeployment();

    const amountWei = ethers.parseUnits(amountUSDC.toString(), 6); // USDC has 6 decimals
    await usdc.approve(await escrow.getAddress(), amountWei);
    const tx = await escrow.deposit();
    await tx.wait();
    console.log(`Escrow funded at ${await escrow.getAddress()}`);
    return escrow;
  }

  // ---------- Seller side ----------
  async function sellerFulfill(escrowAddress: string, proof: Uint8Array) {
    const escrow = new ethers.Contract(escrowAddress, ESCROW_ABI, wallet);
    const tx = await escrow.fulfill(proof);
    await tx.wait();
    console.log("Funds released to seller");
  }

  // Example usage:
  // const escrow = await buyerDeposit("generate-image:cat", 3600, 0.05); // $0.05 escrow, 1h timeout
  // // Seller does work off‑chain, creates proof (e.g., IPFS CID of the image)
  // const proof = new TextEncoder().encode("bafybeigdyrzt5wfp7ud7g27cyhhnb2x7aqt7e22sbckbw5jjjfoyjyp3zi");
  // await sellerFulfill(await escrow.getAddress(), proof);
}

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Explanation of the flow

  1. Buyer creates the escrow contract, passing the hash of the service description (serviceHash) and a timeout

Top comments (0)