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

For developers building autonomous AI agents


1. Why escrow matters for AI‑to‑human freelancing

Autonomous agents can publish services, receive requests, and deliver work without a human in the loop. The remaining friction is payment: an agent must be confident it will be paid before it spends compute, and the requester must be sure the work will be delivered before they part with funds. On‑chain escrow solves this by locking funds in a deterministic contract that releases only when verifiable conditions are met.

The most common pattern today is:

  1. Requester deposits USDC into an escrow contract.
  2. Agent performs the job off‑chain and produces a cryptographic proof (e.g., a signed hash of the output).
  3. Escrow verifies the proof and releases the USDC to the agent (or refunds the requester on failure).

When the escrow contract follows the x402 payment‑protocol spec, the interaction becomes a single HTTP‑like request/response that carries a payment header, making it easy to embed in existing agent frameworks.


2. Core components of an x402‑enabled escrow

Component Responsibility On‑chain / Off‑chain
ERC‑20 USDC token Holds value; approved by escrow On‑chain (Base)
Escrow contract Locks funds, validates proof, releases/refunds On‑chain
Agent service Does work, returns signed result + payment request Off‑chain (serverless, worker, etc.)
Requester client Initiates request, pays via x402 header, verifies output Off‑chain (SDK or fetch)
Validator (optional) Third‑party oracle that attests to correctness for subjective jobs Can be on‑chain or off‑chain

The escrow contract is deliberately minimal: it only needs to know (a) the amount of USDC locked, (b) the expected payment amount, (c) the address that will receive the payout, and (d) a hash of the agreed‑upon work description. The actual work verification happens off‑chain; the contract only checks that a supplied signature matches the hash and that the signer is the authorized agent.


3. Minimal escrow contract (Solidity, Base‑compatible)

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

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

/// @notice Simple escrow for USDC payments under x402
contract USDCедиow is Ownable {
    IERC20 public immutable usdc;
    uint256 public price;          // price in USDC (6 decimals on Base)
    address public payee;          // agent that will receive funds
    bytes32 public workHash;       // keccak256(description || nonce)
    bool   public released;        // prevents double payout

    constructor(
        address _usdc,
        uint256 _price,
        address _payee,
        bytes32 _workHash
    ) Ownable(msg.sender) {
        usdc = IERC20(_usdc);
        price = _price;
        payee = _payee;
        workHash = _workHash;
    }

    /// @notice Fund the escrow. Must be called by the requester before work starts.
    /// @dev The caller must first `approve` the escrow to spend `price` USDC.
    function deposit() external {
        require(usdc.transferFrom(msg.sender, address(this), price), "Transfer failed");
    }

    /// @notice Agent calls this after completing work.
    /// @param sig   ECDSA signature of `workHash` by the agent.
    /// @dev Uses EIP‑191 personal sign format; recover signer and compare to `payee`.
    /// @dev Reverts if payment already released or signature invalid.
    function release(bytes memory sig) external {
        require(!released, "Already released");
        require(usdc.balanceOf(address(this)) >= price, "Insufficient funds");

        // Recover signer from signature
        address signer = ecrecover(
            keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", workHash)),
            0x1F & sig[64],
            bytes20(sig[0..31]),
            bytes20(sig[32..63])
        );
        require(signer == payee, "Invalid signature");

        released = true;
        usdc.transfer(payee, price);
    }

    /// @notice Requester can reclaim funds if the agent never releases.
    function refund() external {
        require(!released, "Already released");
        require(msg.sender == owner(), "Only requester");
        usdc.transfer(msg.sender, usdc.balanceOf(address(this)));
    }

    /// @owner can change the payee (useful for rotating agent keys)
    function updatePayee(address newPayee) external onlyOwner {
        payee = newPayee;
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract holds USDC only after deposit() is called; the requester must first approve the escrow to spend the exact amount (usdc.approve(address(escrow), price)).
  • release() expects an EIP‑191 signature over the pre‑agreed workHash. The hash can include a nonce or timestamp to prevent replay attacks.
  • The escrow is non‑custodial beyond the time the funds are locked; no admin can siphon money without the requester’s explicit approval.
  • Deployment cost on Base (as of late‑2025) is ≈ 0.0005 ETH (~$0.80) – negligible compared to typical freelance fees.

4. Agent side: signing and requesting payment (TypeScript/Node)

// agent.js – runs inside a serverless function or long‑lived worker
import { ethers } from "ethers";
import fetch from "node-fetch";

// Configuration (filled at deploy time)
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base USDC
const ESCROW_ADDRESS = "0xEscrow..."; // address of deployed contract
const AGENT_PRIVATE_KEY = process.env.AGENT_PK!;
const PRICE_USDC = 5_000_000n; // $5.00 (USDC has 6 decimals)

const provider = new ethers.JsonRpcProvider("https://base.mainnet.rpc.cloud");
const wallet = new ethers.Wallet(AGENT_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,
  [
    "function deposit()",
    "function release(bytes sig)",
    "function workHash() view returns (bytes32)",
    "function price() view returns (uint256)",
    "function payee() view returns (address)"
  ],
  wallet
);

// ------------------------------------------------------------------
// 1️⃣ Approve escrow to spend USDC (only needed once per session)
async function approveEscrow() {
  const allowance = await usdc.allowance(wallet.address, ESCROW_ADDRESS);
  if (allowance < PRICE_USDC) {
    const tx = await usdc.approve(ESCROW_ADDRESS, PRICE_USDC);
    await tx.wait();
  }
}

// 2️⃣ Deposit funds (requester does this; agent just checks)
async function ensureFunded() {
  const bal = await usdc.balanceOf(ESCROW_ADDRESS);
  if (bal < PRICE_USDC) throw new Error("Escrow not funded");
}

// 3️⃣ Perform the job (example: translate text)
async function doWork(input: string): Promise<string> {
  // placeholder for real AI work – could be an LLM call, image gen, etc.
  return input.toUpperCase(); // dummy transformation
}

// 4️⃣ Build the payment proof and call release()
async function claimPayment(jobId: string, workResult: string) {
  const workHash = await escrow.workHash(); // set by requester before deposit
  // The hash must be keccak256(workDescription || jobId) – both parties agree.
  const messageHash = ethers.getBytes(workHash);
  const signature = await wallet.signMessage(messageHash); // returns 0x + r + s + v

  const tx = await escrow.release(signature);
  await tx.wait();
  console.log(`Payment claimed: ${tx.hash}`);
}

// ------------------------------------------------------------------
// Main handler (e.g., Vercel/Cloudflare Workers entry point)
export default async function handler(req: Request) {
  const { jobId, input } = await req.json(); // sent by requester via x402 header
  await approveEscrow();
  await ensureFunded();

  const result = await doWork(input);
  await claimPayment(jobId, result);

  // Return result to requester; they can verify locally if needed
  return new Response(JSON.stringify({ result }), {
    headers: { "Content-Type": "application/json" },
  });
}
Enter fullscreen mode Exit fullscreen mode

What the code does

  1. Approves the escrow to pull the exact USDC amount (once).
  2. Confirms the escrow already holds funds (the requester’s responsibility).
  3. Does the work off‑chain (any AI model, API call, etc.).
  4. Signs the pre‑agreed workHash with the agent’s key and calls release().
  5. Returns the work

Top comments (0)