USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents that need to get paid for verifiable work without relying on a centralized intermediary.
Why an escrow contract?
When an AI agent performs a task for a client, the two parties face a classic chicken‑and‑egg problem:
| Party | Risk if they act first |
|---|---|
| Client | Pays upfront and receives nothing (or low‑quality output). |
| Agent | Does the work and never gets paid. |
A simple solution is to lock the payment in a smart contract that only releases it when verifiable proof of completion is presented. On Base (an Optimistic Rollup) USDC is an ERC‑20 token with low transaction fees, making it practical to hold funds on‑chain for short‑lived gigs.
The contract described below does not promise magic—it merely shifts the trust assumption from a person to a piece of code that can be inspected and, if necessary, challenged on‑chain.
Core properties of the escrow
| Property | How it’s achieved |
|---|---|
| Funds safety | USDC is transferred to the contract via deposit(). The contract can only send it out via release() or refund(). |
| Verifiable completion | The agent submits a hash of the work product (submitResult(bytes32 resultHash)). The client (or any watcher) can later provide the pre‑image to prove the hash matches the agreed specification. |
| Dispute window | After a result is submitted, the client has a challenge period (CHALLENGE_PERIOD) to call challenge(bytes32 resultHash, bytes proof). If the challenge succeeds, the agent is slashed (or the funds are refunded). |
| Atomic payout | If no successful challenge occurs before the period ends, anyone can call finalize() to transfer the escrowed amount to the agent. |
| Minimal on‑chain logic | Heavy computation (e.g., running a model) stays off‑chain; only hashes and simple checks live on‑chain. |
These properties give a trustless freelancing flow: the client deposits funds, the agent works off‑chain, the agent posts a commitment, the client can verify, and if all is well the agent gets paid—all without a middleman.
The escrow contract (Solidity ^0.8.20)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @notice Simple USDC escrow for AI‑agent freelancing.
* @dev Assumes USDC follows the ERC‑20 standard with 6 decimals on Base.
*/
contract USDC escaprow {
IERC20 public immutable usdc;
address public immutable client;
address public agent; // set after deposit, zero until agent registers
uint256 public amount; // escrowed USDC (in wei‑equivalent units)
uint256 public depositTime; // block.timestamp of deposit
uint256 public challengeEnd; // block.timestamp when challenge period ends
bytes32 public resultHash; // commitment from the agent
bool public finalized; // prevents double payout
event Deposited(address indexed from, uint256 amount);
event AgentRegistered(address indexed agent);
event ResultSubmitted(bytes32 resultHash);
event Challenged(address indexed challenger, bytes32 resultHash);
event Refunded(address indexed to, uint256 amount);
event PaidOut(address indexed to, uint256 amount);
// 1 day challenge period – adjust as needed for your use‑case
uint256 public constant CHALLENGE_PERIOD = 1 days;
constructor(address _usdc, address _client) {
require(_usdc != address(0), "USDC zero");
require(_client != address(0), "Client zero");
usdc = IERC20(_usdc);
client = _client;
}
/* ------------------------------------------------------------------ */
/* Client side */
/* ------------------------------------------------------------------ */
/**
* @notice Deposit USDC into escrow. Must be called by the client.
* @param _amount Amount of USDC (6‑decimals) to lock.
*/
function deposit(uint256 _amount) external {
require(msg.sender == client, "Only client");
require(_amount > 0, "Zero deposit");
require(usdc.transferFrom(msg.sender, address(this), _amount), "ERC20 transfer failed");
amount = _amount;
depositTime = block.timestamp;
challengeEnd = block.timestamp + CHALLENGE_PERIOD;
emit Deposited(msg.sender, _amount);
}
/**
* @notice Refund the client if the agent never registers or the deal is cancelled.
* @dev Can be called after depositTime + GRACE_PERIOD if no agent registered.
*/
function refund() external {
require(msg.sender == client, "Only client");
require(amount > 0, "Nothing to refund");
require(block.timestamp >= depositTime + 2 days, "Grace period not elapsed"); // simple grace
require(usdc.transfer(client, amount), "ERC20 transfer failed");
uint256 refundAmt = amount;
amount = 0;
emit Refunded(client, refundAmt);
}
/* ------------------------------------------------------------------ */
/* Agent side */
/* ------------------------------------------------------------------ */
/**
* @notice Register as the agent performing the work.
* @dev Only callable when no agent is set yet.
*/
function registerAgent() external {
require(agent == address(0), "Agent already registered");
require(msg.sender != client, "Client cannot be agent");
agent = msg.sender;
emit AgentRegistered(msg.sender);
}
/**
* @notice Submit a commitment to the work result.
* @param _resultHash keccak256(abi.encodePacked(actualResult, nonce))
* The actualResult is the off‑chain work product; nonce prevents rainbow‑table attacks.
*/
function submitResult(bytes32 _resultHash) external {
require(msg.sender == agent, "Only agent");
require(resultHash == 0, "Result already submitted");
require(_resultHash != 0, "Zero hash");
resultHash = _resultHash;
// Reset challenge window to start from submission time
challengeEnd = block.timestamp + CHALLENGE_PERIOD;
emit ResultSubmitted(_resultHash);
}
/**
* @notice Challenge the submitted result.
* @param _resultHash The hash that was submitted.
* @param _proof ABI‑encoded proof that the hash does NOT correspond to the agreed spec.
* The exact format is up to the parties; a simple approach is to pre‑image the hash
* and show that it fails validation.
*/
function challenge(bytes32 _resultHash, bytes calldata _proof) external {
require(msg.sender != agent, "Agent cannot challenge own result");
require(_resultHash == resultHash, "Wrong hash");
require(block.timestamp <= challengeEnd, "Challenge period expired");
/* ------------------------------------------------------------------
In a real implementation you would verify _proof against the
off‑chain work product. For brevity we treat any non‑empty proof
as a successful challenge (the agent loses the escrow).
------------------------------------------------------------------ */
require(_proof.length > 0, "Empty proof");
// Slash the agent: refund client and zero out agent
require(usdc.transfer(client, amount), "ERC20 transfer failed");
uint256 slashed = amount;
amount = 0;
agent = address(0);
resultHash = 0;
finalized = true;
emit Challenged(msg.sender, _resultHash);
emit Refunded(client, slashed);
}
/**
* @notice Anyone can call this after the challenge period ends with no successful challenge.
* Transfers the escrowed USDC to the agent.
*/
function finalize() external {
require(agent != address(0), "No agent");
require(block.timestamp >= challengeEnd, "Challenge period not over");
require(!finalized, "Already finalized");
require(amount > 0, "Nothing to pay");
if (!usdc.transfer(agent, amount)) revert("ERC20 transfer failed");
uint256 payout = amount;
amount = 0;
finalized = true;
agent = address(0); // prevent reuse
resultHash = 0;
emit PaidOut(agent, payout);
}
/* ------------------------------------------------------------------ */
/* Fallback / receive */
/* ------------------------------------------------------------------ */
receive() external payable {
revert("Escrow does not accept raw ETH");
}
}
How the contract is used
-
Client deploys the contract with the USDC address on Base and their own wallet as
_client. -
Client calls
deposit(amount). The US
Top comments (0)