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 compensation for micro‑services without relying on a centralized intermediary.


Why Escrow Matters for Agent‑to‑Agent Payments

AI agents often expose HTTP‑callable functions (e.g., “summarize text”, “classify image”) and expect payment per invocation. In a traditional SaaS model the provider trusts the platform to collect funds and remit them later. For fully autonomous agents that trust model breaks down:

  • Counterparty risk – an agent can’t sue a non‑paying client.
  • Settlement latency – waiting for invoices or manual payouts defeats the purpose of real‑time automation.
  • Opacity – agents can’t verify that the correct amount was transferred without inspecting a ledger.

An on‑chain escrow solves these by locking funds in a immutable contract before work begins and releasing them only when predefined conditions are met. USDC is a natural choice because it’s a regulated, 1:1 USD‑pegged ERC‑20 token with low volatility and broad wallet support.


Core Design Principles

  1. Minimal on‑chain footprint – the escrow contract should be cheap to deploy and interact with (≤ 30 k gas for deposit, ≤ 50 k for release).
  2. Deterministic release criteria – either a signed proof from the agent (off‑chain verification) or a timeout that returns funds to the payer.
  3. No external oracle dependence – the contract does not need price feeds; it works purely with token amounts.
  4. Upgrade‑safe – use the OpenZeppelin Upgradeable pattern only if you truly need it; otherwise keep the contract immutable to avoid admin keys.

Escrow Contract (Solidity, Solidity 0.8.20)

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

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

/**
 * @title SimpleUSDCheed
 * @notice Holds USDC deposited by a payer and releases it to an agent
 *         when a valid off‑chain signature is presented, or after a timeout.
 */
contract SimpleUSDCeed is Ownable {
    IERC20 public immutable usdc;          // USDC token address (set at deploy)
    address public payer;                  // Who funds the escrow
    address public agent;                  // Who can claim the funds
    uint256 public amount;                 // Amount of USDC locked (in wei, 6 decimals)
    uint256 public deadline;               // Unix timestamp after which payer can reclaim
    bool public released;                  // Prevent double‑release

    // EIP‑712 domain separator for off‑chain signatures
    bytes32 public constant DOMAIN_SEPARATOR =
        keccak256(
            abi.encode(
                keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
                keccak256(bytes("USDC Escrow")),
                keccak256(bytes("1")),
                block.chainid,
                address(this)
            )
        );
    bytes32 public constant HASHED_TYPE = keccak256(
        bytes("Claim(address agent,uint256 amount,uint256 deadline)")
    );

    constructor(
        address _usdc,
        address _payer,
        address _agent,
        uint256 _amount,
        uint256 _timeoutSeconds
    ) {
        require(_usdc != address(0), "Zero token");
        require(_payer != address(0) && _agent != address(0), "Zero address");
        require(_amount > 0, "Zero amount");
        usdc = IERC20(_usdc);
        payer = _payer;
        agent = _agent;
        amount = _amount;
        deadline = block.timestamp + _timeoutSeconds;
    }

    /**
     * @notice Payer funds the escrow. Must be called after deployment.
     *         The caller must approve the contract to spend `amount` USDC.
     */
    function deposit() external {
        require(msg.sender == payer, "Not payer");
        require(usdc.allowance(payer, address(this)) >= amount, "Insufficient allowance");
        usdc.transferFrom(payer, address(this), amount);
    }

    /**
     * @notice Agent presents an EIP‑712 signature proving they completed the work.
     *         The signature is over (agent, amount, deadline) to prevent replay.
     */
    function claim(bytes calldata sig) external {
        require(!released, "Already released");
        require(block.timestamp <= deadline, "Deadline passed");
        (uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);
        bytes32 structHash = keccak256(abi.encode(HASHED_TYPE, agent, amount, deadline));
        bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
        address signer = ecrecover(digest, v, r, s);
        require(signer == agent, "Invalid signature");
        released = true;
        usdc.transfer(agent, amount);
    }

    /**
     * @notice Payer can reclaim funds after the deadline if the agent never claimed.
     */
    function refund() external {
        require(msg.sender == payer, "Not payer");
        require(block.timestamp > deadline, "Deadline not reached");
        require(!released, "Already released");
        released = true;
        usdc.transfer(payer, amount);
    }

    /* Helper to split signature into v, r, s */
    function splitSignature(bytes memory sig)
        internal
        pure
        returns (uint8 v, bytes32 r, bytes32 s)
    {
        require(sig.length == 65, "Invalid signature length");
        assembly {
            r := mload(add(sig, 0x20))
            s := mload(add(sig, 0x40))
            v := byte(0, mload(add(sig, 0x60)))
        }
        // Adjust v to 0 or 1 for ecrecover
        if (v < 27) {
            v += 27;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • The contract is ownerless after deployment – only the predefined payer and agent can act.
  • deposit() must be called by the payer after they have approved the contract to spend USDC (usdc.approve(address(escrow), amount)).
  • The agent supplies an EIP‑712 signed message that ties the claim to the specific escrow instance (agent, amount, deadline). This prevents replay across different escrows.
  • If the agent never signs before deadline, the payer can call refund() and recover the full amount.

Agent‑Side Implementation (TypeScript / ethers.js)

Below is a minimal worker that:

  1. Listens for an incoming HTTP request (e.g., via Cloudflare Workers).
  2. Performs the promised AI micro‑service.
  3. Produces an EIP‑712 signature authorizing the escrow release.
  4. Returns the signature to the caller, who then submits it on‑chain.

ts
import { ethers } from "ethers";

// --- CONFIG (set via environment variables) ---
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const ESCROW_ADDRESS = process.env.ESCROW_ADDRESS!; // deployed SimpleUSDCeed
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!; // agent's EOA
const CHAIN_ID = 8453; // Base
// ------------------------------------------------

const provider = new ethers.JsonRpcProvider(
  "https://mainnet.base.org" // replace with your preferred RPC
);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const escrow = new ethers.Contract(
  ESCROW_ADDRESS,
  [
    "function claim(bytes sig) external",
    "function agent() view returns (address)",
    "function amount() view returns (uint256)",
    "function deadline() view returns (uint256)",
  ],
  wallet
);

// EIP‑712 domain (must match the contract)
const DOMAIN = {
  name: "USDC Escrow",
  version: "1",
  chainId: CHAIN_ID,
  verifyingContract: ESCROW_ADDRESS,
};
const types = {
  Claim: [
    { name: "agent", type: "address" },
    { name: "amount", type: "uint256" },
    { name: "deadline", type: "uint256" },
  ],
};

export default {
  async fetch(request: Request): Promise<Response> {
    // 1️⃣ Parse incoming payload (example: { prompt: "Summarize this text..." })
    const { prompt } = await request.json();

    // 2️⃣ Perform AI work – replace with your model call
    const result = await callMyModel(prompt); // returns string or json

    // 3️⃣ Build
Enter fullscreen mode Exit fullscreen mode

Top comments (0)