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 relying on a central intermediary.
Why escrow matters for agent‑to‑agent payments
When an AI agent offers a compute‑heavy service (e.g., LLM inference, data labeling, or signal processing) it usually wants payment before expending resources. Conversely, a buyer wants assurance that the service will be delivered as agreed. In a world where agents operate 24/7 and may be owned by different parties, a simple “pay‑then‑hope” model creates counterparty risk that can quickly erode trust.
An escrow contract solves this by holding the buyer’s funds in a neutral smart contract until predefined conditions are met. The contract is trustless because its code, not a third party, enforces the release logic. When the escrow token is a stablecoin like USDC, the value remains stable across chains, making accounting straightforward for both humans and agents.
Contract design overview
We’ll implement a minimal escrow contract on Base (an Ethereum L2) that:
- Accepts USDC (ERC‑20) deposits from a buyer.
- Allows the seller (agent) to submit a proof that the service was completed.
- Releases the funds to the seller after a configurable challenge period, unless the buyer disputes the proof.
- Returns the funds to the buyer if the challenge period expires without a valid proof.
The contract deliberately avoids complex oracle logic; instead, the proof is a hash commitment that the buyer can verify off‑chain. This keeps gas costs low and moves the heavy verification to the agent’s own environment.
Solidity (Solidity ^0.8.20)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract USDC"Escrow is Ownable {
IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
address public buyer;
address public seller;
uint256 public amount; // escrowed USDC (6 decimals)
bytes32 public commitment; // keccak256(off‑chain result || nonce)
uint256 public depositTime;
uint256 public challengePeriod; // seconds, set at deployment
enum State { Created, Funded, Challenged, Released, Refunded }
State public state;
event Deposited(address indexed buyer, uint256 amount);
event CommitmentSubmitted(address indexed seller, bytes32 commitment);
event Challenged(address indexed challenger);
event Released(address indexed seller, uint256 amount);
event Refunded(address indexed buyer, uint256 amount);
constructor(address _usdc, uint256 _challengePeriod) Ownable(msg.sender) {
require(_usdc != address(0), "USDC address zero");
usdc = IERC20(_usdc);
challengePeriod = _challengePeriod;
}
/// @notice Buyer funds the escrow. Must be called after deployment.
function deposit() external payable {
require(state == State.Created, "not created");
require(msg.value == 0, "send USDC via ERC20 transfer, not ETH");
uint256 escrowAmount = usdc.balanceOf(address(this));
require(escrowAmount == 0, "already funded");
// buyer must have approved the contract to pull USDC
usdc.transferFrom(msg.sender, address(this), amount);
buyer = msg.sender;
state = State.Funded;
depositTime = block.timestamp;
emit Deposited(msg.sender, amount);
}
/// @notice Seller sets amount and commits to a future result.
/// The buyer must have approved the escrow to pull USDC before calling deposit.
function setTermsAndDeposit(uint256 _amount) external {
require(state == State.Created, "invalid state");
require(msg.sender == buyer, "only buyer");
amount = _amount;
// buyer approves the contract to pull their USDC
usdc.approve(address(this), amount);
deposit();
}
/// @notice Seller submits a hash commitment of the off‑chain result.
/// The commitment hides the actual output but binds the seller to it.
function submitCommitment(bytes32 _commitment) external {
require(state == State.Funded, "not funded");
require(msg.sender == seller, "only seller");
commitment = _commitment;
state = State.Challenged;
emit CommitmentSubmitted(msg.sender, commitment);
}
/// @notice Buyer can challenge if they think the commitment is invalid.
/// They must provide the pre‑image (result + nonce) that hashes to commitment.
function challenge(bytes calldata preimage) external {
require(state == State.Challenged, "no commitment to challenge");
require(msg.sender == buyer, "only buyer can challenge");
require(keccak256(preimage) == commitment, "invalid preimage");
// If the preimage reveals a result that does NOT meet the agreed spec,
// the buyer can call refund() after the challenge period.
// For simplicity, we treat any valid preimage as a buyer win.
state = State.Refunded;
emit Challenged(msg.sender);
}
/// @notice Seller releases funds after challenge period expires without a successful challenge.
function release() external {
require(state == State.Challenged, "not in challenge period");
require(block.timestamp >= depositTime + challengePeriod, "challenge period not over");
require(msg.sender == seller, "only seller");
state = State.Released;
usdc.transfer(seller, amount);
emit Released(seller, amount);
}
/// @notice Buyer refunds funds if they successfully challenged or if the seller never committed.
function refund() external {
require(state == State.Challenged || state == State.Funded, "invalid state");
require(block.timestamp >= depositTime + challengePeriod, "challenge period not over");
require(msg.sender == buyer, "only buyer");
state = State.Refunded;
usdc.transfer(buyer, amount);
emit Refunded(buyer, amount);
}
/// @notice Allow the owner to reclaim stuck USDC (emergency only).
function rescueUSDC() external onlyOwner {
uint256 balance = usdc.balanceOf(address(this));
require(balance > 0, "nothing to rescue");
usdc.transfer(owner(), balance);
}
}
Key points
- The escrow holds USDC via ERC‑20
transferFrom. The buyer must approve the contract before callingdeposit()(or we combine approval and deposit insetTermsAndDeposit). - The seller’s proof is a hash commitment (
keccak256(off‑chain result || nonce)). This hides the actual result on‑chain, saving gas, while still binding the seller. - A challenge period (e.g., 1 hour) gives the buyer time to submit a pre‑image that either proves the seller’s result is invalid or simply reveals the pre‑image to claim a refund if the seller never committed.
- If no challenge succeeds, the seller calls
release()and receives the funds.
Client (buyer) workflow – TypeScript / viem
ts
import { createPublicClient, http, parseAbi } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { base } from 'viem/chains';
import { encodeFunctionData } from 'viem';
import USDC_ABI from './usdc-abi.json'; // standard ERC20 ABI
import ESCROW_ABI from './escrow-abi.json'; // the contract above
const rpc = 'https://mainnet.base.org';
const publicClient = createPublicClient({ chain: base, transport: http(rpc) });
// Addresses (Base mainnet)
const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const ESCROW = '0xYourEscrowAddressHere'; // deployed with 1‑hour challenge period
const buyerKey = '0xbuyerPrivateKey';
const sellerKey = '0xsellerPrivateKey';
const buyerAcct = privateKeyToAccount(buyerKey);
const sellerAcct = privateKeyToAccount(sellerKey);
// Helper to send a transaction
async function send(tx) {
const hash = await buyerAcct.signTransaction({ ...tx, chainId: base.id });
const receipt = await publicClient.waitForTransactionReceipt({ hash });
return receipt;
}
// 1. Buyer approves escrow to pull USDC
async function approveUSDC(amount) {
const usdcContract = {
address: USDC as `0x${string}`,
abi: USDC_ABI,
};
const data = encodeFunctionData({
abi: usdcContract.abi,
functionName: 'approve',
args: [ESCROW, amount],
});
await send({
to: USDC,
data,
value: 0n,
});
}
//
Top comments (0)