USDC Escrow for AI Agents: How Trustless Freelancing Actually Works
Target audience: developers building autonomous AI agents who need a reliable way to pay for on‑chain services without a trusted intermediary.
1. Why escrow matters for agents
Autonomous agents frequently need to purchase compute, data, or API calls from other services. In a naïve model the agent would:
- Send USDC to the provider’s address.
- Hope the provider returns the promised result.
If the provider is malicious or simply offline, the agent loses funds with no recourse. Conversely, if the agent refuses to pay after receiving the result, the provider bears the risk. An escrow contract removes this trust asymmetry by holding funds until both parties can prove that the agreed‑upon work has been completed.
The solution described below uses the x402 payment protocol (a lightweight HTTP‑based scheme for charging per‑request) together with a minimal escrow contract on Base (an Optimism‑derived L2). USDC is the settlement token because it is widely supported, has a stable value, and is natively available on Base.
2. High‑level flow
Agent Escrow Contract Provider
------ --------------- --------
1. Agent deposits USDC into escrow <-- deposit(tx) -------------------
2. Agent calls provider endpoint <-- HTTP GET with x402 header -----
(includes escrow address & amount)
3. Provider verifies escrow balance (read-only)
and that the caller is allowed to spend the amount.
4. Provider performs work, returns result.
5. Agent validates result (off‑chain or on‑chain proof).
6. If satisfied, agent calls escrow.release(provider)
<-- transfer USDC to provider.
7. If dissatisfied, agent calls escrow.refund(agent)
<-- USDC returned to agent.
Key properties:
-
Atomicity – The escrow contract only moves funds after an explicit
releasecall, which the agent signs after verifying the work. - Non‑custodial – No third party holds the keys; the contract itself is the custodian.
- Permissionless – Anyone can deploy the escrow contract and interact with it using standard ERC‑20 APIs.
3. The escrow contract (Solidity 0.8.24)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function transfer(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);
}
/**
* @notice Simple escrow for USDC payments between an agent and a provider.
* @dev The contract assumes the ERC20 token implements the standard interface.
* No upgradeability, no admin privileges – funds can only be moved via
* the two explicit functions below.
*/
contract USDCนะEscrow {
IERC20 public immutable usdc;
address public agent;
address public provider;
uint256 public amount; // amount locked in wei (6 decimals for USDC)
bool public released; // prevents double release
bool public refunded; // prevents double refund
constructor(address _usdc, address _agent, address _provider, uint256 _amount) {
require(_usdc != address(0), "USDC zero");
require(_agent != address(0), "Agent zero");
require(_provider != address(0), "Provider zero");
require(_amount > 0, "Amount zero");
usdc = IERC20(_usdc);
agent = _agent;
provider = _provider;
amount = _amount;
// Pull funds from the agent into the contract.
require(usdc.transferFrom(agent, address(this), amount), "Transfer failed");
}
/**
* @notice Called by the agent after verifying the provider's work.
* @dev Transfers the locked USDC to the provider. Can be called only once.
*/
function release() external {
require(msg.sender == agent, "Only agent");
require(!released, "Already released");
require(!refunded, "Already refunded");
released = true;
usdc.transfer(provider, amount);
}
/**
* @notice Called by the agent if the provider did not deliver.
* @dev Returns the locked USDC to the agent. Can be called only once.
*/
function refund() external {
require(msg.sender == agent, "Only agent");
require(!released, "Already released");
require(!refunded, "Already refunded");
refunded = true;
usdc.transfer(agent, amount);
}
/**
* @notice Helper for external parties to check the current state.
* @return true if funds are still locked.
*/
function fundsLocked() external view returns (bool) {
return !released && !refunded;
}
}
Why this contract is minimal
- No upgrade proxy – reduces attack surface.
- No admin role – eliminates privileged key risk.
- Uses ERC‑20
transferFromto pull funds at deployment time, guaranteeing the agent has approved the contract beforehand. - State flags (
released,refunded) make re‑entrancy impossible because the external calls happen after the flag is set.
Trade‑offs
| Aspect | Benefit | Limitation |
|---|---|---|
| Gas cost | Only two state changes (deposit at construction, release/refund). On Base, a full cycle ≈ 150k gas (~$0.001). | Deposit transaction must be sent by the agent before any work can start, adding a latency step. |
| Token flexibility | Works with any ERC‑20 that follows the standard (USDC, DAI, etc.). | If the token has non‑standard behavior (e.g., fees on transfer), the contract may break. |
| Dispute resolution | Relies on the agent’s off‑chain verification; no on‑chain arbitration. | If the agent and provider disagree on whether work was satisfactory, the contract cannot enforce a decision – the agent must choose to release or refund. |
| Atomicity with x402 | The escrow address is included in the request header, letting the provider verify that sufficient funds are locked before serving the request. | The provider must implement the verification logic; otherwise a malicious caller could spoof the header. |
4. Agent side – interacting with escrow and x402 (TypeScript/viem)
ts
import { createPublicClient, http, parseEther } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { base } from 'viem/chains';
import { abi as erc20Abi } from './IERC20.json'; // standard ERC20 abi
import escrowAbi from './USDCนะEscrow.json'; // abi of the contract above
// ---------- Configuration ----------
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC on Base
const AGENT_PRIVATE_KEY = '0x...'; // agent's EOA key
const PROVIDER_ADDRESS = '0xProvider...';
const ESCROW_AMOUNT = parseEther('0.05'); // 0.05 USDC (6 decimals)
const RPC_URL = 'https://mainnet.base.org';
// ---------- Setup ----------
const publicClient = createPublicClient({
chain: base,
transport: http(RPC_URL),
});
const agentAccount = privateKeyToAccount(AGENT_PRIVATE_KEY);
// Helper: approve escrow to pull USDC from the agent
async function approveUSDC(spender: address, amount: bigint) {
const usdcContract = {
address: USDC_ADDRESS,
abi: erc20Abi,
};
const { request } = await publicClient.simulateContract({
address: USDC_ADDRESS,
abi: erc20Abi,
functionName: 'approve',
args: [spender, amount],
account: agentAccount,
});
const hash = await publicClient.writeContract(request);
await publicClient.waitForTransactionReceipt({ hash });
}
// 1. Deploy escrow (agent does this once per job)
async function deployEscrow(): Promise<`0x${string}`> {
await approveUSDC(
// spender will be the escrow contract address – we don't know it yet,
// but ERC‑20 allows approving a maximal amount; we set it to the exact amount.
// The contract will pull funds in its constructor.
'0x0000000000000000000000000000000000000000', // placeholder, will be overwritten
ESCROW_AMOUNT
);
const { request } = await publicClient.simulateContract({
account: agentAccount,
abi: escrowAbi,
bytecode: /* compile the Solidity contract and paste the runtime bytecode here */ '',
functionName: 'constructor',
args: [USDC_ADDRESS, agentAccount.address, PROVIDER_ADDRESS, ESCROW_AMOUNT],
});
const hash = await publicClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
// The contract address is created in the receipt
return receipt.contractAddress as `0x${string}`;
}
// 2. Call a provider endpoint with x402 header
async function callProvider(escrowAddr: `0x${string}`) {
const headers = {
'X-402-Payment-Required': 'true',
'X-402-Token': USDC_ADDRESS,
'X-402-Amount': (ESCROW_AMOUNT / 10n ** 6n).toString(), // USDC has 6 decimals
'X-402-Escrow': escrowAddr,
'X-402-Agent': agentAccount.address,
};
Top comments (0)