USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers who are building autonomous AI agents that need to pay (or get paid) for services without relying on a custodial intermediary.
Why escrow matters for agent‑to‑agent payments
AI agents today can call APIs, run ML inference, or execute smart‑contract transactions autonomously. When two agents agree to exchange a service—say, one agent runs a data‑labeling job and another returns the labeled dataset—they need a payment mechanism that guarantees:
- Atomicity – the payer only loses funds if the service is provably delivered.
- Non‑custodial – no third party holds the money; the agents themselves control the escrow.
- Deterministic settlement – the outcome can be verified on‑chain (or via a trusted off‑chain proof) so that disputes are minimized.
USDC on Base (an Optimistic Rollup anchored to Ethereum) satisfies the first two points: it’s a widely‑used, ERC‑20‑compliant stablecoin with low transaction fees (~$0.001 per transfer). Adding a simple escrow layer gives us the third point without introducing complex dispute‑resolution mechanisms.
System overview
+----------------+ +----------------+ +----------------+
| Agent A | <---> | Escrow Contract| <--> | Agent B |
| (payer) | USDC | (holds USDC) | USDC | (service prov.)|
+----------------+ +----------------+ +----------------+
^ ^ ^
| | |
deposit & approve verify service claim payout
(off‑chain signature) (on‑chain or oracle) (escrow.release)
-
Deposit – Agent A approves the escrow contract to pull USDC from its wallet and calls
deposit(amount, serviceId). - Service execution – Agent B performs the work off‑chain (or on‑chain) and produces a verifiable artifact (e.g., a Merkle root of labeled data, a signed ML model hash, or a transaction receipt).
-
Verification – Either:
- the escrow contract reads the artifact directly (if it’s on‑chain), or
- an off‑chain oracle (or a trusted verifier) calls
verifyProof(serviceId, proof)which updates an internalfulfilled[serviceId]flag.
-
Payout – Agent B calls
withdraw(serviceId). The contract checks thatfulfilled[serviceId] == trueand transfers the escrowed USDC to Agent B. If the service is not fulfilled within a timeout, Agent A can callrefund(serviceId).
The contract is deliberately minimal: no upgradeability, no governance, and no external dependencies beyond the USDC token address.
The escrow contract (Solidity ^0.8.20)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transfer(address recipient, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
}
/**
* @notice Simple USDC escrow for agent‑to‑agent freelancing.
* @dev Assumes USDC follows the standard ERC‑20 interface with 6 decimals.
* No reentrancy guard is needed because the only external call is
* a safe ERC‑20 transfer after all state changes.
*/
contract USdCEscrow {
IERC20 public immutable usdc; // USDC token on Base
address public immutable owner; // Deployer – can pause in emergencies
struct Job {
address payer; // Agent that deposited funds
address provider; // Agent that will receive payout
uint256 amount; // USDC amount (6‑decimals)
bool fulfilled; // Set true when proof is verified
uint256 deadline; // Block.timestamp after which payer can refund
}
mapping(bytes32 => Job) public jobs; // key = serviceId (hash of job description)
event Deposit(bytes32 indexed serviceId, address payer, uint256 amount);
event Fulfilled(bytes32 indexed serviceId, address provider);
event Withdrawn(bytes32 indexed serviceId, address provider, uint256 amount);
event Refunded(bytes32 indexed serviceId, address payer, uint256 amount);
constructor(address _usdc) {
require(_usdc != address(0), "Zero token address");
usdc = IERC20(_usdc);
owner = msg.sender;
}
/**
* @notice Agent A deposits USDC for a particular service.
* @dev The caller must have approved the contract to spend `amount` USDC.
* `serviceId` is typically keccak256(abi.encodePacked(jobDesc, nonce)).
*/
function deposit(bytes32 serviceId, address provider, uint256 amount) external {
require(amount > 0, "Zero amount");
require(jobs[serviceId].amount == 0, "Job already exists");
// Pull USDC from payer
require(
usdc.transferFrom(msg.sender, address(this), amount),
"Transfer failed"
);
jobs[serviceId] = Job({
payer: msg.sender,
provider: provider,
amount: amount,
fulfilled: false,
deadline: block.timestamp + 7 days // configurable timeout
});
emit Deposit(serviceId, msg.sender, amount);
}
/**
* @notice Off‑chain verifier (or oracle) signals that the service is done.
* @dev Only callable by a trusted verifier address; in a fully trustless
* setup the verifier logic could be replaced by on‑chain data
* inspection (e.g., checking a Merkle root stored in calldata).
*/
function fulfill(bytes32 serviceId) external {
Job storage j = jobs[serviceId];
require(j.amount > 0, "No such job");
require(!j.fulfilled, "Already fulfilled");
require(msg.sender == owner, "Only verifier"); // replace with your verifier logic
j.fulfilled = true;
emit Fulfilled(serviceId, j.provider);
}
/**
* @notice Provider withdraws payment after fulfillment.
* @dev Reverts if the job is not yet fulfilled or if the deadline passed
* (the latter allows the payer to refund first).
*/
function withdraw(bytes32 serviceId) external {
Job storage j = jobs[serviceId];
require(j.amount > 0, "No such job");
require(j.fulfilled, "Service not fulfilled");
require(
!j.fulfilled || block.timestamp <= j.deadline,
"Can't withdraw after deadline"
);
j.amount = 0; // zero out to prevent re‑entrancy
usdc.transfer(j.provider, j.amount);
emit Withdrawn(serviceId, j.provider, j.amount);
}
/**
* @notice Payer reclaims funds if the provider never fulfills before deadline.
*/
function refund(bytes32 serviceId) external {
Job storage j = jobs[serviceId];
require(j.amount > 0, "No such job");
require(block.timestamp > j.deadline, "Deadline not passed");
require(!j.fulfilled, "Already fulfilled; provider should withdraw");
j.amount = 0;
usdc.transfer(j.payer, j.amount);
emit Refunded(serviceId, j.payer, j.amount);
}
/* Optional: allow owner to pause contract in case of emergency */
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function pause() external onlyOwner { /* implement via OpenZeppelin Pausable if needed */ }
function unpause() external onlyOwner { /* ... */ }
}
Key points in the code
- The contract holds USDC via
transferFrom; the payer must first approve the contract. -
fulfilledis set by a trusted verifier (ownerin the example). In a production system you’d replace that check with an on‑chain proof verification (e.g., validating a zk‑SNARK or checking a Merkle root against data stored on IPFS/Filecoin). - No external calls happen before state updates, eliminating classic re‑entrancy risks. The only external call (
usdc.transfer) occurs after the job’s amount is zeroed out. - The timeout (
deadline) is hard‑coded to 7 days; adjust based on expected service latency.
Interacting from an agent (TypeScript + ethers.js)
Below is a minimal snippet that an AI agent could run in a Node.js environment to deposit, wait for fulfillment, and withdraw. Error handling is kept explicit
Top comments (0)