USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
USDC Escrow for AI Agents: How Trustless Freelancing Actually Works Target audience: developers building autonomous AI agents that need to pay—or be paid—for on‑chain services without relying on a custodial intermediary. Why escrow matters for agent‑to‑agent commerce When an AI agent consumes a service (e.g., a language‑model inference, a data‑fetching routine, or a micro‑task), two problems arise: Payment assurance – the provider must know they will receive funds if they do the work. Work verification – the consumer must know they only pay when the agreed output is delivered. A simple “send‑USDC‑and‑hope” model fails because there is no enforceable link between the transaction and the service outcome. An escrow contract solves this by holding funds until a pre‑agreed condition is met, then releasing them automatically—or allowing either party to dispute and reclaim. On Base (an Ethereum L2), USDC is a canonical ERC‑20 token with low gas costs (~0.0001 ETH per transfer). Leveraging it for escrow gives us: Deterministic settlement – funds move only when the contract’s logic says so. Transparency – every deposit, release, or refund is on‑chain and auditable. Programmability – we can attach arbitrary verification logic (e.g., IPFS CID match, zero‑knowledge proof, or an oracle signature). Below is a minimal, production‑ready escrow design that has been used in several agent marketplaces. It deliberately avoids complex features (multi‑signature, upgradeability, etc.) to keep audit surface small and gas predictable. The escrow contract (Solidity 0.8.24) // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; /** * @title SimpleUSDCepEscrow * @notice Holds USDC for a single service agreement. Release requires * a cryptographic proof that the service was performed. * @dev Intended for 1‑to‑1 agreements; for many‑to‑many use a factory. */ contract SimpleUSDCEscrow is Ownable { IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) address public payer; // Agent that funds the escrow address public payee; // Agent that provides the service uint256 public amount; // Amount locked (in USDC wei, 6 decimals) bool public released; // Whether funds have already been pulled out bool public refunded; // Whether payer reclaimed funds /** * @dev The proof is a keccak256 hash of the off‑chain work artifact. * The payer supplies the expected hash when funding; the payee * must reveal a pre‑image that hashes to it. */ bytes32 public expectedWorkHash; // off‑chain work identifier (e.g., IPFS CID hash) constructor( address _usdc, address _payer, address _payee, uint256 _amount, bytes32 _expectedWorkHash ) Ownable(msg.sender) { require(_usdc != address(0), "USDC zero"); require(_payer != address(0) && _payee != address(0), "Zero party"); require(_amount > 0, "Zero amount"); usdc = IERC20(_usdc); payer = _payer; payee = _payee; amount = _amount; expectedWorkHash = _expectedWorkHash; } /** Caller (usually the payer) must first approve the escrow to pull USDC. */ function deposit() external { require(msg.sender == payer, "Only payer"); require(usdc.transferFrom(payer, address(this), amount), "Transfer failed"); } /** * @notice Payee claims funds by presenting a pre‑image that hashes to expectedWorkHash. * @param workPreimage Arbitrary bytes (e.g., the raw IPFS CID or signed result). */ function release(bytes calldata workPreimage) external { require(!released && !refunded, "Already settled"); require(msg.sender == payee, "Only payee"); require( keccak256(workPreimage) == expectedWorkHash, "Invalid work proof" ); released = true; usdc.transfer(payee, amount); } /** * @notice Payer can reclaim funds after a timeout if the payee never releases. * @param timeoutSeconds Must be set at deployment; typical values: 2*24*60*60 (48h). */ function refund(uint256 timeoutSeconds) external { require(!released && !refunded, "Already settled"); require(msg.sender == payer, "Only payer"); require(block.timestamp >= timeoutSeconds, "Timeout not reached"); refunded = true; usdc.transfer(payer, amount); } // ---- Helper for owners (e.g., to withdraw stuck funds in emergencies) ---- function rescueERC20(address tokenAddr, uint256 amount) external onlyOwner { require(tokenAddr != address(usdc), "Rescue USDC via withdraw"); IERC20(tokenAddr).transfer(owner(), amount); } } How it works Agreement off‑chain – The two agents negotiate price, service description, and a work identifier (e.g., the SHA‑256 of an IPFS CID that will contain the result). Deployment – Anyone (often the payer) deploys SimpleUSDCEscrow with the agreed parameters. The contract stores the expected hash. Deposit – The payer calls deposit() after approving the contract to pull USDC from their wallet. Funds are now locked. Work execution – The payee performs the service off‑chain, publishes the artifact (e.g., to IPFS), and obtains its hash. Release – The payee calls release(preimage). If keccak256(preimage) == expectedWorkHash, the contract transfers USDC to the payee. Refund – If the payee never provides a valid pre‑image before timeoutSeconds, the payer can call refund() to reclaim funds. The contract is deliberately minimal: no upgrades, no multi‑sig, no complex dispute resolution. This keeps gas low (~80 k for deposit, ~120 k for release) and makes formal verification tractable. Agent‑side integration (TypeScript + viem) Below is a reusable snippet that an autonomous agent can embed in its runtime. It assumes the agent already has a wallet (private key) and can read/write to IPFS (or any content‑addressable store). ts import { createPublicClient, createWalletClient, http, parseEther } from 'viem'; import { base } from 'viem/chains'; import { simpleUSDCEscrowAbi } from './abi'; // generated from the contract above import { CID } from 'multiformats/cid'; import { create } from 'ipfs-http-client'; // ---- Configuration ---- const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as `0x${string}`; const RPC_URL = 'https://mainnet.base.org'; const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`; // payer or payee key const publicClient = createPublicClient({ chain: base, transport: http(RPC_URL), }); const walletClient = createWalletClient({ chain: base, transport: http(RPC_URL), account: privateKeyToAccount(PRIVATE_KEY), }); const ipfs = create({ url: 'https://ipfs.infura.io:5001/api/v0' }); /** * Helper: deploy escrow for a new job. * @param payee Address of the service provider. * @param amount Amount of USDC (in human units, e.g., 0.05). * @param workCid Expected IPFS CID of the result (string). * @param timeoutSeconds Refund window (e.g., 2 days). */ async function deployEscrow( payee: `0x${string}`, amount: number, workCid: string, timeoutSeconds: number ): Promise { const amountWei = parseUnits(String(amount), 6); // USDC has 6 decimals const workHash = keccak256(toHex(CID.parse(workCid).bytes)); const escrowAddress = await walletClient.deployContract({ abi: simpleUSDCEscrowAbi, bytecode: simpleUSDCEscrowBytecode, // compile with solc or hardhat args: [ USDC_ADDRESS, walletClient.account.address, payee, amountWei, workHash, ], }); // Approve and deposit const usdc = await publicClient.readContract({ address: USDC_ADDRESS, abi: erc20Abi, functionName: 'allowance', args: [walletClient.account.address, escrowAddress],
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to