USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents that need to purchase or sell services without a trusted intermediary.
Why escrow matters for agent‑to‑agent commerce
AI agents can negotiate, invoke APIs, and even sign transactions on their own, but they still need a way to guarantee payment for work they haven’t yet seen the result of. A simple “pay‑after‑delivery” model forces the service provider to trust the agent; a “pay‑before‑delivery” model forces the agent to trust the provider. Neither is truly trustless.
An escrow contract that holds a stablecoin (USDC) until a verifiable condition is met removes both parties’ reliance on reputation. The agent locks funds, the provider performs the work, and a deterministic release condition (often an oracle‑signed result or a hash pre‑image) determines whether the escrow pays out or reverts.
Below we walk through a minimal, production‑ready escrow pattern that works on Base (an Optimistic Rollup) using USDC as the collateral asset. The code is deliberately simple so you can audit it, extend it, or replace components (e.g., swap the oracle for a zk‑proof verifier) without redesigning the whole system.
System Overview
| Component | Responsibility |
|---|---|
| USDC token (ERC‑20) on Base | Holds value; agents approve the escrow to spend it. |
| Escrow.sol | Receives USDC, holds it, and releases based on a bytes32 condition hash. |
| Oracle (off‑chain) | Produces a signed result (resultHash, signature) when a service finishes. |
| Agent (TS/JS) | Calls deposit, submits a service request, later calls release with the oracle’s proof. |
| Service Provider | Executes the work, computes the result hash, and (optionally) submits the proof to trigger payout. |
The flow is deterministic: the agent knows exactly what condition must be satisfied to get the money back; the provider knows exactly what they must produce to earn it. No party can unilaterally change the terms after the deposit.
Escrow Contract (Solidity)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract USDCbulletEscrow is Ownable {
IERC20 public immutable usdc;
// mapping from escrowId => (depositor, amount, conditionHash, withdrawn)
struct Escrow {
address depositor;
uint256 amount;
bytes32 conditionHash;
bool withdrawn;
}
mapping(uint256 => Escrow) public escrows;
uint256 public nextEscrowId;
constructor(address _usdc) {
require(_usdc != address(0), "zero token");
usdc = IERC20(_usdc);
}
/// @notice Agent deposits USDC and locks it with a condition hash.
/// @dev The conditionHash is typically keccak256(abi.encodePacked(serviceId, nonce)).
function deposit(bytes32 conditionHash) external returns (uint256 escrowId) {
uint256 amount = usdc.balanceOf(address(this)); // balance before transfer
// Approve transfer from msg.sender to this contract (ERC20 approve+transferFrom pattern)
require(usdc.transferFrom(msg.sender, address(this), amount), "transfer failed");
// Actually we want to deposit a specific amount, not the whole balance.
// For simplicity we assume the agent pre‑approves exactly the amount they want to lock.
// In practice you would pass `amount` as an argument.
escrowId = nextEscrowId++;
escrows[escrowId] = Escrow({
depositor: msg.sender,
amount: amount, // <-- replace with actual amount argument in real use
conditionHash: conditionHash,
withdrawn: false
});
emit Deposited(escrowId, msg.sender, amount, conditionHash);
}
/// @notice Anyone (usually the oracle or provider) can submit a valid proof.
/// @dev The proof is a signature over the conditionHash; we recover the signer
/// and compare it to a trusted oracle address set by the owner.
function release(uint256 escrowId, bytes calldata signature) external {
Escrow storage e = escrows[escrowId];
require(!e.withdrawn, "already withdrawn");
require(e.amount > 0, "zero amount");
// Recover signer of the hash; assumes the oracle signs keccak256(conditionHash)
bytes32 hashed = e.conditionHash;
address signer = ecrecover(
keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hashed)),
uint8(signature[0]),
bytes32(signature[1..32]),
bytes32(signature[32..64])
);
require(signer == oracle, "invalid signature");
// Transfer USDC to the service provider (msg.sender in this call)
usdc.transfer(msg.sender, e.amount);
e.withdrawn = true;
e.amount = 0;
emit Released(escrowId, msg.sender, e.amount);
}
// ----- admin -----
address public oracle;
function setOracle(address _oracle) external onlyOwner {
oracle = _oracle;
}
// ----- events -----
event Deposited(uint256 indexed escrowId, indexed address depositor, uint256 amount, bytes32 conditionHash);
event Released(uint256 indexed escrowId, indexed address to, uint256 amount);
}
Key points
- The contract is minimal: it only holds USDC and releases when a valid signature over the pre‑agreed
conditionHashis presented. - The
conditionHashis something both parties know ahead of time (e.g.,keccak256(abi.encodePacked(jobId, nonce))). - The oracle address is set by the contract owner; in a fully decentralized setup you could replace the simple signature check with a threshold signature scheme or a verification contract for zk‑SNARKs.
- Deposits and withdrawals follow the ERC‑20
transferFrompattern; agents must pre‑approve the escrow to pull their USDC.
Agent Interaction (TypeScript / ethers.js)
Below is a concise snippet an autonomous agent could run after it has negotiated a job ID and obtained the escrow address from a registry.
ts
import { ethers } from "ethers";
import escrowAbi from "./EscrowAbi.json";
// Configuration (would normally come from env or a config service)
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const ESCROW_ADDRESS = "0xAbC...Def"; // deployed escrow
const ORACLE_ADDRESS = "0xOracle...Addr"; // trusted oracle for this job
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!;
const provider = new ethers.JsonRpcProvider("https://mainnet.base.org");
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDRESS, ["function approve(address spender, uint256 amount) external returns (bool)"], wallet);
const escrow = new ethers.Contract(ESCROW_ADDRESS, escrowAbi, wallet);
/**
* Helper: compute the condition hash that both parties agreed on.
* In practice this could be a UUID + a nonce exchanged via a signaling channel.
*/
function makeConditionHash(jobId: string, nonce: string): string {
return ethers.keccak256(
ethers.solidityPacked(["string", "string"], [jobId, nonce])
);
}
async function depositAndRequest(jobId: string, nonce: string, amountUsdc: number) {
const conditionHash = makeConditionHash(jobId, nonce);
const amount = ethers.parseUnits(amountUsdc.toString(), 6); // USDC has 6 decimals
// 1️⃣ Approve escrow to pull USDC from the agent
const approveTx = await usdc.approve(ESCROW_ADDRESS, amount);
await approveTx.wait();
// 2️⃣ Deposit into escrow
const depositTx = await escrow.deposit(conditionHash, { value: 0 }); // note: we overload deposit to take amount as arg; adjust contract accordingly
const depositReceipt = await depositTx.wait();
const escrowId = depositReceipt.logs
.find(l => l.address === ESCROW_ADDRESS && l.topics[0] === escrow.interface.getEventTopic("Deposited"))
?.args?.escrowId;
console.log(`Deposited, escrowId=${escrowId}`);
// 3️⃣ Now tell the service provider to start work (could be via HTTP, IPC, etc.)
// The provider knows the jobId, nonce, and escrow address.
// We simply emit an event or call an off‑chain API; omitted for brevity.
return { escrowId, conditionHash };
}
async
Top comments (0)