USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
By a senior engineer who’s built both on‑chain agents and off‑chain services
Introduction
Autonomous agents need a way to buy and sell micro‑services without relying on a central marketplace that can censor, charge high fees, or disappear overnight. The most straightforward trust‑less primitive is an escrow that holds a stablecoin (USDC) until both parties cryptographically prove that the agreed work has been delivered. This article walks through a minimal, auditable escrow design, shows how an agent can interact with it using the x402 payment standard, and outlines the real‑world trade‑offs you’ll face when you put it into production.
Why USDC?
- Stable value – USDC’s 1:1 peg to USD eliminates the need for agents to constantly reprice tasks in volatile tokens.
- Wide support – USDC is ERC‑20 compatible on Ethereum, Base, Polygon, Arbitrum, etc., so the same escrow contract can be deployed once and used across L2s.
- Regulatory clarity – While not a legal endorsement, USDC is issued by a regulated entity and enjoys broader exchange coverage than many algorithmic stables.
Escrow Contract Overview
The escrow is a simple deterministic contract with three roles:
| Role | Responsibility |
|---|---|
| Creator (the agent that wants a service) | Deposits USDC, specifies the service hash and a deadline. |
| Performer (the agent that executes the service) | Claims the escrow by providing a pre‑image that hashes to the service hash before the deadline. |
| Arbitrator (optional) | Can refund the creator if the deadline passes without a valid claim. In the minimal version we set the arbitrator to address(0), making the contract self‑executed. |
The core logic:
-
Deposit – Creator calls
deposit(bytes32 serviceHash, uint256 deadline). The contract transfers USDC frommsg.senderto itself and stores the hash+deadline. -
Claim – Performer calls
claim(bytes32 serviceHash, bytes preimage). Ifkeccak256(preimage) == serviceHashandblock.timestamp <= deadline, the contract transfers the escrowed USDC tomsg.sender. -
Refund – If
block.timestamp > deadlineand no claim has been made, anyone can callrefund()to return the funds to the creator (only works if the arbitrator is set; withaddress(0)the function reverts, forcing the creator to withdraw via a separatewithdraw()after deadline).
The contract is deliberately tiny—under 200 bytes of bytecode—to keep deployment cheap (~0.0005 ETH on Base) and to minimize attack surface.
Solidity Implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract USDCreditEscrow {
IERC20 public immutable usdc;
address public creator;
bytes32 public serviceHash;
uint256 public deadline;
bool public claimed;
bool public refunded;
event Deposited(address indexed creator, uint256 amount);
event Claimed(address indexed performer, bytes preimage);
event Refunded(address indexed to);
constructor(address _usdc) {
usdc = IERC20(_usdc);
creator = msg.sender;
}
/// @notice Deposit USDC and lock the service hash.
/// @dev The caller must approve the contract to spend at least `amount` USDC.
function deposit(bytes32 _serviceHash, uint256 _deadline, uint256 amount) external {
require(!claimed && !refunded, "Escrow already resolved");
require(_deadline > block.timestamp, "Deadline must be in future");
require(usdc.transferFrom(msg.sender, address(this), amount), "Transfer failed");
creator = msg.sender;
serviceHash = _serviceHash;
deadline = _deadline;
emit Deposited(msg.sender, amount);
}
/// @notice Claim the escrow by providing a valid preimage.
function claim(bytes calldata preimage) external {
require(!claimed && !refunded, "Escrow already resolved");
require(block.timestamp <= deadline, "Deadline passed");
require(keccak256(preimage) == serviceHash, "Invalid preimage");
claimed = true;
uint256 balance = usdc.balanceOf(address(this));
require(usdc.transfer(msg.sender, balance), "USDC transfer failed");
emit Claimed(msg.sender, preimage);
}
/// @notice Refund the creator after the deadline (only if arbitrator set).
function refund() external {
require(!claimed && !refunded, "Escrow already resolved");
require(block.timestamp > deadline, "Deadline not reached");
require(msg.sender == creator, "Only creator can refund");
refunded = true;
uint256 balance = usdc.balanceOf(address(this));
require(usdc.transfer(creator, balance), "USDC transfer failed");
emit Refunded(creator);
}
/// @notice Allow the creator to pull funds after deadline if no arbitrator.
function withdraw() external {
require(!claimed && !refunded, "Escrow already resolved");
require(block.timestamp > deadline, "Deadline not reached");
require(msg.sender == creator, "Only creator can withdraw");
refunded = true;
uint256 balance = usdc.balanceOf(address(this));
require(usdc.transfer(creator, balance), "USDC transfer failed");
emit Refunded(creator);
}
}
Key points
- The contract does not hold any upgradeable proxy or admin keys—once deployed, its behavior is immutable.
- USDC is treated as an untrusted ERC‑20; we use
transferFrom/transferand check return values (OpenZeppelin’s ERC20 wrapper already reverts on false). - The only external dependency is the USDC token address, which you pass at deployment time.
Agent‑Side Interaction (JavaScript/TypeScript)
Below is a minimal agent that wants to purchase a sentiment‑analysis micro‑service. It uses ethers.js and the x402 payment header format defined by the x402 spec (https://x402.org).
import { ethers } from "ethers";
import { USDCreditEscrow } from "./abis/USDCreditEscrow.json";
// Configuration (Base mainnet, USDC address)
const RPC = "https://mainnet.base.org";
const USDC_ADDR = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ADDR = "0xYourDeployedEscrowHere"; // replace after deployment
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!;
const provider = new ethers.JsonRpcProvider(RPC);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDR, erc20Abi, wallet);
const escrow = new ethers.Contract(ESCROW_ADDR, USDCreditEscrow.abi, wallet);
// Helper: approve USDC for escrow (call once per session)
async function approveEscrow(amount: bigint) {
const tx = await usdc.approve(ESCROW_ADDR, amount);
await tx.wait();
}
// Step 1: Deposit and lock the service hash
async function fundEscrow(serviceHash: string, deadline: number, amountUSD: number) {
const amount = ethers.parseUnits(amountUSD.toString(), 6); // USDC has 6 decimals
await approveEscrow(amount);
const tx = await escrow.deposit(serviceHash, deadline, amount);
const receipt = await tx.wait();
console.log("Deposit tx:", receipt.hash);
}
// Step 2: Perform work off‑chain, generate preimage, claim
async function claimEscrow(preimage: string) {
const tx = await escrow.claim(preimage);
const receipt = await tx.wait();
console.log("Claim tx:", receipt.hash);
}
// Example usage
(async () => {
// Imagine we have a service that returns a sentiment score.
// The service provider publishes the hash of the expected output:
const serviceHash = ethers.keccak256(ethers.toUtf8Bytes('{"score":0.87}'));
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
await fundEscrow(serviceHash, deadline, 0.05); // $0.05 escrow
// Off‑chain: call the actual AI model, get result, ensure it matches hash
const result = await runSentimentModel("I love this product!"); // your model
const preimage = ethers.toUtf8Bytes(JSON.stringify({ score: result }));
// Verify that hash matches before committing on‑chain
if (ethers.keccak256(preimage) !== serviceHash) {
throw new Error("Model output does not match expected hash");
}
// On‑chain claim
await claimEscrow(preimage);
})();
What the code does
- Approves the escrow to pull USDC from the agent’s wallet
Top comments (0)