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

An engineering‑focused walkthrough of building a payment‑guaranteed workflow for autonomous agents on Base (or any EVM‑compatible L2).


Why escrow matters for agent‑to‑agent work

When an AI agent offers a service — ​say, “summarize a PDF for $0.03” — ​the caller has no guarantee the agent will actually run the model and return a useful result. Conversely, the agent needs assurance that it will be paid after expending compute. Traditional APIs solve this with reputation scores, SLAs, or centralized billing, but those reintroduce trust points that defeat the purpose of a truly autonomous, censorship‑resistant marketplace.

A trustless escrow contract removes the need for a middle‑man by holding the payer’s USDC in a smart contract until a verifiable condition (e.g., a cryptographic proof of work) is satisfied. If the condition isn’t met within a timeout, the funds can be reclaimed; if it is, the agent can withdraw. The pattern is simple, but the devil is in the details: gas cost, oracle reliability, and dispute handling.

Below is a minimal, production‑ready escrow design that you can drop into an agent‑service scaffolding today.


1. Core contract – USDCiraEscrow.sol

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

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

/**
 * @notice Simple escrow for USDC payments between a requester and an agent.
 * @dev Funds are deposited by the requester. The agent can claim them only
 *      after presenting a valid off‑chain proof verified by the `verify`
 *      external function (implementation left to the integrator). A timeout
 *      allows the requester to recover funds if the agent never proves completion.
 */
contract USDCiraEscrow is ReentrancyGuard {
    IERC20 public immutable usdc;          // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    address public immutable requester;
    address public agent;                  // set upon first deposit
    uint256 public amount;                 // escrowed USDC (6 decimals)
    uint256 public deadline;               // block.timestamp after which requester can refund
    bool public claimed;                   // prevents double‑withdraw

    // Event for off‑chain indexing / debugging
    event Deposit(address indexed requester, address indexed agent, uint256 amount, uint256 deadline);
    event Claimed(address indexed agent, uint256 amount);
    event Refunded(address indexed requester, uint256 amount);

    /**
     * @param _usdc   Address of the USDC token contract (must be ERC‑20 compliant)
     * @param _timeout Seconds after which the requester can reclaim funds
     */
    constructor(address _usdc, uint256 _timeout) {
        require(_usdc != address(0), "Zero USDC");
        usdc = IERC20(_usdc);
        requester = msg.sender;
        // agent stays address(0) until first deposit
    }

    /**
     * @notice Fund the escrow. Caller must approve the contract to pull USDC.
     * @param _agent  The agent address that will eventually earn the funds.
     * @param _amt    Amount of USDC (in base units, i.e. 6 decimals) to escrow.
     */
    function deposit(address _agent, uint256 _amt) external nonReentrant {
        require(msg.sender == requester, "Only requester can deposit");
        require(_agent != address(0), "Agent zero address");
        require(_amt > 0, "Zero amount");
        require(agent == address(0), "Agent already set"); // enforce one‑to‑one mapping

        agent = _agent;
        amount = _amt;
        deadline = block.timestamp + _timeout;

        // Pull funds from requester
        usdc.transferFrom(msg.sender, address(this), _amt);

        emit Deposit(requester, agent, amount, deadline);
    }

    /**
     * @notice Agent calls this after performing work and generating a proof.
     * @dev The `verify` function is a hook; implementers must replace it with
     *      their own verification logic (e.g., zk‑SNARK, optimistic challenge, or
     *      a trusted oracle signature). It must return `true` only if the work
     *      is provably complete.
     * @param proof   Arbitrary calldata supplied by the agent for verification.
     */
    function claim(bytes calldata proof) external nonReentrant {
        require(msg.sender == agent, "Only agent can claim");
        require(!claimed, "Already claimed");
        require(block.timestamp <= deadline, "Expired; requester can refund");
        require(_verify(proof), "Invalid proof");

        claimed = true;
        usdc.transfer(agent, amount);
        emit Claimed(agent, amount);
    }

    /**
     * @notice Requester retrieves funds if the agent never proves completion.
     */
    function refund() external nonReentrant {
        require(msg.sender == requester, "Only requester can refund");
        require(block.timestamp > deadline, "Still within deadline");
        require(!claimed, "Already claimed");

        usdc.transfer(requester, amount);
        emit Refunded(requester, amount);
    }

    /**
     * @dev Placeholder for proof verification. Replace with your own logic.
     *      Must be pure or view (no state changes) to stay gas‑efficient.
     */
    function _verify(bytes calldata /*proof*/) internal view returns (bool) {
        // Example: always accept for demo purposes – **DO NOT USE IN PRODUCTION**
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

What the contract does (and doesn’t) do

Feature Implementation Trade‑off
Funds locked USDC transferred via transferFrom on deposit. Requires the requester to pre‑approve the contract (standard ERC‑20 flow).
Agent‑set once agent stored on first deposit; prevents malicious re‑deposit to a different agent. Limits flexibility; if you need to swap agents mid‑task you must redeploy.
Timeout refund deadline = block.timestamp + timeout. If blockchain latency spikes (rare on Base), the agent might lose a valid claim just before timeout; choose a timeout that comfortably exceeds worst‑case execution + proof submission time.
Proof verification hook _verify is a stub; you must implement. The verification step is where most security lives. A weak or centralized verifier defeats trustlessness. Options: (1) on‑chain zk‑SNARK verification, (2) optimistic challenge period with a bonded disputer, or (3) a trusted oracle signature (still introduces a trust point but can be mitigated via multi‑sig).
Reentrancy guard OpenZeppelin’s ReentrancyGuard. Minimal gas overhead; essential for any ERC‑20 handling.
No upgradeability Simplicity favors immutability. If you discover a bug you must redeploy; consider a proxy pattern only if you have a governance process.

2. Off‑chain agent workflow (TypeScript + ethers.js)

Below is a concise example of how an autonomous agent would interact with the escrow contract on Base. Assume the agent has:

  • A private key (or an AWS KMS‑derived signer) for paying gas.
  • Access to the USDC contract address on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913).
  • The escrow contract address (deployed once per job or reused via a factory).

ts
// agent-worker.ts
import { ethers } from "ethers";
import escrowAbi from "./USDCiraEscrow.json"; // ABI generated via solc or hardhat

// ==== CONFIGURATION ====
const RPC_URL = "https://mainnet.base.org"; // public Base RPC (or your own Infura/Alchemy)
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ADDRESS = "0xEscrowDeployedHere"; // replace with actual
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!; // never commit this
const JOB_TIMEOUT = 2 * 60 * 60; // 2 h window to finish + prove work
// =======================

const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDRESS, ["function approve(address spender, uint256 amount) returns (bool)"], wallet);
const escrow = new ethers.Contract(ESCROW_ADDRESS, escrowAbi, wallet);

/**
 * Called by the orchestrator when a job is posted.
 * @param requester The address that deposited funds.
 * @param amountUSDC Amount (in USDC, 6 decimals) to escrow.
 */
async function handleJob(requester: string, amountUSDC: number) {
  // 1️⃣ Approve escrow to pull USDC
Enter fullscreen mode Exit fullscreen mode

Top comments (0)