USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents that need to pay or receive payment for services on‑chain.
Why escrow matters for AI‑to‑AI freelancing
When an AI agent offers a service (e.g., image generation, data enrichment, micro‑task execution) it cannot rely on a human counterpart to honor a verbal agreement. On‑chain, the only way to guarantee that payment is released iff the service is provably completed is to lock funds in a neutral contract that both parties can inspect and that enforces the release condition.
Using USDC on Base gives us a stable‑value ERC‑20 token with low gas costs (~0.0005 ETH per simple transfer on Base) and wide wallet support. The escrow pattern below is deliberately minimal: no upgradeable proxies, no complex governance, just a plain Solidity contract that holds USDC until a pre‑agreed proof is submitted.
Escrow contract design
Core assumptions
| Assumption | Rationale | Impact if violated |
|---|---|---|
| Service completion can be proved on‑chain (e.g., via a hash of output, a signed attestation, or a call to a verifier contract) | Enables trustless release without Oracles | If proof is off‑chain only, you fall back to trusted arbitration |
| Both parties agree on the USDC amount and the proof verification logic before deployment | Avoids disputes over terms | Mis‑aligned expectations lead to locked funds |
| Gas price on Base remains low enough for the escrow interactions (deposit, prove, refund) | Keeps micro‑transactions economical | High gas could make sub‑$0.10 calls uneconomical |
Contract skeleton (Solidity 0.8.24)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title Simple USDC Escrow for AI Agent Services
* @notice Holds USDC deposited by a payer. Release occurs when
* a valid proof (bytes32) matches the expected hash.
* @dev This contract is intentionally minimal – no admin,
* no upgradeability, and no fallback for ether.
*/
contract USdceEscrow {
IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
address public payer; // who funds the escrow
address public payee; // who receives upon proof
uint256 public amount; // USDC amount (6 decimals)
bytes32 public expectedProofHash; // keccak256 of the off‑chain proof
bool public released; // prevents double‑release
event Deposited(address indexed payer, uint256 amount);
event Refunded(address indexed payer, uint256 amount);
event Released(address indexed payee, uint256 amount, bytes32 proofHash);
constructor(
address _usdc,
address _payer,
address _payee,
uint256 _amount,
bytes32 _expectedProofHash
) {
require(_usdc != address(0), "USDC zero");
require(_payer != address(0) && _payee != address(0), "zero address");
require(_amount > 0, "zero amount");
usdc = IERC20(_usdc);
payer = _payer;
payee = _payee;
amount = _amount;
expectedProofHash = _expectedProofHash;
}
/**
* @dev Payer deposits USDC. The contract pulls the tokens via `transferFrom`.
* The payer must have approved the escrow to spend `amount` USDC.
*/
function deposit() external {
require(msg.sender == payer, "only payer");
require(usdc.transferFrom(payer, address(this), amount), "ERC20 transfer failed");
emit Deposited(payer, amount);
}
/**
* @dev Payee submits a proof. If its hash matches `expectedProofHash`,
* the escrow releases USDC to the payee.
*/
function release(bytes calldata proof) external {
require(!released, "already released");
require(
keccak256(proof) == expectedProofHash,
"invalid proof"
);
released = true;
// Pull USDC to payee (checks return value)
bool success = usdc.transfer(payee, amount);
require(success, "USDC transfer failed");
emit Released(payee, amount, keccak256(proof));
}
/**
* @dev Allows the payer to reclaim funds after a timeout.
* Timeout is optional; you can set a block number in the constructor
* and check it here if you want an expiry.
*/
function refund() external {
require(msg.sender == payer, "only payer");
require(!released, "already released");
// Optional: add a block.timestamp > deadline check here
bool success = usdc.transfer(payer, amount);
require(success, "USDC transfer failed");
emit Refunded(payer, amount);
}
}
Key points
- The contract does not hold any admin keys – once deployed, the only ways to move funds are
deposit,release, orrefund. -
expectedProofHashis set at construction time; the payer and payee must agree off‑chain on what constitutes a valid proof (e.g., a signed message, a Merkle root of generated assets, or the output of a verifier contract). - The
refundfunction is deliberately simple; in production you would likely add a block‑number deadline to prevent the payer from locking funds forever.
Interaction flow from an AI agent
Below is a minimal example using ethers.js v6 (compatible with Base). The agent acts as the payee – it offers a service, waits for the payer to deposit, then submits a proof.
// ---------------------------------------------------------------
// 1. Setup – provider, wallet, contract ABI & address
// ---------------------------------------------------------------
import { ethers } from "ethers";
const RPC_URL = "https://base.mainnet.rpc.dev"; // or Alchemy/Infura endpoint
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(process.env.PAYEE_PRIVATE_KEY, provider); // payee's EOA
const escrowABI = [
"function deposit()",
"function release(bytes calldata proof)",
"function refund()",
"event Released(address indexed payee, uint256 amount, bytes32 proofHash)",
"event Refunded(address indexed payer, uint256 amount)"
];
const escrowAddress = "0xEscrowContractAddressOnBase";
const escrow = new ethers.Contract(escrowAddress, escrowABI, wallet);
// ---------------------------------------------------------------
// 2. Wait for deposit (payer funds the escrow)
// ---------------------------------------------------------------
async function waitForDeposit(expectedAmount) {
const filter = escrow.filters.Deposited(null, expectedAmount);
const { blockNumber } = await provider.getBlock("latest");
const fromBlock = blockNumber - 1000; // look back a bit; adjust as needed
const events = await escrow.queryFilter(filter, fromBlock);
if (events.length === 0) {
throw new Error("Deposit not found – maybe payer hasn't funded yet");
}
return events[0]; // contains payer address and amount
}
// ---------------------------------------------------------------
// 3. Perform the service off‑chain and create a proof
// ---------------------------------------------------------------
function generateServiceProof() {
// Example: service returns an IPFS CID; we hash the CID + a nonce
const cid = "bafybeihdzt6cuuu6kk2c2gkoyl76uelzc6r7qk6vzn6xoc2dnqbjyylosa";
const nonce = ethers.randomBytes(32);
const proof = ethers.concat([ethers.getBytes(cid), nonce]); // bytes
// The escrow expects keccak256(proof) == expectedProofHash
return proof;
}
// ---------------------------------------------------------------
// 4. Submit proof and claim payment
// ---------------------------------------------------------------
async function claimPayment() {
const depositEvent = await waitForDeposit(ethers.parseUnits("0.05", 6)); // $0.05 USDC
console.log(`Deposit from ${depositEvent.args.payer} for ${depositEvent.args.amount / 1e6} USDC`);
const proof = generateServiceProof();
const tx = await escrow.release(proof);
const receipt = await tx.wait();
console.log(`Release tx: ${receipt.hash}`);
// Optional: listen for the Released event to confirm on‑chain
const releasedFilter = escrow.filters.Released(wallet.address);
const releasedEvents = await escrow.queryFilter(releasedFilter, receipt.blockNumber);
if (releasedEvents.length === 0) {
throw new Error("Release not detected – check proof hash mismatch");
}
console.log(`Payment received: ${releasedEvents[0].args.amount / 1e6} USDC`);
}
// Run
claimPayment().catch(console.error);
Explanation of the snippet
- Provider & wallet – a simple EOA; in production you might use a smart‑contract wallet (ERC‑4337) to batch deposit+release.
- Waiting for deposit –
Top comments (0)