USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
For developers who want autonomous agents to sell services and get paid without a custodial middle‑man.
1. Why escrow matters for AI‑driven freelancing
Autonomous agents operate on code, not contracts. When a client asks an agent to run a task (e.g., generate a synthetic dataset, call an LLM, or scrape a public API), the two parties need a guarantee that:
- The agent will be paid only after delivering the agreed output.
- The client will not lose funds if the agent fails or disappears.
Traditional solutions rely on a trusted platform that holds money in a custodial account, runs dispute resolution, and can freeze funds. For a truly permissionless system we replace that custodian with a smart‑contract escrow that:
- Holds USDC (an ERC‑20 token) on a layer‑2 with low gas costs (Base, Arbitrum, Optimism, etc.).
- Releases funds only when a verifiable condition is met—typically a cryptographic proof that the agent performed the work.
- Can be invoked by anyone (client, agent, or a third‑party resolver) without needing to trust a central entity.
The escrow does not solve the problem of what constitutes valid work; that remains the agent’s responsibility. It only guarantees that payment follows the pre‑agreed rule.
2. Minimal escrow contract design
Below is a Solidity 0.8.20 contract that implements a simple two‑party escrow for USDC. It assumes the token address is known at deployment and that the agent will provide a hash of the expected output before work begins. The client deposits USDC, the agent reveals the preimage (the actual output) to claim payment, and a timeout lets the client reclaim funds if the agent never reveals.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract USDCerrorEscrow is ReentrancyGuard {
IERC20 public immutable usdc;
address public immutable client;
address public payable agent; // payable so we can send ETH if needed (not used here)
// The hash of the expected output, supplied by the agent before work starts.
bytes32 public outputHash;
// Deadline (block.timestamp) after which the client can refund.
uint256 public refundDeadline;
enum State { Created, Funded, Completed, Refunded }
State public state;
event Deposited(address indexed from, uint256 amount);
event OutputRevealed(bytes32 indexed outputHash, bytes output);
event Refunded(address indexed to, uint256 amount);
event PaidOut(address indexed to, uint256 amount);
constructor(
address _usdc,
address _client,
address _agent,
bytes32 _outputHash,
uint256 _refundDelaySeconds
) {
require(_usdc != address(0), "USDC zero");
require(_client != address(0), "Client zero");
require(_agent != address(0), "Agent zero");
usdc = IERC20(_usdc);
client = _client;
agent = payable(_agent);
outputHash = _outputHash;
refundDeadline = block.timestamp + _refundDelaySeconds;
state = State.Created;
}
/// @notice Client deposits USDC into escrow.
function deposit() external nonReentrant {
require(state == State.Created, "Not created");
require(msg.sender == client, "Only client");
uint256 amount = usdc.balanceOf(address(this)); // assume client pre‑approved transfer
// In practice the client would call usdc.transferFrom before calling deposit.
// Here we rely on the caller having already moved funds.
require(amount > 0, "Zero deposit");
state = State.Funded;
emit Deposited(msg.sender, amount);
}
/// @notice Agent reveals the preimage of outputHash to claim payment.
/// @param output The actual work product (bytes). The contract keccak256‑hashes it
/// and checks against the stored hash.
function revealOutput(bytes calldata output) external nonReentrant {
require(state == State.Funded, "Not funded");
require(msg.sender == agent, "Only agent");
require(keccak256(output) == outputHash, "Hash mismatch");
state = State.Completed;
emit OutputRevealed(outputHash, output);
_payAgent();
}
/// @notice Client can refund after the deadline if the agent never revealed.
function refund() external nonReentrant {
require(state == State.Funded, "Not funded");
require(block.timestamp >= refundDeadline, "Deadline not passed");
require(msg.sender == client, "Only client");
state = State.Refunded;
uint256 amount = usdc.balanceOf(address(this));
_sendUSDC(client, amount);
emit Refunded(client, amount);
}
function _payAgent() internal {
uint256 amount = usdc.balanceOf(address(this));
require(amount > 0, "Nothing to pay");
_sendUSDC(agent, amount);
emit PaidOut(agent, amount);
}
function _sendUSDC(address recipient, uint256 amount) internal {
// Using ERC20's transfer (assumes USDC follows standard; for USDT use safeTransfer)
bool success = usdc.transfer(recipient, amount);
require(success, "USDC transfer failed");
}
// Fallback to reject plain ETH transfers (we only handle USDC)
receive() external payable {
revert("No ETH accepted");
}
}
How it works in practice
- Agreement – Off‑chain, the client and agent agree on a price, a description of the work, and a timeout (e.g., 2 hours).
-
Commit – The agent computes a hash of the expected output (e.g.,
keccak256(JSON.stringify({taskId, result}))) and shares it with the client. The client then deploys the escrow contract with that hash and the agreed timeout. -
Fund – The client transfers the USDC to the escrow (via
usdc.transferFromor by approving and callingdeposit). -
Work – The agent performs the task off‑chain, obtains the real output, and calls
revealOutput(output). The contract verifies the hash and releases funds. -
Timeout – If the agent never reveals, the client can call
refund()after the deadline and recover the USDC.
The contract is deliberately minimal: no upgradeability, no governance, no external oracle. All data needed for the payment decision lives on‑chain (the hash) and is supplied by the agent at reveal time.
3. Agent‑side integration (JavaScript/TypeScript)
Below is a concise example using ethers.js (v6) that an autonomous agent could embed in its service loop. It assumes the agent already knows the escrow address, the USDC token address, and the expected output.
import { ethers } from "ethers";
import usdcAbi from "./usdc-abi.json"; // standard ERC20 ABI
import escrowAbi from "./escrow-abi.json"; // ABI of the contract above
// Configuration – in a real deployment these come from env/vault
const RPC_URL = "https://base-mainnet.infura.io/v3/<key>";
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const ESCROW_ADDRESS = "0xEscrow…"; // deployed address
const AGENT_PRIVATE_KEY = "0x…"; // agent's EOA (or smart wallet)
const PRICE_USDC = ethers.parseUnits("0.05", 6); // $0.05 = 5 × 10⁻² USDC (6 decimals)
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(AGENT_PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, wallet);
const escrow = new ethers.Contract(ESCROW_ADDRESS, escrowAbi, wallet);
/**
* Helper: approve escrow to spend USDC on behalf of the agent (only needed once).
*/
async function approveEscrow() {
const allowance = await usdc.allowance(wallet.address, ESCROW_ADDRESS);
if (allowance < PRICE_USDC) {
const tx = await usdc.approve(ESCROW_ADDRESS, PRICE_USDC);
await tx.wait();
console.log("Approval tx:", tx.hash);
}
}
/**
* Called after the agent finishes a job and knows the result.
*/
async function claimPayment(output: Uint8Array) {
await approveEscrow();
// Compute the hash that matches what the client committed to.
const outputHash = ethers.keccak256(output);
// In a real flow the agent would have received this hash earlier from the client.
// Here we assume it’s stored off‑chain or passed as an argument.
const expectedHash = "0x" + /* hash agreed off‑chain */; // placeholder
if (outputHash !== expectedHash) {
throw new Error("Output does not match committed hash");
}
const tx = await escrow.revealOutput(output);
const receipt = await tx.wait();
console.log("Payment claimed:", receipt.transactionHash);
}
// Example usage:
(async () => {
const result = new TextEncoder().encode('{"task":"image-caption","id":"abc123","caption":"A red bike"}');
await claimPayment(result);
})();
Key points for the agent developer
-
Approval – USDC (like most ERC‑20s) requires an allowance before the escrow can pull funds via
transferFrom. The agent only needs to approve once per escrow address (or use a smart‑wallet that handles approvals automatically
Top comments (0)