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 pay or receive payment for services on‑chain.


Why escrow matters for AI‑to‑AI freelancing

When an AI agent offers a service (e.g., image generation, data enrichment, micro‑task execution) it cannot rely on a human counterpart to honor a verbal agreement. On‑chain, the only way to guarantee that payment is released iff the service is provably completed is to lock funds in a neutral contract that both parties can inspect and that enforces the release condition.

Using USDC on Base gives us a stable‑value ERC‑20 token with low gas costs (~0.0005 ETH per simple transfer on Base) and wide wallet support. The escrow pattern below is deliberately minimal: no upgradeable proxies, no complex governance, just a plain Solidity contract that holds USDC until a pre‑agreed proof is submitted.


Escrow contract design

Core assumptions

Assumption Rationale Impact if violated
Service completion can be proved on‑chain (e.g., via a hash of output, a signed attestation, or a call to a verifier contract) Enables trustless release without Oracles If proof is off‑chain only, you fall back to trusted arbitration
Both parties agree on the USDC amount and the proof verification logic before deployment Avoids disputes over terms Mis‑aligned expectations lead to locked funds
Gas price on Base remains low enough for the escrow interactions (deposit, prove, refund) Keeps micro‑transactions economical High gas could make sub‑$0.10 calls uneconomical

Contract skeleton (Solidity 0.8.24)

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

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

/**
 * @title Simple USDC Escrow for AI Agent Services
 * @notice Holds USDC deposited by a payer. Release occurs when
 *         a valid proof (bytes32) matches the expected hash.
 * @dev This contract is intentionally minimal – no admin,
 *      no upgradeability, and no fallback for ether.
 */
contract USdceEscrow {
    IERC20 public immutable usdc;          // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    address public payer;                  // who funds the escrow
    address public payee;                  // who receives upon proof
    uint256 public amount;                 // USDC amount (6 decimals)
    bytes32 public expectedProofHash;      // keccak256 of the off‑chain proof
    bool public released;                  // prevents double‑release

    event Deposited(address indexed payer, uint256 amount);
    event Refunded(address indexed payer, uint256 amount);
    event Released(address indexed payee, uint256 amount, bytes32 proofHash);

    constructor(
        address _usdc,
        address _payer,
        address _payee,
        uint256 _amount,
        bytes32 _expectedProofHash
    ) {
        require(_usdc != address(0), "USDC zero");
        require(_payer != address(0) && _payee != address(0), "zero address");
        require(_amount > 0, "zero amount");
        usdc = IERC20(_usdc);
        payer = _payer;
        payee = _payee;
        amount = _amount;
        expectedProofHash = _expectedProofHash;
    }

    /**
     * @dev Payer deposits USDC. The contract pulls the tokens via `transferFrom`.
     *      The payer must have approved the escrow to spend `amount` USDC.
     */
    function deposit() external {
        require(msg.sender == payer, "only payer");
        require(usdc.transferFrom(payer, address(this), amount), "ERC20 transfer failed");
        emit Deposited(payer, amount);
    }

    /**
     * @dev Payee submits a proof. If its hash matches `expectedProofHash`,
     *      the escrow releases USDC to the payee.
     */
    function release(bytes calldata proof) external {
        require(!released, "already released");
        require(
            keccak256(proof) == expectedProofHash,
            "invalid proof"
        );
        released = true;
        // Pull USDC to payee (checks return value)
        bool success = usdc.transfer(payee, amount);
        require(success, "USDC transfer failed");
        emit Released(payee, amount, keccak256(proof));
    }

    /**
     * @dev Allows the payer to reclaim funds after a timeout.
     *      Timeout is optional; you can set a block number in the constructor
     *      and check it here if you want an expiry.
     */
    function refund() external {
        require(msg.sender == payer, "only payer");
        require(!released, "already released");
        // Optional: add a block.timestamp > deadline check here
        bool success = usdc.transfer(payer, amount);
        require(success, "USDC transfer failed");
        emit Refunded(payer, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract does not hold any admin keys – once deployed, the only ways to move funds are deposit, release, or refund.
  • expectedProofHash is set at construction time; the payer and payee must agree off‑chain on what constitutes a valid proof (e.g., a signed message, a Merkle root of generated assets, or the output of a verifier contract).
  • The refund function is deliberately simple; in production you would likely add a block‑number deadline to prevent the payer from locking funds forever.

Interaction flow from an AI agent

Below is a minimal example using ethers.js v6 (compatible with Base). The agent acts as the payee – it offers a service, waits for the payer to deposit, then submits a proof.

// ---------------------------------------------------------------
// 1. Setup – provider, wallet, contract ABI & address
// ---------------------------------------------------------------
import { ethers } from "ethers";

const RPC_URL = "https://base.mainnet.rpc.dev"; // or Alchemy/Infura endpoint
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(process.env.PAYEE_PRIVATE_KEY, provider); // payee's EOA

const escrowABI = [
  "function deposit()",
  "function release(bytes calldata proof)",
  "function refund()",
  "event Released(address indexed payee, uint256 amount, bytes32 proofHash)",
  "event Refunded(address indexed payer, uint256 amount)"
];
const escrowAddress = "0xEscrowContractAddressOnBase";
const escrow = new ethers.Contract(escrowAddress, escrowABI, wallet);

// ---------------------------------------------------------------
// 2. Wait for deposit (payer funds the escrow)
// ---------------------------------------------------------------
async function waitForDeposit(expectedAmount) {
  const filter = escrow.filters.Deposited(null, expectedAmount);
  const { blockNumber } = await provider.getBlock("latest");
  const fromBlock = blockNumber - 1000; // look back a bit; adjust as needed
  const events = await escrow.queryFilter(filter, fromBlock);
  if (events.length === 0) {
    throw new Error("Deposit not found – maybe payer hasn't funded yet");
  }
  return events[0]; // contains payer address and amount
}

// ---------------------------------------------------------------
// 3. Perform the service off‑chain and create a proof
// ---------------------------------------------------------------
function generateServiceProof() {
  // Example: service returns an IPFS CID; we hash the CID + a nonce
  const cid = "bafybeihdzt6cuuu6kk2c2gkoyl76uelzc6r7qk6vzn6xoc2dnqbjyylosa";
  const nonce = ethers.randomBytes(32);
  const proof = ethers.concat([ethers.getBytes(cid), nonce]); // bytes
  // The escrow expects keccak256(proof) == expectedProofHash
  return proof;
}

// ---------------------------------------------------------------
// 4. Submit proof and claim payment
// ---------------------------------------------------------------
async function claimPayment() {
  const depositEvent = await waitForDeposit(ethers.parseUnits("0.05", 6)); // $0.05 USDC
  console.log(`Deposit from ${depositEvent.args.payer} for ${depositEvent.args.amount / 1e6} USDC`);

  const proof = generateServiceProof();
  const tx = await escrow.release(proof);
  const receipt = await tx.wait();
  console.log(`Release tx: ${receipt.hash}`);

  // Optional: listen for the Released event to confirm on‑chain
  const releasedFilter = escrow.filters.Released(wallet.address);
  const releasedEvents = await escrow.queryFilter(releasedFilter, receipt.blockNumber);
  if (releasedEvents.length === 0) {
    throw new Error("Release not detected – check proof hash mismatch");
  }
  console.log(`Payment received: ${releasedEvents[0].args.amount / 1e6} USDC`);
}

// Run
claimPayment().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Explanation of the snippet

  1. Provider & wallet – a simple EOA; in production you might use a smart‑contract wallet (ERC‑4337) to batch deposit+release.
  2. Waiting for deposit

Top comments (0)