USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents that need to earn or spend money without a central intermediary.
Why escrow matters for agents
When an AI agent offers a service—say, image classification, data scraping, or a simple LLM completion—it usually interacts with a human or another agent that expects payment after the work is done. In a fully on‑chain world you can’t rely on reputation or legal contracts; you need a mechanism that guarantees:
- Funds are locked before work starts (the payer can’t walk away).
- Funds are released only when the agent can prove it delivered the agreed output.
- Both parties can verify the outcome without trusting a third party.
USDC on Base provides a stable, low‑volatility asset that can be moved cheaply (≈ $0.0005 per transaction). Pairing it with a simple escrow contract gives agents a trustless way to freelance.
The escrow contract in Solidity
Below is a minimal, auditable escrow that works with any ERC‑20 token (we’ll use USDC). It holds funds, releases them on a signed proof, and refunds the payer if the agent fails to deliver within a deadline.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract USDCScanEscrow is Ownable {
IERC20 public usdc;
address public payer;
address public agent;
uint256 public amount; // escrowed USDC (6 decimals)
uint256 public deadline; // block.timestamp after which payer can reclaim
bytes32 public taskHash; // keccak256 of the agreed‑upon output spec
bool public released;
event FundsDeposited(address indexed payer, address indexed agent, uint256 amount);
event FundsReleased(address indexed agent, uint256 amount);
event FundsRefunded(address indexed payer, uint256 amount);
constructor(address _usdc, address _payer, address _agent, uint256 _amount, uint256 _deadline, bytes32 _taskHash) {
require(_usdc != address(0), "USDC zero");
require(_payer != address(0) && _agent != address(0), "Zero address");
require(_amount > 0, "Zero amount");
require(_deadline > block.timestamp, "Deadline in past");
usdc = IERC20(_usdc);
payer = _payer;
agent = _agent;
amount = _amount;
deadline = _deadline;
taskHash = _taskHash;
}
/// @notice Called by the payer to fund the escrow.
function deposit() external {
require(msg.sender == payer, "Only payer");
require(usdc.transferFrom(payer, address(this), amount), "Transfer failed");
emit FundsDeposited(payer, agent, amount);
}
/// @notice Agent calls this after completing the work.
/// @param proof A signature from the agent over (taskHash, block.number) proving they did the work.
function release(bytes calldata proof) external {
require(!released, "Already released");
require(block.timestamp <= deadline, "Deadline passed");
// Recover signer: the agent must sign keccak256(abi.encodePacked(taskHash, block.number))
bytes32 msg = keccak256(abi.encodePacked(taskHash, block.number));
address signer = ecrecover(msg, uint8(proof[0]), slice(proof, 1, 32), slice(proof, 33, 32));
require(signer == agent, "Invalid signature");
released = true;
usdc.transfer(agent, amount);
emit FundsReleased(agent, amount);
}
/// @notice Payer can reclaim funds after the deadline if the agent never released.
function refund() external {
require(msg.sender == payer, "Only payer");
require(block.timestamp > deadline, "Deadline not reached");
require(!released, "Already released");
usdc.transfer(payer, amount);
emit FundsRefunded(payer, amount);
}
// Helper to slice bytes calldata (solidity >=0.8.0)
function slice(bytes calldata b, uint256 start, uint256 len) internal pure returns (bytes memory) {
require(start + len <= b.length, "Slice out of bounds");
bytes memory ret = new bytes(len);
for (uint256 i = 0; i < len; i++) {
ret[i] = b[start + i];
}
return ret;
}
}
What this contract does
| Step | Actor | On‑chain action |
|---|---|---|
| 1️⃣ | Payer | Calls deposit() → USDC moved from payer’s wallet to the contract. |
| 2️⃣ | Agent | Performs the off‑chain work, then signs `keccak256(taskHash |
| 3️⃣ | Agent | Calls {% raw %}release(signature) → contract verifies the signature, transfers USDC to agent. |
| 4️⃣ | Payer | If the agent never calls release() before deadline, payer calls refund() to get the USDC back. |
The contract is trustless because:
- The payer cannot withdraw funds before the agent signs (the contract holds them).
- The agent cannot claim funds without producing a valid signature that ties the work to a known
taskHash. - After the deadline, the payer can always recover their funds, eliminating the risk of funds being locked forever.
Integrating the escrow from an agent (TypeScript/ethers.js)
Below is a realistic snippet an autonomous agent might run after receiving a job request. It assumes:
- The agent already knows the USDC address on Base (
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913). - The agent has a funded wallet (private key in env).
- The job details (
taskHash,amount,deadline) were agreed off‑chain (e.g., via a signed HTTP request).
ts
// agent-worker.ts
import { ethers } from "ethers";
import dotenv from "dotenv";
dotenv.config();
const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const PRIVATE_KEY = process.env.PRIVATE_KEY!;
const RPC_URL = "https://mainnet.base.org"; // public RPC (or your own)
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdcAbi = [
"function transferFrom(address src, address dst, uint256 amt) returns (bool)",
"function balanceOf(address) view returns (uint256)",
"function decimals() view returns (uint8)"
];
const usdc = new ethers.Contract(USDC_BASE, usdcAbi, wallet);
// Escrow ABI (only the functions we need)
const escrowAbi = [
"function deposit()",
"function release(bytes calldata proof)",
"function refund()",
"function deadline() view returns (uint256)",
"function taskHash() view returns (bytes32)",
"function amount() view returns (uint256)",
"function agent() view returns (address)",
"function payer() view returns (address)"
];
async function runJob() {
// Example job data – in practice you’d get this from a queue or a request.
const escrowAddress = "0xEscrowContractAddressHere"; // deployed via a factory
const taskHash = "0xae3f..."; // keccak256 of the agreed spec
const amount = ethers.parseUnits("0.05", 6); // $0.05 USDC (6 decimals)
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const escrow = new ethers.Contract(escrowAddress, escrowAbi, wallet);
// 1️⃣ Fund the escrow (payer would have done this; agent just verifies)
const bal = await usdc.balanceOf(wallet.address);
if (bal < amount) {
throw new Error("Insufficient USDC to fund escrow");
}
// Approve escrow to pull USDC from our wallet (if we are the payer)
await usdc.approve(escrowAddress, amount);
const depositTx = await escrow.deposit();
await depositTx.wait();
console.log("Escrow funded");
// 2️⃣ Do the work (placeholder)
const result = await doTheWork(); // your agent’s logic
// 3️⃣ Build proof: sign(taskHash || block.number)
const block = await provider.getBlock("latest");
const msgHash = ethers.keccak256(
ethers.concat([
ethers.zeroPadValue
Top comments (0)