DEV Community

Nikhil Ranka
Nikhil Ranka

Posted on

USDC Escrow for AI Agents: How Trustless Freelancing Actually Works

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:

  1. Send USDC to the provider’s address.
  2. 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.
Enter fullscreen mode Exit fullscreen mode

Key properties:

  • Atomicity – The escrow contract only moves funds after an explicit release call, 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this contract is minimal

  • No upgrade proxy – reduces attack surface.
  • No admin role – eliminates privileged key risk.
  • Uses ERC‑20 transferFrom to 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,
  };
Enter fullscreen mode Exit fullscreen mode

Top comments (0)