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

By a senior engineer – no hype, just the mechanics.


Why escrow matters for autonomous agents

When an AI agent offers a paid service (e.g., “summarize a PDF for $0.03”), the client needs assurance that payment will be released only after the work is verifiably completed. Conversely, the agent must know it won’t be stiffed after doing the work. Traditional APIs solve this with centralized billing, but that re‑introduces a single point of failure and requires KYC/AML overhead that many agent developers want to avoid.

A trustless escrow built on a programmable ERC‑20 token (USDC) lets both parties lock funds in a smart contract that releases them only when a pre‑agreed condition is satisfied—typically a signed receipt or an on‑chain proof of work. The pattern works on any EVM‑compatible chain; the examples below use Base because it offers low gas fees and native USDC support.


The escrow contract – a minimal, auditable design

We’ll start with a Solidity contract that is intentionally tiny: no upgradeability, no governance, just the escrow logic. This keeps audit surface small and gas costs predictable.

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

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

contract USCEscrow {
    IERC20 public immutable usdc;               // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    address public payer;                       // who funds the escrow
    address public payee;                       // who receives the payout (the agent)
    uint256 public amount;                      // escrowed USDC (6 decimals)
    bool public released;                       // prevents double‑release

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

    /** Called by the payer to lock funds. */
    function deposit() external payable {
        require(msg.sender == payer, "only payer");
        require(usdc.transferFrom(payer, address(this), amount), "transfer failed");
    }

    /** Called by the payee when they have proof of work. */
    function release(bytes calldata proof) external {
        require(msg.sender == payee, "only payee");
        require(!released, "already released");
        // In a real system you would verify `proof` against an oracle or zk‑snark.
        // For this example we accept any non‑empty proof as a placeholder.
        require(proof.length > 0, "empty proof");
        released = true;
        usdc.transfer(payee, amount);
    }

    /** Allows the payer to reclaim funds if the payee never releases. */
    function refund() external {
        require(msg.sender == payer, "only payer");
        require(!released, "already released");
        // Simple timeout: after 7 days the payer can pull back.
        require(block.timestamp >= 7 days, "too early");
        usdc.transfer(payer, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

What this contract gives you

Property How it’s achieved Trade‑off
Atomic lock‑up deposit() transfers USDC from payer to contract before any work starts. Requires the payer to have USDC and approve the contract (standard ERC‑20 flow).
Conditional release release() only succeeds if the payee supplies a proof and hasn’t already released. Proof verification is left to the developer; a weak proof (e.g., any non‑empty bytes) defeats the purpose.
Refund safety net After a timeout (refund()), the payer can reclaim funds. Locks capital for the timeout period; choosing the timeout is a UX vs. risk decision.
Low gas Only a few storage writes and one ERC‑20 transfer per action. No upgradability; if a bug is found you must deploy a new contract and migrate funds.

From contract to agent – the workflow

Below is a TypeScript snippet that shows how an autonomous agent (running on Node.js or a Cloudflare Worker) would:

  1. Publish an offer (price, service description, escrow contract address).
  2. Wait for a client to deposit.
  3. Perform the work (here a dummy summarisation).
  4. Generate a proof (a simple SHA‑256 of the output + a nonce).
  5. Call release() to claim payment.
// agent.ts – runs inside the AI agent environment
import { ethers } from "ethers";
import { USCEscrow__factory } from "./typechain"; // generated via ethers
import crypto from "crypto";

// ---- CONFIGURATION -------------------------------------------------
const RPC_URL = "https://mainnet.base.org";          // Base mainnet RPC
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!; // agent's EOA
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ARTIFACT = require("./artifacts/contracts/USCEscrow.sol/USCEscrow.json");
// ------------------------------------------------------------------

const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDRESS, ["function balanceOf(address) view returns (uint256)"], wallet);

// Helper: load or deploy escrow (in practice you’d pre‑deploy a template and clone via CREATE2)
async function getEscrow(payer: address, amount: bigint): Promise<USCEscrow> {
    const escrow = new ethers.Contract(
        // deterministic address derived from payer, agent, amount, and a salt
        ethers.getCreate2Address(
            ethers.getAddress("0x00000000000000000000000000000000000000FF"), // factory placeholder
            ethers.keccak256(
                ethers.defaultAbiCoder.encode(
                    ["address", "address", "uint256", "bytes32"],
                    [payer, wallet.address, amount, ethers.zeroPadValue(ethers.toBeHex(0x1234), 32)]
                )
            ),
            ethers.keccak256(ethers.toUtf8Bytes("USCEscrow_v1"))
        ),
        USCEscrow_artifact.abi,
        wallet
    );
    return escrow as USCEscrow;
}

// ---- SERVICE LOGIC -------------------------------------------------
async function summarizePdf(buffer: Buffer): Promise<string> {
    // In a real agent you’d call a model (e.g., Llama3) and return the text.
    // Here we just pretend.
    return "Summary: ...";
}

// ---- MAIN LOOP -----------------------------------------------------
export async function handleRequest(req: Request) {
    // 1️⃣ Parse client intent: { payer, amount, pdfBase64 }
    const { payer, amount, pdfBase64 } = await req.json();
    const amtWei = ethers.parseUnits(amount.toString(), 6); // USDC has 6 decimals

    // 2️⃣ Get (or create) escrow contract
    const escrow = await getEscrow(payer, amtWei);
    // Ensure funds are deposited; revert if not.
    const deposited = await escrow.deposit();
    await deposited.wait();

    // 3️⃣ Do the work
    const pdfBuf = Buffer.from(pdfBase64, "base64");
    const summary = await summarizePdf(pdfBuf);

    // 4️⃣ Build a proof: hash of output + a random nonce (prevents replay)
    const nonce = ethers.randomBytes(32);
    const proof = ethers.keccak256(ethers.concat([ethers.toUtf8Bytes(summary), nonce]));

    // 5️⃣ Release funds
    const tx = await escrow.release(proof);
    const receipt = await tx.wait();

    // 6️⃣ Return result to client + tx hash for transparency
    return new Response(
        JSON.stringify({
            summary,
            escrowTx: receipt.hash,
            proof,
            nonce: ethers.encodeBase64(nonce)
        }),
        { headers: { "Content-Type": "application/json" } }
    );
}
Enter fullscreen mode Exit fullscreen mode

What the code actually does

  • Escrow creation via CREATE2 – guarantees the client can compute the exact contract address before sending funds, removing the need for a separate registration transaction.
  • Deposit check – the agent waits for the deposit() transaction to be mined; if the payer never funds, the agent times out and walks away.
  • Proof‑of‑work – a hash of the returned summary plus a nonce. In production you’d replace this with a verifiable computation (e.g., a zk‑SNARK proving the

Top comments (0)