USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents that need to pay for on‑chain services without relying on a central intermediary.
Why escrow matters for agents
Autonomous agents often act as both consumer and provider of on‑chain services (e.g., calling a data oracle, invoking a compute function, or purchasing API access). When the agent must spend funds it does not own, two problems appear:
- Counter‑party risk – the service provider could take the payment and not deliver the result.
- Atomicity – the agent wants to guarantee that either both payment and service execution happen, or neither does.
A trustless escrow solves both by locking funds in a contract that only releases them when a verifiable condition is met. The condition can be as simple as “the caller supplied a valid signature proving the service was executed”, or as complex as a Merkle‑proof of off‑chain work.
Below we walk through a minimal, production‑ready escrow pattern using USDC on Base (an EVM‑compatible rollup) and the x402 payment‑protocol extension, which lets agents attach a payment to an HTTP request in a standard way.
The escrow contract
We keep the contract deliberately small:
- Deposit USDC (ERC‑20) into the escrow.
- Allow a beneficiary (the service provider) to call
releaseonly if they present a valid x402 payment receipt signed by the agent. - Permit the agent to
refundafter a timeout if the service never appears.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title Simple USDC Escrow for x402‑paid agents
* @notice Agents deposit USDC; providers release funds by presenting a valid
* x402 payment receipt (agent signature over request details).
*/
contract USDCx402Escrow is Ownable {
IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
address public immutable agent; // The AI agent that funds the escrow
uint256 public constant REFUND_TIMEOUT = 7 days; // Adjust to your SLA
struct Receipt {
bytes32 requestId; // Keccak256 of (method, URL, bodyHash, nonce)
uint256 amount; // USDC amount (6 decimals)
uint256 deadline; // Unix timestamp after which receipt is stale
bytes signature; // Agent's ECDSA signature over keccak256(abi.encodePacked(requestId, amount, deadline))
}
mapping(bytes32 => bool) public usedReceipt; // Prevent replay
event Deposit(address indexed from, uint256 amount);
event Release(address indexed to, uint256 amount, bytes32 requestId);
event Refund(address indexed to, uint256 amount);
constructor(address _usdc, address _agent) {
require(_usdc != address(0) && _agent != address(0), "zero address");
usdc = IERC20(_usdc);
agent = _agent;
}
/** Agent deposits USDC into the escrow. */
function deposit() external payable {
require(msg.sender == agent, "only agent");
require(msg.value == 0, "send USDC via ERC20 transfer, not ETH");
uint256 amount = usdc.balanceOf(address(this));
usdc.transferFrom(agent, address(this), amount); // Pull previously approved amount
emit Deposit(agent, amount);
}
/** Provider calls this to claim payment. */
function release(Receipt calldata receipt) external {
require(!usedReceipt[receipt.requestId], "receipt already used");
usedReceipt[receipt.requestId] = true;
// 1️⃣ Verify signature matches the agent
bytes32 hash = keccak256(
abi.encodePacked(
receipt.requestId,
receipt.amount,
receipt.deadline
)
);
address signer = ecrecover(hash, uint8(receipt.signature[0]) + 27, receipt.signature[1], receipt.signature[2]);
require(signer == agent, "invalid agent signature");
// 2️⃣ Check amount and deadline
require(receipt.amount > 0, "zero amount");
require(block.timestamp <= receipt.deadline, "expired receipt");
// 3️⃣ Transfer USDC
usdc.transfer(msg.sender, receipt.amount);
emit Release(msg.sender, receipt.amount, receipt.requestId);
}
/** Agent can refund after timeout if no one claimed. */
function refund() external {
require(msg.sender == agent, "only agent");
require(block.timestamp >= REFUND_TIMEOUT, "timeout not reached");
uint256 bal = usdc.balanceOf(address(this));
require(bal > 0, "nothing to refund");
usdc.transfer(agent, bal);
emit Refund(agent, bal);
}
/** Helper for agent to approve escrow to pull USDC. */
function approveEscrow(uint256 amount) external {
require(msg.sender == agent, "only agent");
usdc.approve(address(this), amount);
}
}
How it works
-
Funding – The agent approves the escrow to pull a specific amount of USDC (via
approveEscrow) and then callsdeposit(). The escrow now holds the funds. -
Request – When the agent wants to call a service, it builds an x402 payment receipt: a struct containing a unique
requestId(oftenkeccak256(abi.encodePacked(method, url, bodyHash, nonce))), the USDC amount, a deadline, and signs the hash with its private key. The receipt is attached to the HTTP request as anx402-paymentheader (see the x402 spec). -
Fulfilment – The service provider verifies the header locally (optional) and then calls
escrow.release(receipt). If the signature matches the agent and the receipt is fresh, the escrow transfers the USDC to the provider. -
Fallback – If the provider never appears, the agent can call
refund()after a configurable timeout, retrieving the deposited USDC.
Agent‑side implementation (TypeScript / ethers.js)
Below is a minimal example that shows how an autonomous agent would:
- Load its private key (from env or a secure vault).
- Approve and deposit USDC into the escrow.
- Build and sign an x402 receipt.
- Make an HTTP request with the receipt header.
ts
// agent.ts
import { ethers } from "ethers";
import dotenv from "dotenv";
dotenv.config();
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base
const ESCROW_ADDRESS = process.env.ESCROW_ADDR!; // deployed contract
const AGENT_PRIVATE_KEY = process.env.AGENT_PRIV_KEY!;
const RPC_URL = "https://mainnet.base.org"; // public Base RPC
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(AGENT_PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDRESS, [
"function approve(address spender, uint256 amount) returns (bool)",
"function balanceOf(address) view returns (uint256)",
], wallet);
const escrow = new ethers.Contract(ESCROW_ADDRESS, [
"function deposit()",
"function approveEscrow(uint256 amount)",
"function release(bytes32 requestId, uint256 amount, uint256 deadline, bytes signature)",
], wallet);
/** Helper: keccak256 of a UTF‑8 string */
function keccak256(str: string): string {
return ethers.keccak256(ethers.toUtf8Bytes(str));
}
/** Build a deterministic requestId */
function makeRequestId(method: string, url: string, body: any, nonce: number): string {
const bodyHash = ethers.keccak256(ethers.toUtf8Bytes(JSON.stringify(body)));
return ethers.keccak256(
ethers.concat([
ethers.toUtf8Bytes(method),
ethers.toUtf8Bytes(url),
ethers.toUtf8Bytes(bodyHash),
ethers.zeroPadValue(BigInt(nonce), 32)
])
);
}
/** Sign the x402 receipt hash */
async function signReceipt(requestId: string, amount: number, deadline: number): Promise<string> {
const hash = ethers.keccak256(
ethers.concat([
ethers.getBytes(requestId),
ethers.zeroPadValue(BigInt(amount * 1e6), 32), // USDC has 6 decimals
ethers.zeroPadValue(BigInt(deadline), 32)
])
);
const signature = await wallet.signMessage(ethers.getBytes(hash));
return signature; // 65‑byte (r,s,v) signature
}
/** Main flow */
async function run() {
// 1️⃣ Approve & deposit (run once per funding round)
const depositAmount = ethers.parseUnits("10", 6); // 10 USDC
await usdc.approve(ESCROW_ADDRESS, depositAmount);
const txDeposit = await escrow.deposit();
await txDeposit.wait();
console.log("Deposited:", depositAmount / 1e6, "USDC");
// 2
Top comments (0)