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 who are building autonomous AI agents that need to receive payment for verifiable work without relying on a centralized intermediary.


1. Why escrow matters for agent‑to‑human contracts

AI agents can perform tasks (data labeling, micro‑model inference, prompt completion) and produce a deterministic output that can be verified on‑chain or via an off‑chain attestation. If the agent simply sends an invoice and waits for a human to pay, two problems arise:

  1. Counterparty risk – the human may refuse to pay after seeing the result.
  2. Atomicity gap – there is no built‑in guarantee that payment and delivery happen together.

An escrow contract solves both by locking funds upfront and releasing them only when a pre‑agreed condition is satisfied. Using a stablecoin like USDC removes price volatility, while deploying on a low‑cost L2 (Base) keeps gas fees in the sub‑cent range for most interactions.


2. The building blocks

Component Role Typical choice
Token Stablecoin used for payment USDC (ERC‑20) on Base (contract 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
Escrow logic Holds funds, releases on proof Minimal Solidity contract (see §3)
Verification Off‑chain check that the agent did the work Agent‑signed payload + optional oracle or ZK‑proof
SDK / RPC Interact with Ethereum‑compatible chains viem (or ethers.js) + Base public RPC (https://base.meowrpc.com)

The escrow contract is intentionally simple: it does not try to enforce the quality of the work; that verification stays off‑chain. This keeps gas costs low and lets developers plug in any verification mechanism they prefer (signature, Merkle proof, etc.).


3. Minimal escrow contract (Solidity 0.8.20)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract USDCôngEscrow {
    IERC20 public immutable usdc;
    address public payer;
    address public payee;
    uint256 public amount;
    bool public released;

    // `payloadHash` is the off‑chain verification input (e.g., keccak256 of agent result)
    bytes32 public payloadHash;

    constructor(address _usdc, address _payer, address _payee, uint256 _amount, bytes32 _payloadHash) {
        require(_usdc != address(0), "bad token");
        require(_payer != address(0) && _payee != address(0), "bad parties");
        require(_amount > 0, "zero amount");
        usdc = IERC20(_usdc);
        payer = _payer;
        payee = _payee;
        amount = _amount;
        payloadHash = _payloadHash;
    }

    /// @notice Fund the escrow – caller must be the payer and must approve USDC transfer
    function fund() external {
        require(msg.sender == payer, "only payer");
        require(usdc.allowance(payer, address(this)) >= amount, "insufficient allowance");
        usdc.transferFrom(payer, address(this), amount);
    }

    /// @notice Release funds to the payee if the supplied hash matches the stored one
    function release(bytes32 providedHash) external {
        require(!released, "already released");
        require(providedHash == payloadHash, "invalid proof");
        require(usdc.balanceOf(address(this)) >= amount, "escrow underfunded");
        released = true;
        usdc.transfer(payee, amount);
    }

    /// @notice Refund the payer if the escrow expires or verification fails
    function refund() external {
        require(msg.sender == payer, "only payer");
        require(!released, "already released");
        usdc.transfer(payer, usdc.balanceOf(address(this)));
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The constructor captures the expected payload hash (payloadHash). This hash is computed off‑chain by the agent (e.g., keccak256(abi.encodePacked(result, nonce))).
  • fund() moves USDC from the payer into the contract. The payer must first approve the escrow to spend their USDC (usdc.approve(address(escrow), amount)).
  • release() checks that the hash supplied by the caller matches the stored expectation; if so, it transfers the full amount to the payee.
  • refund() lets the payer reclaim funds if the agent never provides a valid proof or if a timeout is enforced off‑chain.

The contract is deliberately non‑upgradable and without admin keys after deployment, which eliminates a central point of failure but also means any bug is permanent.


4. Agent‑side workflow (TypeScript + viem)

Below is a concise, end‑to‑end example showing how an autonomous agent would:

  1. Compute a result and its hash.
  2. Deploy (or reuse) the escrow contract.
  3. Fund it (assuming the payer has already approved).
  4. Submit the proof to trigger payout.
import { createPublicClient, http, parseEther } from 'viem';
import { base } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
import { deployContract, getContractAt } from 'viem';

// ------------------- Configuration -------------------
const RPC_URL = 'https://base.meowrpc.com';
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // Base USDC
const PRIVATE_KEY = process.env.PRIVATE_KEY!; // payer's key (for funding)
const AGENT_KEY = process.env.AGENT_KEY!;    // agent's key (to sign payload)
const ESCROW_ABI = [ /* copy the ABI from the Solidity contract above */ ];

const client = createPublicClient({
  chain: base,
  transport: http(RPC_URL),
});
const payer = privateKeyToAccount(PRIVATE_KEY);
const agent = privateKeyToAccount(AGENT_KEY);

// ------------------- Helper: compute payload hash -------------------
function computePayloadHash(result: string, nonce: bigint): `0x${string}` {
  // In practice you would use a solidity-compatible keccak256
  const encoded = ethers.AbiCoder.defaultAbiCoder().encode(
    ['string', 'uint256'],
    [result, nonce]
  );
  return ethers.keccak256(encoded) as `0x${string}`;
}

// ------------------- Main flow -------------------
async function run() {
  // 1. Agent does work
  const result = '{"label":"cat","confidence":0.93}';
  const nonce = BigInt(Date.now()); // prevent replay
  const payloadHash = computePayloadHash(result, nonce);

  // 2. Deploy escrow (could be cached address if reused)
  const escrow = await deployContract(client, {
    abi: ESCROW_ABI,
    bytecode: `0x${/* compiled bytecode */}`,
    args: [
      USDC_ADDRESS,
      payer.address,   // payer
      agent.address,   // payee (agent receives funds)
      parseEther('0.05'), // $0.05 worth of USDC (6 decimals)
      payloadHash,
    ],
  });

  // 3. Payer funds the escrow (must have approved USDC beforehand)
  const usdcContract = await getContractAt(client, {
    address: USDC_ADDRESS,
    abi: [
      // minimal approve + balanceOf
      { name: 'approve', type: 'function', inputs: [{ name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }], outputs: [{ name: '', type: 'bool' }] },
      { name: 'allowance', type: 'function', inputs: [{ name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }], outputs: [{ name: '', type: 'uint256' }] },
      { name: 'balanceOf', type: 'function', inputs: [{ name: 'account', type: 'address' }], outputs: [{ name: '', type: 'uint256' }] },
    ],
  });

  // Approve if needed
  const allowance = await usdcContract.read.allowance([payer.address, escrow.address]);
  if (allowance < parseEther('0.05')) {
    await usdcContract.write.approve([escrow.address, parseEther('0.10')]); // some headroom
  }

  // Fund escrow
  await usdcContract.write.transferFrom([payer.address, escrow.address, parseEther('0.05')]);

  // 4. Agent submits proof to release funds
  const escrowInstance = await getContractAt(client, {
    address: escrow.address,
    abi: ESCROW_ABI,
  });
  await escrowInstance.write.release([payloadHash]);

  console.log('Payment released to agent');
}
run().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

What the code demonstrates

  • Atomicity – The payer cannot reclaim funds once release() succeeds because the contract transfers USDC immediately.
  • Replay protection – The

Top comments (0)