USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers who are building autonomous AI agents that need to receive payment for verifiable work without relying on a centralized intermediary.
1. Why escrow matters for agent‑to‑human contracts
AI agents can perform tasks (data labeling, micro‑model inference, prompt completion) and produce a deterministic output that can be verified on‑chain or via an off‑chain attestation. If the agent simply sends an invoice and waits for a human to pay, two problems arise:
- Counterparty risk – the human may refuse to pay after seeing the result.
- Atomicity gap – there is no built‑in guarantee that payment and delivery happen together.
An escrow contract solves both by locking funds upfront and releasing them only when a pre‑agreed condition is satisfied. Using a stablecoin like USDC removes price volatility, while deploying on a low‑cost L2 (Base) keeps gas fees in the sub‑cent range for most interactions.
2. The building blocks
| Component | Role | Typical choice |
|---|---|---|
| Token | Stablecoin used for payment | USDC (ERC‑20) on Base (contract 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) |
| Escrow logic | Holds funds, releases on proof | Minimal Solidity contract (see §3) |
| Verification | Off‑chain check that the agent did the work | Agent‑signed payload + optional oracle or ZK‑proof |
| SDK / RPC | Interact with Ethereum‑compatible chains |
viem (or ethers.js) + Base public RPC (https://base.meowrpc.com) |
The escrow contract is intentionally simple: it does not try to enforce the quality of the work; that verification stays off‑chain. This keeps gas costs low and lets developers plug in any verification mechanism they prefer (signature, Merkle proof, etc.).
3. Minimal escrow contract (Solidity 0.8.20)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract USDCôngEscrow {
IERC20 public immutable usdc;
address public payer;
address public payee;
uint256 public amount;
bool public released;
// `payloadHash` is the off‑chain verification input (e.g., keccak256 of agent result)
bytes32 public payloadHash;
constructor(address _usdc, address _payer, address _payee, uint256 _amount, bytes32 _payloadHash) {
require(_usdc != address(0), "bad token");
require(_payer != address(0) && _payee != address(0), "bad parties");
require(_amount > 0, "zero amount");
usdc = IERC20(_usdc);
payer = _payer;
payee = _payee;
amount = _amount;
payloadHash = _payloadHash;
}
/// @notice Fund the escrow – caller must be the payer and must approve USDC transfer
function fund() external {
require(msg.sender == payer, "only payer");
require(usdc.allowance(payer, address(this)) >= amount, "insufficient allowance");
usdc.transferFrom(payer, address(this), amount);
}
/// @notice Release funds to the payee if the supplied hash matches the stored one
function release(bytes32 providedHash) external {
require(!released, "already released");
require(providedHash == payloadHash, "invalid proof");
require(usdc.balanceOf(address(this)) >= amount, "escrow underfunded");
released = true;
usdc.transfer(payee, amount);
}
/// @notice Refund the payer if the escrow expires or verification fails
function refund() external {
require(msg.sender == payer, "only payer");
require(!released, "already released");
usdc.transfer(payer, usdc.balanceOf(address(this)));
}
}
Key points
- The constructor captures the expected payload hash (
payloadHash). This hash is computed off‑chain by the agent (e.g.,keccak256(abi.encodePacked(result, nonce))). -
fund()moves USDC from the payer into the contract. The payer must first approve the escrow to spend their USDC (usdc.approve(address(escrow), amount)). -
release()checks that the hash supplied by the caller matches the stored expectation; if so, it transfers the full amount to the payee. -
refund()lets the payer reclaim funds if the agent never provides a valid proof or if a timeout is enforced off‑chain.
The contract is deliberately non‑upgradable and without admin keys after deployment, which eliminates a central point of failure but also means any bug is permanent.
4. Agent‑side workflow (TypeScript + viem)
Below is a concise, end‑to‑end example showing how an autonomous agent would:
- Compute a result and its hash.
- Deploy (or reuse) the escrow contract.
- Fund it (assuming the payer has already approved).
- Submit the proof to trigger payout.
import { createPublicClient, http, parseEther } from 'viem';
import { base } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
import { deployContract, getContractAt } from 'viem';
// ------------------- Configuration -------------------
const RPC_URL = 'https://base.meowrpc.com';
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // Base USDC
const PRIVATE_KEY = process.env.PRIVATE_KEY!; // payer's key (for funding)
const AGENT_KEY = process.env.AGENT_KEY!; // agent's key (to sign payload)
const ESCROW_ABI = [ /* copy the ABI from the Solidity contract above */ ];
const client = createPublicClient({
chain: base,
transport: http(RPC_URL),
});
const payer = privateKeyToAccount(PRIVATE_KEY);
const agent = privateKeyToAccount(AGENT_KEY);
// ------------------- Helper: compute payload hash -------------------
function computePayloadHash(result: string, nonce: bigint): `0x${string}` {
// In practice you would use a solidity-compatible keccak256
const encoded = ethers.AbiCoder.defaultAbiCoder().encode(
['string', 'uint256'],
[result, nonce]
);
return ethers.keccak256(encoded) as `0x${string}`;
}
// ------------------- Main flow -------------------
async function run() {
// 1. Agent does work
const result = '{"label":"cat","confidence":0.93}';
const nonce = BigInt(Date.now()); // prevent replay
const payloadHash = computePayloadHash(result, nonce);
// 2. Deploy escrow (could be cached address if reused)
const escrow = await deployContract(client, {
abi: ESCROW_ABI,
bytecode: `0x${/* compiled bytecode */}`,
args: [
USDC_ADDRESS,
payer.address, // payer
agent.address, // payee (agent receives funds)
parseEther('0.05'), // $0.05 worth of USDC (6 decimals)
payloadHash,
],
});
// 3. Payer funds the escrow (must have approved USDC beforehand)
const usdcContract = await getContractAt(client, {
address: USDC_ADDRESS,
abi: [
// minimal approve + balanceOf
{ name: 'approve', type: 'function', inputs: [{ name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }], outputs: [{ name: '', type: 'bool' }] },
{ name: 'allowance', type: 'function', inputs: [{ name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }], outputs: [{ name: '', type: 'uint256' }] },
{ name: 'balanceOf', type: 'function', inputs: [{ name: 'account', type: 'address' }], outputs: [{ name: '', type: 'uint256' }] },
],
});
// Approve if needed
const allowance = await usdcContract.read.allowance([payer.address, escrow.address]);
if (allowance < parseEther('0.05')) {
await usdcContract.write.approve([escrow.address, parseEther('0.10')]); // some headroom
}
// Fund escrow
await usdcContract.write.transferFrom([payer.address, escrow.address, parseEther('0.05')]);
// 4. Agent submits proof to release funds
const escrowInstance = await getContractAt(client, {
address: escrow.address,
abi: ESCROW_ABI,
});
await escrowInstance.write.release([payloadHash]);
console.log('Payment released to agent');
}
run().catch(console.error);
What the code demonstrates
-
Atomicity – The payer cannot reclaim funds once
release()succeeds because the contract transfers USDC immediately. - Replay protection – The
Top comments (0)