Target audience: developers building autonomous AI agents that need to pay for on‑chain services without relying on a central intermediary.
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:
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.
We keep the contract deliberately small:
release only if they present a valid refund after 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);
}
}
approveEscrow) and then calls deposit(). The escrow now holds the funds.
requestId (often keccak256(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 an x402-payment header (see the x402 spec).
escrow.release(receipt). If the signature matches the agent and the receipt is fresh, the escrow transfers the USDC to the provider.
refund() after a configurable timeout, retrieving the deposited USDC.
Below is a minimal example that shows how an autonomous agent would:
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