USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Published on dev.to – a practical guide for developers building autonomous agents
Why USDC?
When an AI agent needs to purchase a service (e.g., a language‑model call, a data lookup, or a compute job) from another autonomous agent, the simplest way to guarantee payment without a trusted intermediary is to lock funds in a smart‑contract escrow that releases them only after the service is provably delivered.
USDC on a low‑cost L2 like Base offers three concrete advantages:
| Property | Why it matters for agents |
|---|---|
| Stable value | 1 USDC ≈ $1 eliminates price‑volatility risk during the escrow window. |
| ERC‑20 interface | Widely supported by wallets, libraries (ethers.js, viem), and existing DeFi tooling. |
| Low gas on Base | Typical transfer ≈ 0.0005 USDC (≈ $0.0005) – cheap enough for micro‑transactions. |
The trade‑off is that you must accept the on‑chain latency (≈ 2 seconds on Base) and the need for each agent to hold a wallet with a private key (or a programmable signer).
The x402 Payment Pattern
The x402 protocol repurposes the HTTP 402 Payment Required status code to signal that a client must pay before the server returns a resource. The flow is:
- Client makes a normal GET/POST request.
-
Server responds
402 Payment Requiredwith a headerX-Payment-Requestcontaining:-
token– the ERC‑20 contract address (USDC) -
amount– smallest unit (wei) to pay -
payload– arbitrary data the server will verify (e.g., a nonce, request ID) -
signature– ECDSA signature of the above fields by the server’s escrow wallet
-
- Client verifies the signature, then pays the escrow contract (or directly to the server if the escrow is a simple pay‑and‑release).
- After payment, the client retries the request with an
X-Payment-Headercontaining the transaction hash. -
Server checks that the transaction succeeded, verifies the payload matches the original request, and returns the actual resource (
200 OK).
This pattern keeps the payment logic off‑chain (just a signature verification) while ensuring atomicity via the escrow contract.
Minimal Escrow Contract (Solidity)
Below is a bare‑bones escrow that works with any ERC‑20 token (USDC in our case). It holds funds until the seller calls release() with a valid payment proof; the buyer can also refund() after a timeout if the seller never releases.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract USDEscrow is ReentrancyGuard {
IERC20 public immutable usdc;
address public immutable seller;
address public immutable buyer;
uint256 public amount; // amount locked (in USDC wei, 6 decimals)
uint256 public deadline; // unix timestamp after which buyer can refund
bool public released;
event Deposited(address indexed from, uint256 amount);
event Released(address indexed to, uint256 amount);
event Refunded(address indexed to, uint256 amount);
constructor(
address _usdc,
address _seller,
address _buyer,
uint256 _amount,
uint256 _lockSeconds // how long the buyer waits before they can refund
) {
require(_usdc != address(0), "USDC zero");
require(_seller != address(0) && _buyer != address(0), "zero participant");
require(_amount > 0, "zero amount");
usdc = IERC20(_usdc);
seller = _seller;
buyer = _buyer;
amount = _amount;
deadline = block.timestamp + _lockSeconds;
}
/* Called by the buyer to fund the escrow */
function deposit() external nonReentrant {
require(msg.sender == buyer, "only buyer");
require(usdc.transferFrom(msg.sender, address(this), amount), "transfer failed");
emit Deposited(msg.sender, amount);
}
/* Seller calls after they have fulfilled the service */
function release() external nonReentrant {
require(msg.sender == seller, "only seller");
require(!released, "already released");
require(block.timestamp <= deadline, "past deadline");
released = true;
usdc.transfer(seller, amount);
emit Released(seller, amount);
}
/* Buyer can reclaim funds if seller never releases */
function refund() external nonReentrant {
require(msg.sender == buyer, "only buyer");
require(released == false, "already released");
require(block.timestamp > deadline, "still within lock period");
usdc.transfer(buyer, amount);
emit Refunded(buyer, amount);
}
/* Helper: check if a payment (deposit) has been made */
function escrowBalance() external view returns (uint256) {
return usdc.balanceOf(address(this));
}
}
Key points
- The contract is non‑upgradable and uses OpenZeppelin’s
ReentrancyGuardto avoid re‑entrancy attacks. -
deposit()must be called by the buyer before the seller signs the x402 challenge; otherwise the seller could sign a request for funds that aren’t locked. - The
deadlinegives the buyer a deterministic window to recover funds if the seller ghosts.
Agent‑Side Implementation (TypeScript + ethers.js)
Assume an AI agent that wants to call a remote summarization service. The service publishes an x402‑protected endpoint at https://summarizer.example.com/summarize.
ts
import { ethers } from "ethers";
import axios from "axios";
// ------------------- Configuration -------------------
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const RPC_URL = "https://mainnet.base.org"; // public RPC (or your own)
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!; // agent's wallet
const ESCROW_ABI = [ /* abi from the contract above */ ];
const ESCROW_ADDRESS = "0xYourEscrowAddressHere"; // deployed per job
const provider = new ethers.JsonRpcProvider(RPC_URL);
const signer = new ethers.Wallet(PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDRESS, ["function balanceOf(address) view returns (uint256)", "function transfer(address,uint256) returns (bool)"], signer);
const escrow = new ethers.Contract(ESCROW_ADDRESS, ESCROW_ABI, signer);
// -----------------------------------------------------
async function requestSummarization(text: string): Promise<string> {
// 1️⃣ First request – expect 402
let resp = await axios.get("https://summarizer.example.com/summarize", {
params: { text },
validateStatus: () => true // we handle non‑2xx ourselves
});
if (resp.status !== 402) {
throw new Error(`Unexpected status ${resp.status}`);
}
const {
token,
amount,
payload, // e.g. a nonce + requestId
signature
} = resp.headers["x-payment-request"]; // server returns JSON string here
const req = JSON.parse(payload);
// 2️⃣ Verify the server’s signature
const msgHash = ethers.solidityPackedKeccak256(
["address", "uint256", "bytes"],
[token, amount, ethers.toUtf8Bytes(req.nonce + req.requestId)]
);
const recovered = ethers.verifyMessage(msgHash, signature);
if (recovered.toLowerCase() !== signer.address.toLowerCase()) {
throw new Error("Invalid x402 signature");
}
// 3️⃣ Fund escrow (if not already)
const escrowBal = await escrow.escrowBalance();
if (escrowBal === 0) {
const usdcBal = await usdc.balanceOf(signer.address);
if (usdcBal < amount) throw new Error("Insufficient USDC");
await usdc.approve(ESCROW_ADDRESS, amount);
const depositTx = await escrow.deposit();
await depositTx.wait();
}
// 4️⃣ Pay the server directly (simple model) – alternatively call escrow.release() after verification
const payTx = await usdc.transfer(
signer.address, // In a real escrow the seller
Top comments (0)