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 monetize their services without a middleman.


Why escrow matters for agent‑to‑agent payments

When an AI agent offers a service (e.g., text summarization, image classification, data lookup) it must know two things before it spends compute cycles:

  1. The caller has paid – otherwise the agent is providing free work.
  2. The payment is recoverable if the service fails – otherwise the caller loses funds with no recourse.

A simple “pay‑then‑call” model fails on (2); a “call‑then‑pay” model fails on (1). Escrow solves both by locking funds in a neutral contract that releases them only when predefined conditions are met. On‑chain escrow is attractive because it is trustless: no party needs to trust the other, only the smart contract code.

On the Base layer‑2 (an Optimistic Rollup backed by Ethereum), USDC is a native ERC‑20 with low transaction fees (≈ $0.001) and fast finality (~2 seconds). The x402 protocol extends HTTP with a Payment header that carries a signed claim to escrowed USDC, letting agents verify payment without changing their existing API surface.

Below we walk through a minimal, production‑ready pattern:

  1. Escrow contract – holds USDC until the agent signals success or failure.
  2. Agent side – verifies the x402 header, calls the escrow contract, performs work, then calls release or refund.
  3. Caller side – builds the x402 header, sends the request, and handles the response.

1. The escrow contract (Solidity)

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

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

contract AgentEscrow {
    IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    address public agent;        // the AI agent that will earn the funds
    address public payer;        // who funded the escrow
    uint256 public amount;       // locked USDC (in wei, 6 decimals)
    bool public released;        // prevents double‑release

    constructor(address _usdc, address _agent, address _payer, uint256 _amount) {
        require(_usdc != address(0), "USDC addr");
        require(_agent != address(0), "Agent addr");
        require(_payer != address(0), "Payer addr");
        require(_amount > 0, "Zero amount");
        usdc = IERC20(_usdc);
        agent = _agent;
        payer = _payer;
        amount = _amount;
        // Pull funds from payer into the contract
        usdc.transferFrom(payer, address(this), amount);
    }

    /// @notice Agent calls this after successfully completing the job
    function release() external {
        require(msg.sender == agent, "Only agent");
        require(!released, "Already released");
        released = true;
        usdc.transfer(agent, amount);
    }

    /// @notice Either party can call this if the job fails or times out
    function refund() external {
        require(!released, "Already released");
        // Allow either agent or payer to trigger a refund; in practice
        // you may want a timeout or dispute resolver.
        usdc.transfer(payer, amount);
    }

    /// @notice Helper for callers to check if escrow is funded
    function funded() external view returns (bool) {
        return usdc.balanceOf(address(this)) == amount;
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The constructor pulls the exact USDC amount from the payer’s wallet into the contract.
  • Only the agent can call release(); anyone can trigger refund() (you could replace this with a timelock or arbitration contract for stricter policies).
  • USDC on Base has 6 decimals; the contract treats the amount as raw units (no division needed).

Deploy this contract once per job, or use a factory that clones it via the ERC‑1167 minimal proxy pattern to keep gas costs low (~ 45 k gas for a clone on Base).


2. Agent verification & workflow (TypeScript + ethers.js)

Assume the agent exposes an HTTP POST /summarize. The caller must include an x402 header:

x402: 1
payment: <base64-url-encoded-payment-token>
Enter fullscreen mode Exit fullscreen mode

The payment token is defined by the x402 spec: it encodes the escrow contract address, the chain ID, the amount, and a signature from the payer’s wallet.

Helper: decode and validate the token

import { ethers } from "ethers";
import { keccak256, toUtf8Bytes, defaultAbiCoder } from "ethers/lib/utils";

// USDC contract address on Base (mainnet)
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
// Base chain ID
const BASE_CHAIN_ID = 8453;

// Minimal ABI for the escrow contract (only need the address check)
const ESCROW_ABI = [
  "function funded() view returns (bool)",
  "function release()",
];

/**
 * Verify that the x402 payment token corresponds to a funded escrow
 * that will pay `agentAddress` the expected amount.
 */
async function verifyX402Payment(
  token: string,
  agentAddress: string,
  expectedAmount: number // in USDC, e.g. 0.05
): Promise<{ escrow: string; payer: string } | null> {
  try {
    const raw = Buffer.from(token, "base64url").toString("utf8");
    // token format: `<escrowAddr>:<chainId>:<amount>:<signature>`
    const [escrowAddr, chainIdStr, amountStr, signature] = raw.split(":");
    if (Number(chainIdStr) !== BASE_CHAIN_ID) return null;

    const amountWei = ethers.parseUnits(amountStr, 6); // USDC has 6 decimals
    if (amountWei !== ethers.parseUnits(expectedAmount.toString(), 6))
      return null;

    // Recover signer from the signed message
    const message = ethers.getBytes(
      `${escrowAddr.toLowerCase()}|${BASE_CHAIN_ID}|${amountWei}`
    );
    const signer = ethers.recoverAddress(
      keccak256(message),
      signature
    );

    // Load escrow contract and confirm it's funded & set to pay this agent
    const escrow = new ethers.Contract(escrowAddr, ESCROW_ABI, new ethers.JsonRpcProvider(
      "https://base.mainnet.rpc.cloud"
    ));
    const funded = await escrow.funded();
    if (!funded) return null;

    // Optionally, you could read the `agent` storage variable if you expose it.
    // For simplicity we trust the payer to have funded the correct contract.
    return { escrow: escrowAddr, payer: signer };
  } catch (e) {
    console.error("x402 verification failed:", e);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Express‑style handler


ts
import express from "express";
import { ethers } from "ethers";

const app = express();
app.use(express.json());

const AGENT_WALLET = "0xYourAgentWallet..."; // must match escrow constructor
const USDC_AMOUNT = 0.05; // $0.05 per summarization request

app.post("/summarize", async (req, res) => {
  const auth = req.headers["payment"] as string | undefined;
  if (!auth) {
    return res.status(402).json({ error: "Missing x402 payment header" });
  }

  const verification = await verifyX402Payment(auth, AGENT_WALLET, USDC_AMOUNT);
  if (!verification) {
    return res.status(402).json({ error: "Invalid or expired payment" });
  }

  try {
    // ----- DO THE ACTUAL WORK -----
    const { text } = req.body;
    if (!typeof text === "string" || text.length === 0) {
      throw new Error("Missing 'text' field");
    }
    // Placeholder: call your model or external API
    const summary = await callSummarizationModel(text);
    // --------------------------------

    // If we reach here, the job succeeded → release escrow
    const escrowContract = new ethers.Contract(
      verification.escrow,
      ["function release()"],
      new ethers.Wallet(AGENT_WALLET, new ethers.JsonRpcProvider("https://base.mainnet.rpc.cloud"))
    );
    const tx = await escrowContract.release();
    await tx.wait();

    res.json({ summary });
  } catch (err) {
    // On any failure, allow refund (you could also implement a dispute timer)
    const escrowContract = new ethers.Contract(
      verification.escrow,
      ["function refund()"],
      new ethers.Wallet(AGENT_WALLET,
Enter fullscreen mode Exit fullscreen mode

Top comments (0)