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 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 release only if they present a valid x402 payment receipt signed by the agent. Permit the agent to 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 = 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 calls deposit(). 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 (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). 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 { 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
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to