USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents that need to buy or sell services without a trusted intermediary.
1. Why escrow matters for AI‑to‑AI commerce
Autonomous agents can negotiate, invoke APIs, and even sign transactions, but they still face the classic “pay‑or‑get‑nothing” dilemma:
| Scenario | Risk for the buyer | Risk for the seller |
|---|---|---|
| Pay up‑front, then receive result | May lose funds if the agent fails or returns garbage | None |
| Receive result, then pay later | None | May never get paid if the buyer disappears |
| Trust a third‑party escrow service | Central point of failure, KYC, censorship | Same |
A trustless escrow removes the third party by locking funds in a smart contract that can only release them when a pre‑agreed condition is provably satisfied on‑chain (or via a trusted off‑chain attestation). USDC on Base is a good fit because:
- It’s a stable ERC‑20 token, so price volatility doesn’t affect the agreement.
- Base’s low gas fees (~$0.0001 per transaction) make micro‑payments viable.
- The contract can be kept simple, reducing attack surface.
2. Core design of a minimal USDC escrow contract
We’ll implement a single‑use escrow that holds funds for one job. The flow:
- Creator (buyer) deposits USDC and specifies the seller address and a validation hash (keccak256 of the expected result).
- Solver (seller) performs the work off‑chain, computes the result, and submits the pre‑image (the actual result) to the contract.
- The contract checks
keccak256(submitted) == validationHash. If true, it releases the USDC to the seller; otherwise, after a timeout the buyer can reclaim the funds.
Why a hash?
It lets the buyer verify correctness without revealing the result until the seller claims payment, preventing a “free‑rider” who could simply copy the answer.
2.1 Solidity (v0.8.20) implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract USDC lourdeEscrow {
IERC20 public immutable usdc;
address public buyer;
address public seller;
bytes32 public validationHash; // keccak256(expectedResult)
uint256 public amount; // in USDC (6 decimals on Base)
uint256 public deadline; // block.timestamp after which buyer can refund
enum State { Created, Funded, Solved, Refunded }
State public state;
constructor(address _usdc, address _buyer, address _seller, bytes32 _hash, uint256 _amount, uint256 _timeoutSeconds) {
require(_usdc != address(0), "USDC address zero");
require(_buyer != address(0) && _seller != address(0), "Zero party");
require(_amount > 0, "Amount must be > 0");
usdc = IERC20(_usdc);
buyer = _buyer;
seller = _seller;
validationHash = _hash;
amount = _amount;
deadline = block.timestamp + _timeoutSeconds;
state = State.Created;
}
/// @notice Buyer funds the escrow. Must be called exactly once.
function fund() external payable {
require(msg.sender == buyer, "Only buyer");
require(state == State.Created, "Already funded");
require(usdc.transferFrom(buyer, address(this), amount), "USDC transfer failed");
state = State.Funded;
}
/// @notice Seller submits the pre‑image. If hash matches, funds are released.
function solve(bytes calldata preimage) external {
require(msg.sender == seller, "Only seller");
require(state == State.Funded, "Not funded yet");
require(keccak256(preimage) == validationHash, "Invalid solution");
usdc.transfer(seller, amount);
state = State.Solved;
}
/// @notice Buyer can reclaim funds after deadline if seller never solved.
function refund() external {
require(msg.sender == buyer, "Only buyer");
require(state == State.Funded, "Not in fundable state");
require(block.timestamp >= deadline, "Deadline not reached");
usdc.transfer(buyer, amount);
state = State.Refunded;
}
/// @notice Helper for off‑chain verification: returns true if escrow is ready to payout.
function isSolved() external view returns (bool) {
return state == State.Solved;
}
}
Key points
- The contract holds USDC via
transferFrom; the buyer must approve the contract to spend their USDC beforehand (usdc.approve(address(escrow), amount)). - The
validationHashis set at deployment; the seller never learns the expected result until they compute it. - A simple timeout prevents funds from being locked forever.
- No external oracle is needed; verification is pure on‑chain hashing.
3. Agent‑side workflow (TypeScript + ethers.js)
Below is a minimal, production‑ready snippet that an autonomous agent could embed in its decision loop. It assumes the agent already possesses a private key (or wallet) with USDC on Base.
import { ethers } from "ethers";
import escrowAbi from "./USDC lourdeEscrow.json"; // ABI generated by solc
// -----------------------------------------------------------------------------
// Configuration (replace with your own values)
// -----------------------------------------------------------------------------
const RPC_URL = "https://mainnet.base.org"; // Base mainnet RPC
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const PRIVATE_KEY = process.env.PRIVATE_KEY!; // funds must be loaded beforehand
const BUYER = new ethers.Wallet(PRIVATE_KEY, new ethers.JsonRpcProvider(RPC_URL));
const SELLER = "0xSellerAddress..."; // set when the job is posted
const AMOUNT_USDC = ethers.parseUnits("0.05", 6); // $0.05 (USDC has 6 decimals)
const TIMEOUT_SECONDS = 3600; // 1 hour to solve
// -----------------------------------------------------------------------------
async function createEscrow(jobHash: BytesLike): Promise<string> {
const factory = new ethers.ContractFactory(escrowAbi, escrowAbi, BUYER);
const escrow = await factory.deploy(
USDC_ADDRESS,
BUYER.address,
SELLER,
jobHash,
AMOUNT_USDC,
TIMEOUT_SECONDS
);
await escrow.waitForDeployment();
const address = await escrow.getAddress();
// Approve USDC spending
const usdc = new ethers.Contract(
USDC_ADDRESS,
["function approve(address spender, uint256 amount) returns (bool)"],
BUYER
);
const approveTx = await usdc.approve(address, AMOUNT_USDC);
await approveTx.wait();
// Fund the escrow
const fundTx = await escrow.fund();
await fundTx.wait();
return address;
}
async function attemptSolve(escrowAddr: string, solution: Uint8Array): Promise<boolean> {
const escrow = new ethers.Contract(escrowAddr, escrowAbi, BUYER);
const tx = await escrow.solve(solution);
const receipt = await tx.wait();
// Check if state moved to Solved (optional)
const state = await escrow.state();
return state === 2; // enum State.Solved == 2
}
async function refundIfNeeded(escrowAddr: string): Promise<void> {
const escrow = new ethers.Contract(escrowAddr, escrowAbi, BUYER);
const now = (await BUYER.provider.getBlock("latest")).timestamp;
const deadline = await escrow.deadline();
if (now >= Number(deadline)) {
const tx = await escrow.refund();
await tx.wait();
}
}
/* Example usage inside an agent loop */
export async function handleJob(job: { hash: BytesLike; compute: () => Promise<Uint8Array> }) {
const escrowAddr = await createEscrow(job.hash);
try {
const result = await job.compute(); // off‑chain work (ML inference, data fetch, etc.)
const solved = await attemptSolve(escrowAddr, result);
if (!solved) {
console.error("Solution rejected – likely hash mismatch");
await refundIfNeeded(escrowAddr);
} else {
console.log("Job solved and paid");
}
} catch (err) {
console.error("Agent failed to compute:", err);
await refundIfNeeded(escrowAddr);
}
}
Explanation of the flow
- Deploy a new escrow contract with the hash of the expected output.
- Approve and fund the contract with the agreed USDC amount.
- The agent (acting as either buyer or seller) computes the result off‑chain.
- It calls
solve(bytes); if the hash matches, the contract transfers USDC to the seller. - If the timeout elapses without a valid solve, the buyer calls
refund()to retrieve funds.
Top comments (0)