USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents that need to pay or receive compensation for micro‑services without relying on a centralized intermediary.
Why Escrow Matters for Agent‑to‑Agent Payments
AI agents often expose HTTP‑callable functions (e.g., “summarize text”, “classify image”) and expect payment per invocation. In a traditional SaaS model the provider trusts the platform to collect funds and remit them later. For fully autonomous agents that trust model breaks down:
- Counterparty risk – an agent can’t sue a non‑paying client.
- Settlement latency – waiting for invoices or manual payouts defeats the purpose of real‑time automation.
- Opacity – agents can’t verify that the correct amount was transferred without inspecting a ledger.
An on‑chain escrow solves these by locking funds in a immutable contract before work begins and releasing them only when predefined conditions are met. USDC is a natural choice because it’s a regulated, 1:1 USD‑pegged ERC‑20 token with low volatility and broad wallet support.
Core Design Principles
- Minimal on‑chain footprint – the escrow contract should be cheap to deploy and interact with (≤ 30 k gas for deposit, ≤ 50 k for release).
- Deterministic release criteria – either a signed proof from the agent (off‑chain verification) or a timeout that returns funds to the payer.
- No external oracle dependence – the contract does not need price feeds; it works purely with token amounts.
-
Upgrade‑safe – use the OpenZeppelin
Upgradeablepattern only if you truly need it; otherwise keep the contract immutable to avoid admin keys.
Escrow Contract (Solidity, Solidity 0.8.20)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title SimpleUSDCheed
* @notice Holds USDC deposited by a payer and releases it to an agent
* when a valid off‑chain signature is presented, or after a timeout.
*/
contract SimpleUSDCeed is Ownable {
IERC20 public immutable usdc; // USDC token address (set at deploy)
address public payer; // Who funds the escrow
address public agent; // Who can claim the funds
uint256 public amount; // Amount of USDC locked (in wei, 6 decimals)
uint256 public deadline; // Unix timestamp after which payer can reclaim
bool public released; // Prevent double‑release
// EIP‑712 domain separator for off‑chain signatures
bytes32 public constant DOMAIN_SEPARATOR =
keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("USDC Escrow")),
keccak256(bytes("1")),
block.chainid,
address(this)
)
);
bytes32 public constant HASHED_TYPE = keccak256(
bytes("Claim(address agent,uint256 amount,uint256 deadline)")
);
constructor(
address _usdc,
address _payer,
address _agent,
uint256 _amount,
uint256 _timeoutSeconds
) {
require(_usdc != address(0), "Zero token");
require(_payer != address(0) && _agent != address(0), "Zero address");
require(_amount > 0, "Zero amount");
usdc = IERC20(_usdc);
payer = _payer;
agent = _agent;
amount = _amount;
deadline = block.timestamp + _timeoutSeconds;
}
/**
* @notice Payer funds the escrow. Must be called after deployment.
* The caller must approve the contract to spend `amount` USDC.
*/
function deposit() external {
require(msg.sender == payer, "Not payer");
require(usdc.allowance(payer, address(this)) >= amount, "Insufficient allowance");
usdc.transferFrom(payer, address(this), amount);
}
/**
* @notice Agent presents an EIP‑712 signature proving they completed the work.
* The signature is over (agent, amount, deadline) to prevent replay.
*/
function claim(bytes calldata sig) external {
require(!released, "Already released");
require(block.timestamp <= deadline, "Deadline passed");
(uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);
bytes32 structHash = keccak256(abi.encode(HASHED_TYPE, agent, amount, deadline));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
address signer = ecrecover(digest, v, r, s);
require(signer == agent, "Invalid signature");
released = true;
usdc.transfer(agent, amount);
}
/**
* @notice Payer can reclaim funds after the deadline if the agent never claimed.
*/
function refund() external {
require(msg.sender == payer, "Not payer");
require(block.timestamp > deadline, "Deadline not reached");
require(!released, "Already released");
released = true;
usdc.transfer(payer, amount);
}
/* Helper to split signature into v, r, s */
function splitSignature(bytes memory sig)
internal
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
require(sig.length == 65, "Invalid signature length");
assembly {
r := mload(add(sig, 0x20))
s := mload(add(sig, 0x40))
v := byte(0, mload(add(sig, 0x60)))
}
// Adjust v to 0 or 1 for ecrecover
if (v < 27) {
v += 27;
}
}
}
Key points:
- The contract is ownerless after deployment – only the predefined
payerandagentcan act. -
deposit()must be called by the payer after they have approved the contract to spend USDC (usdc.approve(address(escrow), amount)). - The agent supplies an EIP‑712 signed message that ties the claim to the specific escrow instance (agent, amount, deadline). This prevents replay across different escrows.
- If the agent never signs before
deadline, the payer can callrefund()and recover the full amount.
Agent‑Side Implementation (TypeScript / ethers.js)
Below is a minimal worker that:
- Listens for an incoming HTTP request (e.g., via Cloudflare Workers).
- Performs the promised AI micro‑service.
- Produces an EIP‑712 signature authorizing the escrow release.
- Returns the signature to the caller, who then submits it on‑chain.
ts
import { ethers } from "ethers";
// --- CONFIG (set via environment variables) ---
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const ESCROW_ADDRESS = process.env.ESCROW_ADDRESS!; // deployed SimpleUSDCeed
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!; // agent's EOA
const CHAIN_ID = 8453; // Base
// ------------------------------------------------
const provider = new ethers.JsonRpcProvider(
"https://mainnet.base.org" // replace with your preferred RPC
);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const escrow = new ethers.Contract(
ESCROW_ADDRESS,
[
"function claim(bytes sig) external",
"function agent() view returns (address)",
"function amount() view returns (uint256)",
"function deadline() view returns (uint256)",
],
wallet
);
// EIP‑712 domain (must match the contract)
const DOMAIN = {
name: "USDC Escrow",
version: "1",
chainId: CHAIN_ID,
verifyingContract: ESCROW_ADDRESS,
};
const types = {
Claim: [
{ name: "agent", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
export default {
async fetch(request: Request): Promise<Response> {
// 1️⃣ Parse incoming payload (example: { prompt: "Summarize this text..." })
const { prompt } = await request.json();
// 2️⃣ Perform AI work – replace with your model call
const result = await callMyModel(prompt); // returns string or json
// 3️⃣ Build
Top comments (0)