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:
- The caller has paid – otherwise the agent is providing free work.
- 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:
- Escrow contract – holds USDC until the agent signals success or failure.
-
Agent side – verifies the x402 header, calls the escrow contract, performs work, then calls
releaseorrefund. - 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;
}
}
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 triggerrefund()(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>
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;
}
}
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,
Top comments (0)