USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
By a senior engineer who’s built (and broken) a few agent‑to‑agent marketplaces.
TL;DR
AI agents can pay each other for services without a human in the loop by using a simple escrow contract on Base (EVM) that holds USDC, releases it on proof‑of‑completion, and lets both sides verify the outcome on‑chain. The approach is inexpensive, deterministic, and works today—but it also forces you to think hard about what “completion” really means for an AI service.
Why escrow, and why USDC on Base?
| Property | Why it matters for AI agents |
|---|---|
| Trustless | No need for a reputation system or a human arbiter; the contract enforces the payment rule. |
| Low friction | USDC is a stablecoin with ~1 USD value, so pricing stays predictable. |
| Cheap | Base (an Optimism rollup) offers sub‑cent transaction fees; a single escrow deployment + two interactions cost <$0.005. |
| Composable | The escrow address is just another ERC‑20 holder; agents can treat it like any other wallet. |
If you already have an agent that can call an HTTP endpoint (or another agent’s JSON‑RPC method), adding escrow is a matter of wrapping that call in a two‑step transaction:
- Fund the escrow with the agreed amount.
- Release funds only when a pre‑agreed condition is met (e.g., a signed result, a hash‑preimage, or an on‑chain oracle answer).
The escrow contract – minimal, auditable, upgrade‑free
Below is a Solidity 0.8.20 contract that implements a single‑use escrow. It is intentionally tiny (≈300 bytes) to keep deployment cost low and to make formal verification trivial.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
/**
* @notice Simple escrow that holds USDC until a caller provides a valid
* completion proof. The proof is any bytes32 value that matches
* a pre‑agreed hash (the "secret").
*/
contract USDCÉscrow {
IERC20 public immutable usdc;
address public payer; // who funded the escrow
address public payee; // who should receive the funds on success
uint256 public amount; // USDC amount (in wei, 6 decimals)
bytes32 public secretHash; // keccak256(secret) agreed off‑chain
bool public released; // prevents double‑release
event Funded(address indexed payer, address indexed payee, uint256 amount);
event Released(address indexed payer, address indexed payee, uint256 amount);
event Refunded(address indexed payer, uint256 amount);
constructor(
address _usdc,
address _payer,
address _payee,
uint256 _amount,
bytes32 _secretHash
) {
require(_usdc != address(0), "USDC zero");
require(_payer != address(0), "Payer zero");
require(_payee != address(0), "Payee zero");
require(_amount > 0, "Zero amount");
usdc = IERC20(_usdc);
payer = _payer;
payee = _payee;
amount = _amount;
secretHash = _secretHash;
}
/**
* @dev Caller (usually the payer) must first approve the escrow to pull USDC.
* After funding, the payer can call `fund()` to move USDC into the contract.
*/
function fund() external {
require(msg.sender == payer, "Only payer");
require(usdc.balanceOf(address(this)) == 0, "Already funded");
bool ok = usdc.transferFrom(payer, address(this), amount);
require(ok, "USDC transfer failed");
emit Funded(payer, payee, amount);
}
/**
* @dev Anyone can call release with the correct pre‑image of secretHash.
* The contract verifies keccak256(preimage) == secretHash.
*/
function release(bytes calldata preimage) external {
require(!released, "Already released");
require(keccak256(preimage) == secretHash, "Invalid proof");
released = true;
bool ok = usdc.transfer(payee, amount);
require(ok, "USDC transfer failed");
emit Released(payer, payee, amount);
}
/**
* @dev Allows the payer to reclaim funds if the payee never provides a proof.
* A timeout (block.timestamp > deadline) is enforced off‑chain; the
* contract itself does not store a deadline to keep it stateless.
*/
function refund() external {
require(msg.sender == payer, "Only payer");
require(!released, "Already released");
// In practice, the off‑chain agent will only call this after a deadline.
bool ok = usdc.transfer(payer, amount);
require(ok, "USDC transfer failed");
emit Refunded(payer, amount);
}
}
How it works in practice
- Agreement off‑chain – The two agents negotiate a service (e.g., “summarize this PDF”) and a price (say $0.05). They also agree on a secret that only the payee can generate after completing the job (e.g., a random 32‑byte nonce, hashed and shared beforehand).
-
Funding – The payer calls
fund()after approving the escrow contract to pull USDC from its wallet. -
Execution – The payee performs the service, computes the secret, and calls
release(secret). If the hash matches, USDC is transferred instantly. -
Refund – If the payee never reveals the secret before an agreed deadline, the payer calls
refund()and gets their money back.
Note: The contract does not enforce a deadline. Deadlines are handled by the agents’ off‑chain logic (e.g., “if block.timestamp > now + 1 hour, call refund”). Keeping the contract stateless saves gas and avoids the need for upgradeable admin keys.
Integrating the escrow into an AI agent (TypeScript + ethers.js)
Below is a minimal, production‑ready snippet that shows how an autonomous agent would:
- approve USDC,
- fund the escrow,
- call a remote service,
- submit the proof,
- and handle refunds.
import { ethers } from "ethers";
import usdcAbi from "./abi/usdc.json"; // standard ERC‑20 ABI
import escrowAbi from "./abi/escrow.json"; // the contract above
// ------------------- Configuration -------------------
const RPC_URL = "https://mainnet.base.org"; // Base mainnet RPC
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base USDC
const PRIVATE_KEY = process.env.PRIVATE_KEY!; // payer's wallet (funded with USDC)
// Service endpoint (could be another agent's HTTP JSON‑RPC)
const SERVICE_URL = "https://agent.example.com/summarize";
// Secret hash agreed off‑chain (payee knows the preimage)
const SECRET = ethers.randomBytes(32); // 32‑byte nonce
const SECRET_HASH = ethers.keccak256(SECRET);
// Price in USDC (6 decimals)
const PRICE_USDC = 0.05; // $0.05
const AMOUNT = ethers.parseUnits(PRICE_USDC.toString(), 6);
// ------------------- Helper -------------------
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, wallet);
// Deploy escrow (or reuse a known address)
async function deployEscrow(): Promise<ethers.Contract> {
const EscrowFactory = new ethers.ContractFactory(escrowAbi, [], wallet);
const escrow = await EscrowFactory.deploy(
USDC_ADDRESS,
wallet.address, // payer
ethers.getAddress("0xPayeeAddress..."), // payee (could be another agent's wallet)
AMOUNT,
SECRET_HASH
);
await escrow.waitForDeployment();
return escrow;
}
// ------------------- Main flow -------------------
async function run() {
const escrow = await deployEscrow();
// 1️⃣ Approve USDC transfer to escrow
const approveTx = await usdc.approve(await escrow.getAddress(), AMOUNT);
await approveTx.wait();
// 2️⃣ Fund escrow
const fundTx = await escrow.fund();
await fundTx.wait();
console.log("Escrow funded:", await escrow.amount());
// 3️⃣ Call the remote service (stateless HTTP POST)
const serviceResp = await fetch(SERVICE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: "Lorem ipsum..." }), // whatever the service expects
});
if (!serviceResp.ok) throw new Error("Service failed");
const result = await serviceResp.json(); // e.g., { summary: "..." }
// 4️⃣ Release funds using the secret preimage
const releaseTx = await escrow.release(SECRET);
await releaseTx.wait();
console.log("Funds released to payee");
// 5️⃣ (Optional) Verify on‑chain that payee got the money
const payeeBal = await usdc.balanceOf(ethers.getAddress("0xPayeeAddress..."));
console.log("Payee USDC balance:", ethers.formatUnits(payeeBal, 6));
}
// Run with error handling & refund logic omitted for brevity
run().catch(console.error);
What the code shows
- Atomicity – The escrow guarantees that the payer never loses funds unless the payee reveals the secret.
- Deterministic price – USDC’s 6‑decimals let you price in fractions of a cent without rounding surprises.
- No central custodian – The contract holds the funds; the agent only needs to sign transactions
Top comments (0)