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

Building autonomous agents that can hire and pay other agents—or humans—without a middleman sounds appealing, but the devil lives in the details. Below is a pragmatic walk‑through of a minimal, on‑chain USDC escrow that lets an AI agent lock funds, trigger a service, and release payment only when verifiable work is done. The goal is not to sell a vision; it’s to show what you actually need to code, what it costs, and where the trust assumptions remain.


1. The problem we’re solving

An AI agent (the payer) wants to buy a micro‑service from another agent or a human provider (the payee). The service might be:

  • an image‑generation call,
  • a data‑scraping task,
  • a short LLM completion,

or anything that can be verified objectively (e.g., a hash of the output matches a commitment).

We need:

  1. Atomicity – the payer’s funds are never lost if the payee disappears.
  2. Non‑custodial – neither party holds the other’s keys.
  3. Verifiability – the payer can check that the payee really did the work before releasing funds.
  4. Low friction – the payer should be able to start the flow from its own code without manual signatures for every transaction.

A simple escrow smart contract satisfies (1)–(3). (4) depends on how the payer manages its signing key and how verification is implemented.


2. Minimal escrow contract (Solidity)

We’ll deploy on Base (an EVM‑compatible L2) where USDC is a standard ERC‑20 at 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913. The contract holds USDC, releases it only after a proof submitted by the payee passes a verification function defined by the payer.

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

interface IERC20 {
    function balanceOf(address) external view returns (uint256);
    function transfer(address, uint256) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

/**
 * @notice Simple escrow for USDC payments between two parties.
 * @dev The payer deposits funds, the payee submits a proof,
 *      and the verifier (a pure or view function) decides if funds are released.
 */
contract USDCeciEscrow {
    IERC20 public immutable usdc;
    address public payer;
    address public payee;
    uint256 public amount;
    bool public released;
    bytes32 public commitment; // optional commitment from payer (e.g., keccak256(service spec))

    // The verifier is a contract address that implements `verify(bytes calldata proof) returns (bool)`.
    address public verifier;

    event Deposited(address indexed payer, uint256 amount);
    event ProofSubmitted(address indexed payee, bytes proof);
    event Released(address indexed payee, uint256 amount);
    event Refunded(address indexed payer, uint256 amount);

    constructor(
        address _usdc,
        address _payer,
        address _payee,
        uint256 _amount,
        address _verifier,
        bytes32 _commitment
    ) {
        require(_usdc != address(0), "USDC zero");
        require(_payer != address(0) && _payee != address(0), "Zero address");
        require(_amount > 0, "Zero amount");
        require(_verifier != address(0), "Verifier zero");
        usdc = IERC20(_usdc);
        payer = _payer;
        payee = _payee;
        amount = _amount;
        verifier = _verifier;
        commitment = _commitment;
    }

    /**
     * @notice Payer (or anyone) deposits USDC into the escrow.
     * @dev The payer must have approved the contract to pull `amount` USDC beforehand.
     */
    function deposit() external {
        require(msg.sender == payer, "Only payer");
        require(usdc.transferFrom(payer, address(this), amount), "Transfer failed");
        emit Deposited(payer, amount);
    }

    /**
     * @notice Payee submits a proof that the service was performed.
     * @dev The verifier contract decides if the proof is valid.
     */
    function submitProof(bytes calldata proof) external {
        require(msg.sender == payee, "Only payee");
        require(!released, "Already released");
        bool ok = IVerifier(verifier).verify(proof);
        require(ok, "Invalid proof");
        released = true;
        usdc.transfer(payee, amount);
        emit ProofSubmitted(payee, proof);
        emit Released(payee, amount);
    }

    /**
     * @notice Payer can reclaim funds if the payee never submits a valid proof before timeout.
     * @dev A simple block‑number timeout; more sophisticated timelocks can be used.
     */
    function refund(uint256 refundAfterBlock) external {
        require(msg.sender == payer, "Only payer");
        require(block.number >= refundAfterBlock, "Not yet timeout");
        require(!released, "Already released");
        released = true; // prevent re‑entry after refund
        usdc.transfer(payer, amount);
        emit Refunded(payer, amount);
    }
}

/**
 * @notice Minimal verifier interface. Implement your own logic off‑chain or on‑chain.
 */
interface IVerifier {
    function verify(bytes calldata proof) external view returns (bool);
}
Enter fullscreen mode Exit fullscreen mode

How it works

  1. Payer approves the escrow contract to pull amount USDC (usdc.approve(escrow, amount)).
  2. Payer calls deposit(). Funds move from the payer’s wallet to the contract.
  3. Payee performs the off‑chain service, creates a proof (e.g., a signature over the service output, a Merkle proof, or a ZK‑SNARK), and calls submitProof(proof).
  4. The escrow forwards the proof to the verifier contract. If verify returns true, the escrow releases USDC to the payee.
  5. If the payee never submits a valid proof before a chosen block number, the payer can call refund() to recover the funds.

3. Verifier design options

The verifier is where trust actually lives. You can make it:

Option Description Trust assumption Gas cost (approx.)
On‑chain pure function (e.g., keccak256(output) == expectedHash) The payer pre‑commits the expected hash; the payee just provides the output. None (if hash is collision‑resistant) ~30 k gas for the hash + storage read
Off‑chain oracle (e.g., Chainlink Keepers, The Graph) An external service watches for a signed message from the payee and calls submitProof. Trust in the oracle not to censor or replay ~50 k gas + oracle fee
ZK‑SNARK verification Payee generates a proof that they know a pre‑image satisfying a circuit (e.g., “I ran model X on prompt Y”). Trust in the SNARK setup ~200 k gas (verification depends on circuit)
Reputation‑based Payee stakes tokens; slashing if they misbehave. Trust in the slashing mechanism Similar to deposit + extra slashing logic

For most micro‑services, a simple hash commitment is enough and cheapest. The payer computes expectedHash = keccak256(abi.encodePacked(prompt, modelVersion, nonce)) before depositing, stores it in the escrow’s commitment slot, and the verifier merely checks keccak256(proof) == commitment.


4. Agent‑side code (TypeScript / ethers.js)

Below is a minimal snippet that an autonomous agent could run to hire a service. It assumes the agent controls an EOA (or a smart‑contract wallet) with a private key stored securely (e.g., in a TPM or encrypted env var).


ts
import { ethers } from "ethers";
import usdcAbi from "./usdcAbi.json"; // standard ERC-20 ABI
import escrowAbi from "./EscrowAbi.json"; // ABI of the contract above

const RPC_URL = "https://mainnet.base.org"; // Base mainnet RPC
const provider = new ethers.JsonRpcProvider(RPC_URL);
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!; // never commit this
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);

// Addresses (Base)
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW = "0xYourEscrowAddressHere"; // deploy once, reuse
const VERIFIER = "0xYourVerifierAddressHere"; // e.g., a simple hash checker
const AMOUNT = ethers.parseUnits("0.05", 6); //
Enter fullscreen mode Exit fullscreen mode

Top comments (0)