USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Building autonomous AI agents that can sell services without a human intermediary is attractive, but the devil is in the details: you need a mechanism that guarantees payment only when the work is verifiably done, and you need to keep gas costs and latency within reason for frequent micro‑transactions. Below is a practical walk‑through of a minimal escrow pattern that uses USDC on Base, plus the trade‑offs you’ll encounter when you try to put it into production.
1. The escrow contract in a nutshell
The goal is simple: a client locks USDC in a contract, the agent performs an off‑chain task, provides a cryptographic proof that the task is finished, and the contract releases the funds to the agent. If the agent fails to provide proof within a timeout, the client can reclaim the deposit.
A stripped‑down version looks like this (Solidity 0.8.20, OpenZeppelin ERC20 and ReentrancyGuard):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract USDEscrow is ReentrancyGuard {
IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
address public immutable agent; // set at deployment
address public client; // setter after deployment
uint256 public amount; // locked amount
bool public deposited; // true after client funds escrow
uint256 public deadline; // block.timestamp after which client can withdraw
bytes32 public proofHash; // keccak256(off‑chain result) supplied by agent
event Deposited(address indexed from, uint256 value);
event ProofSubmitted(bytes32 hash);
event Released(address indexed to, uint256 amount);
event Refunded(address indexed to, uint256 amount);
constructor(address _usdc, address _agent) {
usdc = IERC20(_usdc);
agent = _agent;
}
/// @notice Client funds the escrow. Must approve USDC to this contract first.
function deposit() external nonReentrant {
require(!deposited, "already deposited");
require(msg.value == 0, "send USDC via ERC20, not native");
uint256 depositAmount = usdc.balanceOf(address(this)); // will be zero before transfer
// The caller must have called usdc.approve(address(this), amount) beforehand.
usdc.transferFrom(msg.sender, address(this), amount);
deposited = true;
client = msg.sender;
deadline = block.timestamp + 7 days; // example timeout
emit Deposited(msg.sender, amount);
}
/// @notice Agent sets the amount they expect to receive before the client deposits.
/// Only callable by the agent before deposit.
function setAmount(uint256 _amount) external {
require(msg.sender == agent, "only agent");
require(!deposited, "cannot change after deposit");
amount = _amount;
}
/// @notice Agent submits a hash of the off‑chain result.
/// The hash is computed off‑chain (e.g., keccak256(IPFS CID || timestamp)).
function submitProof(bytes32 _proofHash) external nonReentrant {
require(msg.sender == agent, "only agent");
require(deposited, "no deposit yet");
require(block.timestamp <= deadline, "deadline passed");
proofHash = _proofHash;
emit ProofSubmitted(_proofHash);
}
/// @notice Client releases payment to the agent if a valid proof is present.
function release() external nonReentrant {
require(msg.sender == client, "only client");
require(deposited, "nothing to release");
require(block.timestamp <= deadline, "still within dispute window");
require(proofHash != bytes32(0), "no proof submitted");
usdc.transfer(agent, amount);
deposited = false; // prevent re‑entry
emit Released(agent, amount);
}
/// @notice Client reclaims funds if the agent never submits proof before deadline.
function refund() external nonReentrant {
require(msg.sender == client, "only client");
require(deposited, "nothing to refund");
require(block.timestamp > deadline, "deadline not reached");
usdc.transfer(client, amount);
deposited = false;
emit Refunded(client, amount);
}
/// @notice Helper for the client to know how much to approve.
function getDepositAmount() external view returns (uint256) {
return amount;
}
}
Why this shape?
- Minimal trust – The contract never holds any private keys; it only moves USDC based on immutable rules.
-
Atomicity – Funds move only when the
release()orrefund()transaction succeeds, eliminating the “pay‑then‑hope” problem. - Timeout – A 7‑day window (adjustable) gives the agent enough time to compute a result while preventing funds from being locked forever.
2. Deploying and initializing the escrow
You’ll need the USDC address on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) and the agent’s wallet address. The agent first calls setAmount() to advertise the price, then the client funds the contract.
Deploy script (Hardhat/ethers.js):
// deploy.js
async function main() {
const [deployer, agent] = await ethers.getSigners();
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const Escrow = await ethers.getContractFactory("USDEscrow");
const escrow = await Escrow.deploy(USDC, agent.address);
await escrow.waitForDeployment();
console.log("Escrow deployed to:", escrow.target);
}
After deployment, the agent sets the price:
// setPrice.js
async function main() {
const [agent] = await ethers.getSigners();
const escrowAddress = "0xEscrow..."; // from previous step
const escrow = await ethers.getContractAt("USDEscrow", escrowAddress, agent);
const price = ethers.parseUnits("0.05", 6); // USDC has 6 decimals
const tx = await escrow.setAmount(price);
await tx.wait();
console.log("Price set to", ethers.formatUnits(price, 6), "USDC");
}
3. Client side: funding, requesting work, and releasing
Assume the agent offers a text‑summarization service. The client will:
- Approve the escrow contract to spend USDC.
- Call
deposit()(which internally pulls the pre‑approved amount). - Send the raw text to the agent via any off‑chain channel (e.g., HTTP, WebSocket, or IPFS).
- Wait for the agent to submit a proof (IPFS CID of the summary + timestamp).
- Call
release()once the proof appears on‑chain.
Client implementation (ethers.js + etherscan‑style ABI):
javascript
// client.js
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://base.mainnet.rpc.url");
const usdcAbi = ["function approve(address spender, uint256 amount) uint256"];
const escrowAbi = [
"function setAmount(uint256)",
"function deposit()",
"function getDepositAmount() view returns (uint256)",
"function submitProof(bytes32)",
"function release()",
"function proofHash() view returns (bytes32)",
"function deadline() view returns (uint256)",
];
async function main() {
const [client] = await ethers.getSigners();
const usdc = new ethers.Contract(
"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
usdcAbi,
client
);
const escrow = new ethers.Contract(
"0xEscrow...",
escrowAbi,
client
);
// 1. Approve escrow to pull USDC
const amount = ethers.parseUnits("0.05", 6); // must match agent's price
const approveTx = await usdc.approve(escrow.target, amount);
await approveTx.wait();
// 2. Fund escrow (the escrow's setAmount must have been called already)
const depositTx = await escrow.deposit();
await depositTx.wait();
console.log("Escrow funded");
// 3. Off‑chain: send work to agent (example using fetch)
const workPayload = {
Top comments (0)