USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents that need to receive payment for on‑chain or off‑chain services without relying on a trusted intermediary.
1. Why an escrow makes sense for AI agents
AI agents often act as “freelancers”: they expose an API (or a contract call) that performs a deterministic or stochastic task—e.g., generating a summary, classifying an image, or executing a trade—and they expect to be paid once the output satisfies the requester’s criteria.
In a fully on‑chain world the naïve approach is:
- Payer sends USDC directly to the agent’s address.
- Agent returns the result.
Problems appear quickly:
| Issue | Why it matters | Mitigation |
|---|---|---|
| Non‑atomicity | The agent could take the funds and disappear, or the payer could refuse to pay after receiving the result. | Hold funds in a contract that only releases them when a pre‑agreed condition is met. |
| Deterministic verification | Many AI outputs are probabilistic; you cannot simply compare a hash. | Use an off‑chain verifier (oracle, zk‑proof, or human judge) that signs a “task‑complete” message. |
| Gas cost & latency | Every interaction costs Base gas and adds block‑time latency. | Batch deposits/withdrawals, keep the escrow minimal, and settle disputes off‑chain when possible. |
| Key management | Agents need a private key to sign transactions; leaking it lets anyone steal escrowed funds. | Use a dedicated hot‑wallet with limited allowance, or a smart‑contract wallet (e.g., ERC‑4337) with spending limits. |
An escrow contract solves the first two rows: it locks USDC until a verifiable proof of completion is presented, and it provides a clear dispute path.
2. Minimal USDC escrow design (Solidity)
Below is a working, auditable escrow contract that works with USDC (or any ERC‑20) on Base. It deliberately avoids complex features (e.g., multi‑signature, upgradeability) to keep the attack surface small and the gas cost predictable.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
/**
* @title SimpleEscrow
* @dev Holds USDC for a single payer‑agent pair until a task is marked complete.
* The agent can withdraw only after the payer (or an authorized oracle)
* signs off. Disputes are resolved by a timelocked refund to the payer.
*/
contract SimpleEscrow is ReentrancyGuard {
IERC20 public usdc;
address public payer;
address public agent;
uint256 public amount; // locked USDC (6 decimals)
uint256 public deadline; // block.timestamp after which payer can refund
enum State { Created, Funded, Completed, Disputed, Refunded }
State public state;
// ------------------------------------------------------------------------
// Events
// ------------------------------------------------------------------------
event Funded(address indexed agent, uint256 amount);
event Completed(address indexed agent);
event Disputed(address indexed payer);
event Refunded(address indexed payer, uint256 amount);
// ------------------------------------------------------------------------
// Constructor
// ------------------------------------------------------------------------
constructor(
address _usdc,
address _payer,
address _agent,
uint256 _amount,
uint256 _secondsToDeadline
) {
require(_usdc != address(0), "USDC zero");
require(_payer != address(0) && _agent != address(0), "Zero address");
require(_amount > 0, "Zero amount");
usdc = IERC20(_usdc);
payer = _payer;
agent = _agent;
amount = _amount;
deadline = block.timestamp + _secondsToDeadline;
state = State.Created;
}
// ------------------------------------------------------------------------
// External functions
// ------------------------------------------------------------------------
/**
* @dev Payer (or anyone) transfers USDC into the escrow.
* The contract pulls the exact amount via ERC20 transferFrom.
*/
function fund() external nonReentrant {
require(state == State.Created, "Not funded yet");
require(
usdc.transferFrom(payer, address(this), amount),
"USDC transfer failed"
);
state = State.Funded;
emit Funded(agent, amount);
}
/**
* @dev Agent calls this after completing the task.
* In practice you would pass a signature or a zk‑proof that the
* off‑chain verifier validated. For simplicity we rely on a
* trusted oracle address that can call `complete()`.
*/
function complete() external nonReentrant {
require(state == State.Funded, "Not funded");
require(msg.sender == agent, "Only agent");
state = State.Completed;
emit Completed(agent);
// Release funds immediately
_releaseFunds(agent);
}
/**
* @dev Payer (or a designated dispute resolver) can mark the escrow as
* disputed before the deadline. After the deadline passes, they can
* call `refund()` to retrieve the locked USDC.
*/
function dispute() external nonReentrant {
require(state == State.Funded, "Not funded");
require(msg.sender == payer, "Only payer");
require(block.timestamp < deadline, "Already past deadline");
state = State.Disputed;
emit Disputed(payer);
}
/**
* @dev After the deadline, the payer can refund themselves.
*/
function refund() external nonReentrant {
require(state == State.Disputed, "Not disputed");
require(block.timestamp >= deadline, "Deadline not reached");
require(msg.sender == payer, "Only payer");
state = State.Refunded;
emit Refunded(payer, amount);
_releaseFunds(payer);
}
// ------------------------------------------------------------------------
// Internal helpers
// ------------------------------------------------------------------------
function _releaseFunds(address recipient) internal {
uint256 toSend = amount; // capture before zeroing
amount = 0; // prevent re‑entrancy
usdc.transfer(recipient, toSend);
}
// ------------------------------------------------------------------------
// Fallback / receive – reject plain ETH
// ------------------------------------------------------------------------
receive() external payable {
revert("No ETH accepted");
}
}
How it works
| Step | Actor | On‑chain action |
|---|---|---|
| 1️⃣ | Payer | Calls fund() → escrow pulls USDC from payer’s allowance. |
| 2️⃣ | Agent | Performs the task off‑chain (or on‑chain if cheap). |
| 3️⃣ | Verifier (could be a trusted oracle, a zk‑proof verifier, or a human) | Signs a message or calls an external contract that eventually invokes complete() on behalf of the agent. In the minimal example the agent itself calls complete() after it trusts the off‑chain result. |
| 4️⃣ | Escrow | Transfers the locked USDC to the agent’s address. |
| 5️⃣ | Payer (if dissatisfied) | Calls dispute() before the deadline, then refund() after the deadline to reclaim funds. |
The contract deliberately does not try to verify AI output on‑chain. Verification is left to an off‑chain party that the payer and agent agree on beforehand (e.g., a reputation‑based oracle service, a committee, or a zk‑SNARK that proves the model produced the claimed output). This keeps the contract cheap and avoids the impossibility of proving arbitrary ML results on‑chain today.
3. Using the escrow from an AI agent (JavaScript/ethers.js)
Below is a concise snippet that an autonomous agent could run after finishing a job. It assumes:
- The agent holds a private key for an Ethereum-compatible wallet (on Base).
- The agent has already approved the escrow contract to spend USDC (via
usdc.approve(escrowAddress, amount)). - The off‑chain verifier has already signaled completion (e.g., via a webhook, a signed message, or a decentralized oracle).
javascript
// escrow-agent.js
require('dotenv').config();
const { ethers } = require('ethers');
// ---------------------------------------------------
// Configuration – replace with your own values
// ---------------------------------------------------
const RPC_URL = process.env.BASE_RPC; // e.g., https://base-mainnet.g.alchemy.com/v2/...
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY; // agent's EOA
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const ESCROW_ADDR = "0xYourEscrowContractAddress"; // deployed SimpleEscrow
const AMOUNT_USDC = ethers.parseUnits("0.05
Top comments (0)