USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
A pragmatic look at building verifiable payment flows for autonomous services, with concrete code and the trade‑offs you’ll hit in production.
Why an escrow matters for AI agents
When an AI agent offers a paid API—say, image classification or text summarization—you need two guarantees: the consumer only pays if the work is delivered, and the provider gets paid only after the consumer verifies the output. Traditional SaaS models rely on centralized billing and reputation, but autonomous agents can’t trust a third‑party invoicing system that might go offline or be censored. A blockchain‑based escrow gives both parties a deterministic, on‑chain guarantee without requiring a human intermediary.
The downside is that you inherit blockchain latency, gas costs, and the need to manage private keys securely. You also have to accept that the escrow contract itself is code that can have bugs; auditing and upgradeability become part of the operational budget.
High‑level flow
- Agent registers its service endpoint and a USDC price.
- Consumer creates an escrow deposit (agent‑address, amount, timeout).
-
Agent performs the job off‑chain, then calls
fulfillwith a cryptographic proof (e.g., a signed hash of the result). -
Consumer validates the proof; if satisfied, they call
releasewhich transfers USDC to the agent. - If the consumer disputes or the timeout elapses, either party can call
refundto return the deposit.
All steps are executed on Base, an Ethereum L2 with cheap transactions (~$0.0005) and fast finality (~2 seconds).
Solidity escrow contract
Below is a minimal, auditable escrow that works with any ERC‑20 token (USDC on Base: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913). It avoids complex upgrade patterns to keep the attack surface small.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
}
/**
* @notice Simple escrow for a single USDC payment.
* @dev The agent must be the caller of `fulfill`. The consumer calls `release` or `refund`.
*/
contract USDC cuyosAgentEscrow {
IERC20 public immutable usdc;
address public immutable agent;
address public consumer;
uint256 public amount; // locked USDC (6 decimals)
uint256 public deadline; // block.timestamp after which refund is allowed
bytes32 public jobId; // hash of the off‑chain request (optional)
bytes32 public resultHash; // hash of the agent’s output, set on fulfill
enum State { Created, Funded, Fulfilled, Released, Refunded }
State public state;
event Deposit(address indexed consumer, uint256 amount);
event Fulfill(address indexed agent, bytes32 resultHash);
event Release(address indexed agent);
event Refund(address indexed consumer);
constructor(
address _usdc,
address _agent,
uint256 _amount,
uint256 _timeoutSeconds,
bytes32 _jobId
) {
require(_usdc != address(0), "USDC zero");
require(_agent != address(0), "Agent zero");
require(_amount > 0, "Zero amount");
usdc = IERC20(_usdc);
agent = _agent;
consumer = msg.sender;
amount = _amount;
deadline = block.timestamp + _timeoutSeconds;
jobId = _jobId;
state = State.Created;
}
/** Consumer deposits USDC into the escrow. */
function deposit() external payable {
require(state == State.Created, "Wrong state");
require(msg.sender == consumer, "Not consumer");
require(usdc.transferFrom(consumer, address(this), amount), "Transfer failed");
state = State.Funded;
emit Deposit(consumer, amount);
}
/** Agent calls after completing the job, providing a hash of the result. */
function fulfill(bytes32 _resultHash) external {
require(state == State.Funded, "Not funded");
require(msg.sender == agent, "Only agent");
resultHash = _resultHash;
state = State.Fulfilled;
emit Fulfill(agent, _resultHash);
}
/** Consumer releases payment if they accept the result. */
function release() external {
require(state == State.Fulfilled, "Not fulfilled");
require(msg.sender == consumer, "Only consumer");
state = State.Released;
usdc.transfer(agent, amount);
emit Release(agent);
}
/** Either party can reclaim funds after timeout or if agent never fulfills. */
function refund() external {
require(state != State.Released, "Already released");
require(block.timestamp >= deadline || state == State.Funded, "Too early");
state = State.Refunded;
usdc.transfer(consumer, amount);
emit Refund(consumer);
}
/** Helper for consumers to verify the agent’s off‑chain proof. */
function verifyResult(bytes calldata proof) external view returns (bool) {
// Example: proof is an Ethereum signed message: keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", jobId, resultHash))
// Recover signer and compare to agent address.
bytes32 messageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", jobId, resultHash));
address signer = ecrecover(messageHash, uint8(proof[0]), bytes32(proof[1:33]), bytes32(proof[33:65]));
return signer == agent;
}
}
What this contract does:
- Holds USDC in escrow until the agent signals completion.
- Lets the consumer release funds only after they have verified the agent’s off‑chain proof (the
verifyResulthelper is optional but useful). - Guarantees a timeout‑based refund, preventing funds from being locked forever.
Agent‑side implementation (TypeScript + ethers.js)
Assuming the agent runs in a Node.js environment and already possesses a private key that controls the agent address used in the contract deployment.
ts
import { ethers } from "ethers";
import escrowAbi from "./abi/USDCпаEscrow.json"; // generated from the solidity above
// Configuration – adjust for Base mainnet
const RPC_URL = "https://mainnet.base.org";
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ADDRESS = "0xYourEscrowDeployedHere"; // set after deployment
const AGENT_PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!; // never commit this
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(AGENT_PRIVATE_KEY, provider);
const escrow = new ethers.Contract(ESCROW_ADDRESS, escrowAbi, wallet);
// Example: a simple text‑summarization service
async function handleSummarizeJob(jobId: string, inputText: string) {
// 1️⃣ Wait for the consumer to fund the escrow (polling or event listener)
const depositFilter = escrow.filters.Deposit();
await escrow.once(depositFilter, (consumer, amount) => {
console.log(`Escrow funded by ${consumer} for ${ethers.formatUnits(amount, 6)} USDC`);
});
// 2️⃣ Perform the off‑chain work
const summary = await callLocalLLM(inputText); // your model inference
const resultHash = ethers.keccak256(ethers.toUtf8Bytes(summary));
// 3️⃣ Fulfill on‑chain
const tx = await escrow.fulfill(resultHash);
await tx.wait();
console.log("Fulfilled, resultHash:", resultHash);
// 4️⃣ (Optional) Provide a signed proof so the consumer can verify locally
const messageHash = ethers.keccak256(
ethers.concat([
ethers.toUtf8Bytes("\x19Ethereum Signed Message:\n32"),
ethers.zeroPadValue(jobId, 32),
ethers.zeroPadValue(resultHash, 32)
])
);
const signature = await wallet.signMessage(ethers.getBytes(messageHash));
// Send `signature` back to the consumer via your off‑chan‑nel (e.g., HTTP callback
Top comments (0)