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

Building autonomous AI agents that can sell services without a human intermediary is attractive, but the devil is in the details: you need a mechanism that guarantees payment only when the work is verifiably done, and you need to keep gas costs and latency within reason for frequent micro‑transactions. Below is a practical walk‑through of a minimal escrow pattern that uses USDC on Base, plus the trade‑offs you’ll encounter when you try to put it into production.


1. The escrow contract in a nutshell

The goal is simple: a client locks USDC in a contract, the agent performs an off‑chain task, provides a cryptographic proof that the task is finished, and the contract releases the funds to the agent. If the agent fails to provide proof within a timeout, the client can reclaim the deposit.

A stripped‑down version looks like this (Solidity 0.8.20, OpenZeppelin ERC20 and ReentrancyGuard):

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

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

contract USDEscrow is ReentrancyGuard {
    IERC20 public immutable usdc;          // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    address public immutable agent;        // set at deployment
    address public client;                 // setter after deployment
    uint256 public amount;                 // locked amount
    bool public deposited;                 // true after client funds escrow
    uint256 public deadline;               // block.timestamp after which client can withdraw
    bytes32 public proofHash;              // keccak256(off‑chain result) supplied by agent

    event Deposited(address indexed from, uint256 value);
    event ProofSubmitted(bytes32 hash);
    event Released(address indexed to, uint256 amount);
    event Refunded(address indexed to, uint256 amount);

    constructor(address _usdc, address _agent) {
        usdc = IERC20(_usdc);
        agent = _agent;
    }

    /// @notice Client funds the escrow. Must approve USDC to this contract first.
    function deposit() external nonReentrant {
        require(!deposited, "already deposited");
        require(msg.value == 0, "send USDC via ERC20, not native");
        uint256 depositAmount = usdc.balanceOf(address(this)); // will be zero before transfer
        // The caller must have called usdc.approve(address(this), amount) beforehand.
        usdc.transferFrom(msg.sender, address(this), amount);
        deposited = true;
        client = msg.sender;
        deadline = block.timestamp + 7 days; // example timeout
        emit Deposited(msg.sender, amount);
    }

    /// @notice Agent sets the amount they expect to receive before the client deposits.
    /// Only callable by the agent before deposit.
    function setAmount(uint256 _amount) external {
        require(msg.sender == agent, "only agent");
        require(!deposited, "cannot change after deposit");
        amount = _amount;
    }

    /// @notice Agent submits a hash of the off‑chain result.
    /// The hash is computed off‑chain (e.g., keccak256(IPFS CID || timestamp)).
    function submitProof(bytes32 _proofHash) external nonReentrant {
        require(msg.sender == agent, "only agent");
        require(deposited, "no deposit yet");
        require(block.timestamp <= deadline, "deadline passed");
        proofHash = _proofHash;
        emit ProofSubmitted(_proofHash);
    }

    /// @notice Client releases payment to the agent if a valid proof is present.
    function release() external nonReentrant {
        require(msg.sender == client, "only client");
        require(deposited, "nothing to release");
        require(block.timestamp <= deadline, "still within dispute window");
        require(proofHash != bytes32(0), "no proof submitted");
        usdc.transfer(agent, amount);
        deposited = false; // prevent re‑entry
        emit Released(agent, amount);
    }

    /// @notice Client reclaims funds if the agent never submits proof before deadline.
    function refund() external nonReentrant {
        require(msg.sender == client, "only client");
        require(deposited, "nothing to refund");
        require(block.timestamp > deadline, "deadline not reached");
        usdc.transfer(client, amount);
        deposited = false;
        emit Refunded(client, amount);
    }

    /// @notice Helper for the client to know how much to approve.
    function getDepositAmount() external view returns (uint256) {
        return amount;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this shape?

  • Minimal trust – The contract never holds any private keys; it only moves USDC based on immutable rules.
  • Atomicity – Funds move only when the release() or refund() transaction succeeds, eliminating the “pay‑then‑hope” problem.
  • Timeout – A 7‑day window (adjustable) gives the agent enough time to compute a result while preventing funds from being locked forever.

2. Deploying and initializing the escrow

You’ll need the USDC address on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) and the agent’s wallet address. The agent first calls setAmount() to advertise the price, then the client funds the contract.

Deploy script (Hardhat/ethers.js):

// deploy.js
async function main() {
  const [deployer, agent] = await ethers.getSigners();
  const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";

  const Escrow = await ethers.getContractFactory("USDEscrow");
  const escrow = await Escrow.deploy(USDC, agent.address);
  await escrow.waitForDeployment();

  console.log("Escrow deployed to:", escrow.target);
}
Enter fullscreen mode Exit fullscreen mode

After deployment, the agent sets the price:

// setPrice.js
async function main() {
  const [agent] = await ethers.getSigners();
  const escrowAddress = "0xEscrow..."; // from previous step
  const escrow = await ethers.getContractAt("USDEscrow", escrowAddress, agent);

  const price = ethers.parseUnits("0.05", 6); // USDC has 6 decimals
  const tx = await escrow.setAmount(price);
  await tx.wait();
  console.log("Price set to", ethers.formatUnits(price, 6), "USDC");
}
Enter fullscreen mode Exit fullscreen mode

3. Client side: funding, requesting work, and releasing

Assume the agent offers a text‑summarization service. The client will:

  1. Approve the escrow contract to spend USDC.
  2. Call deposit() (which internally pulls the pre‑approved amount).
  3. Send the raw text to the agent via any off‑chain channel (e.g., HTTP, WebSocket, or IPFS).
  4. Wait for the agent to submit a proof (IPFS CID of the summary + timestamp).
  5. Call release() once the proof appears on‑chain.

Client implementation (ethers.js + etherscan‑style ABI):


javascript
// client.js
import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider("https://base.mainnet.rpc.url");
const usdcAbi = ["function approve(address spender, uint256 amount) uint256"];
const escrowAbi = [
  "function setAmount(uint256)",
  "function deposit()",
  "function getDepositAmount() view returns (uint256)",
  "function submitProof(bytes32)",
  "function release()",
  "function proofHash() view returns (bytes32)",
  "function deadline() view returns (uint256)",
];

async function main() {
  const [client] = await ethers.getSigners();
  const usdc = new ethers.Contract(
    "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    usdcAbi,
    client
  );
  const escrow = new ethers.Contract(
    "0xEscrow...",
    escrowAbi,
    client
  );

  // 1. Approve escrow to pull USDC
  const amount = ethers.parseUnits("0.05", 6); // must match agent's price
  const approveTx = await usdc.approve(escrow.target, amount);
  await approveTx.wait();

  // 2. Fund escrow (the escrow's setAmount must have been called already)
  const depositTx = await escrow.deposit();
  await depositTx.wait();
  console.log("Escrow funded");

  // 3. Off‑chain: send work to agent (example using fetch)
  const workPayload = {
Enter fullscreen mode Exit fullscreen mode

Top comments (0)