USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Building autonomous agents that can buy and sell services without a human intermediary requires a payment primitive that is both programmable and censorship‑resistant. USDC on Base gives us a stable ERC‑20 token, but moving money between agents still needs a trust layer. The pattern that works in practice is a simple escrow contract funded in advance, released only when the buyer can verifiably prove that the seller fulfilled the agreed‑upon work.
Architecture Overview
+----------------+ +---------------------+ +-----------------+
| AI Agent A |------>| USDC Escrow Contract|<------| AI Agent B |
| (buyer) | 1. deposit (hold funds) | 2. release on proof | (seller) |
+----------------+ +---------------------+ +-----------------+
^ |
| 3. off‑chain work result + proof (oracle/Merkle) |
+-----------------------------------------------------+
- Deposit – The buyer locks USDC in the escrow contract, specifying the seller’s address and a work identifier (e.g., a hash of the task description).
- Execution – The seller performs the task off‑chain (or on‑chain via a separate service) and produces a verifiable proof that the work matches the identifier.
- Release – The buyer (or an automated verifier) submits the proof to the contract; if valid, the contract transfers the escrowed amount to the seller. If the proof fails or a timeout expires, the buyer can reclaim the funds.
The contract never holds private keys; it only moves USDC according to on‑chain logic. All trust is shifted to the proof‑verification step, which we discuss later.
Smart Contract Implementation
Below is a minimal, auditable escrow contract written in Solidity 0.8.20. It uses OpenZeppelin’s IERC20 interface for USDC and includes a timeout to prevent funds from being locked forever.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract USDEscrow {
IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
address public buyer;
address public seller;
bytes32 public workId; // keccak256(task description || nonce)
uint256 public amount; // in USDC (6 decimals)
uint256 public depositTime;
uint256 public constant TIMEOUT = 3 days; // adjustable per use‑case
enum State { Created, Funded, Released, Refunded }
State public state;
event Deposited(address indexed buyer, address indexed seller, bytes32 workId, uint256 amount);
event Released(address indexed seller, uint256 amount);
event Refunded(address indexed buyer, uint256 amount);
event Timeout(address indexed buyer, uint256 amount);
constructor(address _usdc, address _buyer, address _seller, bytes32 _workId, uint256 _amount) {
require(_usdc != address(0), "USDC zero");
require(_buyer != address(0) && _seller != address(0), "Zero address");
require(_amount > 0, "Zero amount");
usdc = IERC20(_usdc);
buyer = _buyer;
seller = _seller;
workId = _workId;
amount = _amount;
state = State.Created;
}
/// @notice Buyer funds the escrow. Must be called exactly once.
function deposit() external {
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;
depositTime = block.timestamp;
emit Deposited(buyer, seller, workId, amount);
}
/// @notice Seller claims payment after presenting a valid proof.
/// The proof verification logic is deliberately left to the caller;
/// the contract only checks that the caller is the seller and that
/// the state is Funded. Off‑chain verification must happen before
/// calling this function.
function release() external {
require(msg.sender == seller, "Only seller");
require(state == State.Funded, "Not funded");
require(block.timestamp >= depositTime + TIMEOUT, "Not timed out yet"); // optional: allow early release if proof supplied
// In practice, the caller would have already verified the proof off‑chain.
// To keep the contract simple we trust the caller; see trade‑offs below.
usdc.transfer(seller, amount);
state = State.Released;
emit Released(seller, amount);
}
/// @notice Buyer reclaims funds after timeout if seller never released.
function refund() external {
require(msg.sender == buyer, "Only buyer");
require(state == State.Funded, "Not funded");
require(block.timestamp >= depositTime + TIMEOUT, "Still within timeout");
usdc.transfer(buyer, amount);
state = State.Refunded;
emit Refunded(buyer, amount);
}
/// @notice Helper to read remaining time (useful for UI).
function timeLeft() public view returns (uint256) {
if (state != State.Funded) return 0;
return (depositTime + TIMEOUT) > block.timestamp ? (depositTime + TIMEOUT) - block.timestamp : 0;
}
}
Why this shape?
- Minimal trusted logic – The contract only moves tokens; it does not try to interpret what “work done” means.
- Timeout – Guarantees the buyer can recover funds if the seller disappears.
- Explicit roles – Buyer and seller are set at deployment, preventing address‑spoofing attacks.
Agent Integration Code (Node.js + ethers.js)
Below is a compact example showing how an autonomous buyer agent would:
- Deploy (or attach to) the escrow contract.
- Deposit USDC.
- Request a service from a seller agent via HTTP (the seller could be an x402‑enabled endpoint).
- Verify the result off‑chain (here we illustrate a simple HMAC check; in production you’d use a zk‑SNARK, optimistic challenge, or trusted oracle).
- Call
release()if the proof validates, otherwiserefund()after timeout.
javascript
// ---------------------------------------------------------------
// Buyer Agent – USDC escrow workflow on Base
// ---------------------------------------------------------------
require('dotenv').config();
const { ethers } = require('ethers');
const abi = [
"function deposit()",
"function release()",
"function refund()",
"event Released(address indexed seller, uint256 amount)",
"event Refunded(address indexed buyer, uint256 amount)"
];
async function main() {
const provider = new ethers.JsonRpcProvider(process.env.BASE_RPC_URL);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
const usdcAddr = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const escrowAddr = process.env.ESCROW_ADDRESS; // pre‑deployed or create2
const escrow = new ethers.Contract(escrowAddr, abi, wallet);
// 1️⃣ Deposit – assume we already agreed on workId and amount off‑chain
const workId = ethers.keccak256(ethers.toUtf8Bytes("Summarize article X + nonce 42"));
const amount = ethers.parseUnits("0.05", 6); // $0.05 USDC (6 decimals)
// Approve escrow to pull USDC from our wallet
const usdc = new ethers.Contract(usdcAddr, ["function approve(address spender, uint25
Top comments (0)