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 custodial intermediary.
Why escrow matters for AI‑to‑AI commerce
Autonomous agents can already call HTTP APIs, sign transactions, and read on‑chain data. What they cannot do reliably is trust a counterparty to pay after the work is done—or to deliver the work before receiving payment. In a world where agents spin up, execute a task, and shut down within seconds, any reliance on reputation systems or off‑chain invoicing introduces failure modes that are hard to recover from.
A trustless escrow solves this by locking the payer’s funds in a smart contract that only releases them when a verifiable condition is met (e.g., a signed receipt, a hash of the result, or an oracle attestation). The agent never needs to hold a custodial wallet; it only needs to sign a transaction that moves money into the escrow and later a transaction that pulls it out—both of which can be performed atomically with the service call.
The x402 protocol (a lightweight extension of HTTP 402 Payment Required) gives us a standard way to convey price, payment token, and a payment‑validation endpoint. When combined with a minimal escrow contract, x402 lets agents negotiate and settle payments in a single round‑trip HTTP exchange.
The escrow contract in practice
Below is a minimal, auditable escrow that works with any ERC‑20 token (we’ll use USDC on Base). It holds funds, releases them to the provider when the consumer supplies a valid receipt (a signed message), and can refund the consumer if the provider fails to respond.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract USDC meant for x402 escrow is Ownable {
IERC20 public immutable usdc;
address public provider; // set at deployment
address public consumer; // set when first deposit occurs
uint256 public price; // in USDC (6 decimals)
// Emitted when escrow is funded, paid out, or refunded
event Deposit(address indexed from, uint256 amount);
event Payout(address indexed to, uint256 amount);
event Refund(address indexed to, uint256 amount);
constructor(address _usdc, address _provider, uint256 _price) {
require(_usdc != address(0), "zero token");
require(_provider != address(0), "zero provider");
require(_price > 0, "zero price");
usdc = IERC20(_usdc);
provider = _provider;
price = _price;
}
/// @notice Consumer funds the escrow. Must be called exactly once.
function deposit() external {
require(msg.sender != provider, "provider cannot deposit");
require(consumer == address(0), "already deposited");
uint256 amt = usdc.balanceOf(address(this));
require(amt == 0, "already funded");
// consumer must have approved usdc to spend `price`
usdc.transferFrom(msg.sender, address(this), price);
consumer = msg.sender;
emit Deposit(msg.sender, price);
}
/// @notice Provider calls this after verifying a valid receipt.
/// The receipt is a signature from the consumer over:
/// keccak256(abi.encodePacked(address(this), price, serviceId))
function payout(bytes32 serviceId, bytes calleeSignature) external {
require(msg.sender == provider, "only provider");
require(consumer != address(0), "no deposit yet");
// Recover consumer address from signature
bytes32 hash = keccak256(
abi.encodePacked(address(this), price, serviceId)
);
address recovered = ecrecover(hash, v, r, s); // we unpack signature below
require(recovered == consumer, "invalid signature");
// Transfer USDC to provider
usdc.transfer(provider, price);
emit Payout(provider, price);
// Reset for next use (optional)
consumer = address(0);
}
/// @notice Consumer can reclaim funds if provider never pays out.
/// Callable after a timeout (handled off‑chain) or if both parties agree.
function refund() external {
require(msg.sender == consumer, "only consumer");
require(provider != address(0), "provider not set");
usdc.transfer(consumer, price);
emit Refund(consumer, price);
consumer = address(0);
}
// Helper to unpack signature (v,r,s) from calleeSignature (65 bytes)
function _splitSignature(bytes memory sig)
internal
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
require(sig.length == 65, "invalid signature length");
assembly {
// first 32 bytes, after the length prefix
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
}
}
Key points
- The contract is ownerless after deployment—only the provider and consumer addresses matter.
- Price is baked in at deployment (you can redeploy a new escrow per job if prices vary).
- The provider must present a receipt signed by the consumer; this is the verifiable condition that triggers payment.
- Refunds are possible but require off‑chain coordination (e.g., a timeout or mutual agreement).
Agent‑side workflow (code snippets)
We’ll use viem (the lightweight ethers‑compatible library) for brevity. The same logic works with ethers.js or web3.js.
1. Provider – expose an x402‑protected endpoint
// provider.ts
import { createPublicClient, http, parseAbi } from "viem";
import { base } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { sign } from "viem";
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base USDC
const ESCROW_ADDRESS = "0xYourEscrowHere"; // deployed as shown above
const PRICE_USDC = 005000n; // $0.005 = 5,000 USDC (6 decimals)
const PROVIDER_KEY = "0x..."; // provider’s private key
const account = privateKeyToAccount(PROVIDER_KEY);
const publicClient = createPublicClient({ chain: base, transport: http() });
// Helper: verify consumer signature over (escrow, price, serviceId)
function verifyReceipt(
consumer: `0x${string}`,
signature: `0x${string}`,
serviceId: string
): boolean {
const hash = keccak256(
abi.encodePacked(
["address", "uint256", "string"],
[ESCROW_ADDRESS, PRICE_USDC, serviceId]
)
);
const { v, r, s } = splitSignature(signature);
return recoverAddress({ hash, v, r, s }) === consumer;
}
// Express‑style handler (adapt to your framework)
async function handler(req, res) {
// x402 header: `X-Payment-Required: usdc://<escrow>?price=5000`
const paymentReq = req.headers["x-payment-required"];
if (!paymentReq) {
res.status(402).set(
"X-Payment-Required",
`usdc://${ESCROW_ADDRESS}?price=${PRICE_USDC}`
);
return res.end();
}
// Consumer must have already deposited (we trust the escrow contract)
const { consumer, signature, serviceId } = req.body; // JSON payload
if (
!consumer ||
!signature ||
!verifyReceipt(consumer, signature, serviceId)
) {
return res.status(401).json({ error: "invalid receipt" });
}
// Call escrow to release funds
const escrowAbi = parseAbi([
"function payout(bytes32 serviceId, bytes calldata signature)",
]);
const { request } = await publicClient.simulateContract({
address: ESCROW_ADDRESS,
abi: escrowAbi,
functionName: "payout",
args: [keccak256(toHex(serviceId)), signature],
account,
});
const hash = await publicClient.writeContract(request);
await publicClient.waitForTransactionReceipt({ hash });
// Now perform the actual work and return result
const result = await doTheWork(serviceId);
res.json({ result });
}
The provider only needs to hold a private key to sign the escrow call; the actual USDC never touches their wallet until the escrow releases it.
Top comments (0)