USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents
1. Why escrow matters for agent‑to‑agent contracts
Autonomous AI agents often need to pay for external services (APIs, data, compute) while remaining fully on‑chain or at least cryptographically verifiable. A naïve approach—sending a payment first and hoping the service returns the result—exposes the agent to fraud. Conversely, demanding proof of work before payment stalls pipelines that require instant micro‑transactions (e.g., $0.01 per inference).
An escrow contract solves this by holding funds in a neutral smart contract until verifiable conditions are satisfied. The agent can:
- Lock USDC in the escrow.
- Trigger the external service off‑chain (or via a trusted oracle).
- Release funds automatically when a cryptographic proof (e.g., a signed result, Merkle proof, or zk‑SNARK) is presented.
Because the escrow logic lives on‑chain, neither party needs to trust the other’s off‑chain behavior; the blockchain enforces the release rule.
2. Minimal escrow design on Base (EVM‑compatible)
We’ll use USDC (the ERC‑20 token contract 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 on Base) and a simple Solidity escrow that releases funds when a hash‑preimage is supplied. This pattern works for any off‑chain computation whose result can be hashed and submitted on‑chain.
2.1 Solidity contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
}
/**
* @title SimpleHashEscrow
* @notice Holds USDC until a preimage matching a committed hash is provided.
* @dev The caller (client) deposits USDC and commits `keccak256(preimage)`.
* The service (agent) later reveals the preimage; if the hash matches,
* the escrow transfers the deposited amount to the service.
*/
contract SimpleHashEscrow {
IERC20 public immutable usdc;
address public client;
address public service; // set at deposit time; zero address means unset
bytes32 public committedHash;
uint256 public amount;
bool public released;
event Deposited(address indexed client, address indexed service, uint256 amount, bytes32 hash);
event Released(address indexed service, uint256 amount);
event Refunded(address indexed client, uint256 amount);
constructor(address _usdc) {
require(_usdc != address(0), "Zero USDC");
usdc = IERC20(_usdc);
}
/**
* @dev Client deposits USDC and locks a hash of the expected result.
* @param _service Address that will perform the work.
* @param _hash keccak256(expectedResult)
* @param _amount Amount of USDC (6 decimals) to escrow.
*/
function deposit(address _service, bytes32 _hash, uint256 _amount) external {
require(msg.sender != address(0), "Invalid caller");
require(_service != address(0), "Zero service");
require(_amount > 0, "Zero amount");
require(_hash != 0x0, "Zero hash");
// Transfer USDC from client to escrow
require(usdc.transferFrom(msg.sender, address(this), _amount), "Transfer failed");
client = msg.sender;
service = _service;
committedHash = _hash;
amount = _amount;
released = false;
emit Deposited(msg.sender, _service, _amount, _hash);
}
/**
* @dev Service reveals the preimage. If hash matches, escrow pays out.
* @param _preimage The off‑chain result (e.g., JSON string, signed data).
*/
function reveal(bytes calldata _preimage) external {
require(!released, "Already released");
require(keccak256(_preimage) == committedHash, "Hash mismatch");
released = true;
// Transfer USDC to the service
require(usdc.transfer(service, amount), "Transfer failed");
emit Released(service, amount);
}
/**
* @dev Client can refund if the service never reveals within a timeout.
* Timeout is enforced off‑chain; this function is callable after the
* agreed deadline (checked by the caller).
*/
function refund() external {
require(msg.sender == client, "Only client");
require(!released, "Already released");
// Optional: add block.timestamp > deadline check here
released = true; // prevent double‑spend
require(usdc.transfer(client, amount), "Transfer failed");
emit Refunded(client, amount);
}
}
Key points
- The escrow holds USDC via ERC‑20
transferFrom. The client must firstapprovethe escrow contract for the deposit amount. - Security hinges on the hash commitment: the service cannot learn the expected result before depositing, and the client cannot claim a refund without revealing a matching preimage (or waiting for a timeout).
- The contract is deliberately minimal—no upgradeability, no complex governance—to reduce attack surface.
2.2 Deploying and interacting (JavaScript/TypeScript with ethers.js)
# Install dependencies
npm i ethers dotenv
// escrow.ts
import { ethers } from "ethers";
import * as dotenv from "dotenv";
dotenv.config();
const RPC_URL = process.env.BASE_RPC!; // e.g., https://base.mainnet.rpc.dev
const PRIVATE_KEY = process.env.PRIVATE_KEY!; // client's EOA
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const signer = wallet.connect(provider);
// ABI snippets
const ERC20_ABI = [
"function approve(address spender, uint256 amount) external returns (bool)",
"function allowance(address owner, address spender) external view returns (uint256)",
"function balanceOf(address account) external view returns (uint256)",
];
const ESCROW_ABI = [
"function deposit(address service, bytes32 hash, uint256 amount) external",
"function reveal(bytes calldata preimage) external",
"function refund() external",
"event Deposited(address indexed client, address indexed service, uint256 amount, bytes32 hash)",
"event Released(address indexed service, uint256 amount)",
"event Refunded(address indexed client, uint256 amount)",
];
async function main() {
// 1️⃣ Deploy escrow (once)
const escrowFactory = new ethers.ContractFactory(
[
"constructor(address _usdc)",
"function deposit(address service, bytes32 hash, uint256 amount) external",
"function reveal(bytes calldata preimage) external",
"function refund() external",
],
escrowBytecode, // compile with solc or hardhat, output as hex string
signer
);
const escrow = await escrowFactory.deploy(USDC_ADDRESS);
await escrow.waitForDeployment();
console.log("Escrow at:", await escrow.getAddress());
// 2️⃣ Approve USDC for escrow
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, signer);
const depositAmount = ethers.parseUnits("0.05", 6); // $0.05 USDC (6 decimals)
const approveTx = await usdc.approve(await escrow.getAddress(), depositAmount);
await approveTx.wait();
console.log("USDC approved");
// 3️⃣ Deposit + commit hash
const serviceAddr = "0xServiceAgentAddress"; // replace with agent's EOA or contract
const expectedResult = "{\"answer\": 42}"; // example off‑chain output
const hash = ethers.keccak256(ethers.toUtf8Bytes(expectedResult));
const depositTx = await escrow.deposit(serviceAddr, hash, depositAmount);
await depositTx.wait();
console.log("Deposited, hash committed:", hash);
// 4️⃣ Off‑chain: agent computes result, then calls reveal
// (In practice the agent would sign a transaction with its own key)
const revealTx = await escrow.connect(agentSigner).reveal(ethers.toUtf8Bytes(expectedResult));
await revealTx.wait();
console.log("Funds released to agent");
}
main().catch(console.error);
Explanation of the flow
- Deploy the escrow contract (once per product or per batch).
- Approve the escrow to pull USDC from the client’s wallet.
-
Deposit funds while committing
keccak256(expectedResult). -
Agent performs the work off‑chain, then calls
revealwith the preimage. - If the hash matches, escrow transfers USDC to the agent; otherwise the transaction reverts.
- If the agent never reveals, the client can call
refundafter an agreed timeout (checked off‑chain).
2.3 Trade‑offs & honest assessment
| Aspect | Benefit | Limitation / Risk |
|---|---|---|
| Atomicity | Funds move only when a verifiable condition is met; no counterparty risk. | Requires the result to be representable as a hash‑preimage. Complex outputs (large files, ML models) need off‑chain storage ( |
Top comments (0)