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 earn and spend money without a human in the loop sounds futuristic, but the mechanics are already possible with today’s blockchain tooling. Below is a practical walk‑through of a minimal USDC‑based escrow system that lets an AI agent offer a service, receive payment, and release funds only when the work is provably complete. The focus is on concrete code, realistic trade‑offs, and what you’ll actually need to maintain in production.


Why Escrow Matters for Agent‑to‑Agent Transactions

AI agents operate in hostile environments: they cannot rely on reputation systems, legal contracts, or a trusted third party to guarantee payment. If an agent simply sends USDC to a counterparty and hopes for a result, it has no recourse if the work is never delivered or is incorrectly performed. Escrow flips the risk: the payer locks funds in a contract that only releases them when a pre‑agreed condition is satisfied.

For agents, the condition must be verifiable on‑chain or via a trusted off‑chain oracle that the contract can query. This keeps the settlement trustless while still allowing the agent to perform arbitrarily complex computation off‑chain (e.g., LLM inference, data scraping, micro‑tasks).


System Overview

  1. Agent Registry (off‑chain) – a simple JSON file or IPFS gateway that lists each agent’s public key, service description, and price in USDC.
  2. Escrow Contract – holds the payer’s USDC, exposes deposit, requestWork, and release functions.
  3. Work Verifier – either a deterministic on‑chain check (e.g., hash‑preimage) or an off‑chain oracle that posts a proof to the contract.

The flow is:

  • Payer deposits USDC into the escrow, specifying the target agent and a work‑id.
  • Agent monitors the contract for new WorkRequested events, performs the task off‑chain, and submits a proof (e.g., a solution to a puzzle).
  • Anyone (or a designated verifier) calls release with the proof; if valid, the contract transfers the escrowed amount to the agent.

Solidity Escrow Contract

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

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

contract USdCEscrow {
    IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    address public immutable agent; // the service provider
    uint256 public price; // amount locked, in wei (USDC has 6 decimals)
    bytes32 public workId; // identifier chosen by the payer
    bool public released;

    struct Proof {
        bytes data; // arbitrary proof supplied by the agent
    }

    event WorkRequested(address indexed payer, bytes32 workId);
    event WorkReleased(address indexed agent, uint256 amount);

    constructor(
        address _usdc,
        address _agent,
        uint256 _price, // in USDC * 1e6
        bytes32 _workId
    ) {
        require(_usdc != address(0), "bad usdc");
        require(_agent != address(0), "bad agent");
        require(_price > 0, "zero price");
        usdc = IERC20(_usdc);
        agent = _agent;
        price = _price;
        workId = _workId;
    }

    /// @notice Payer locks USDC. Must approve the contract to spend their tokens first.
    function deposit() external {
        require(usdc.transferFrom(msg.sender, address(this), price), "transfer failed");
        emit WorkRequested(msg.sender, workId);
    }

    /// @notice Agent (or any caller) submits a proof. The contract does not judge validity;
    ///        validation is left to an external verifier or on‑chain logic.
    ///        In this minimal example we accept any non‑empty proof and rely on the caller
    ///        to ensure correctness before calling release.
    function submitProof(bytes calldata _proof) external {
        require(!released, "already released");
        require(_proof.length > 0, "empty proof");
        // In a real system you would store the proof hash or verify here.
        // For demo purposes we just note that a proof exists.
    }

    /// @notice Releases funds if the caller provides a valid proof.
    ///        Replace the internal check with your verification logic (e.g., zk‑SNARK, hash preimage).
    function release(bytes calldata _proof) external {
        require(!released, "already released");
        require(_proof.length > 0, "empty proof");
        // ----- Verification placeholder -----
        // Example: require(keccak256(_proof) == expectedHash, "bad proof");
        // In practice you would call an oracle or verify a signature here.
        // ------------------------------------
        released = true;
        usdc.transfer(agent, price);
        emit WorkReleased(agent, price);
    }

    /// @notice Allows the payer to reclaim funds if the agent never submits a proof
    ///        within a timeout (optional extension).
    function refund() external {
        require(!released, "already released");
        // timeout logic omitted for brevity
        usdc.transfer(msg.sender, price);
        released = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract holds USDC via ERC‑20 transferFrom. The payer must first approve the escrow contract for the exact amount.
  • workId is a bytes32 chosen by the payer; it lets the agent differentiate multiple concurrent jobs.
  • Proof validation is intentionally left as a placeholder. In a production system you would either:
    • Perform the check on‑chain (e.g., verify a hash preimage, a signature, or a zk‑SNARK).
    • Call an external oracle (Chainlink, API3) that signs off on the correctness of the off‑chain work.
  • The refund function demonstrates how a payer can recover funds if the agent never delivers—important for limiting griefing attacks.

Agent‑Side Interaction (JavaScript/ethers.js)

Below is a minimal snippet that an autonomous agent could run inside a Node.js process or a Cloudflare Worker. It assumes the agent holds a private key that controls an Ethereum-compatible address (on Base).


javascript
import { ethers } from "ethers";
import escrowAbi from "./USdCEscrow.json"; // ABI from the compiled contract

// Configuration – replace with your own values
const RPC_URL      = "https://base-mainnet.g.alchemy.com/v2/<YOUR_KEY>";
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ADDR  = "0xEscrowContractAddress..."; // created by the payer
const AGENT_KEY    = process.env.AGENT_PRIVATE_KEY; // never commit this
const PRICE_USDC   = 0.05; // $0.05 per call
const WORK_ID      = ethers.id("summarize-article"); // deterministic identifier

const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet   = new ethers.Wallet(AGENT_KEY, provider);
const usdc     = new ethers.Contract(USDC_ADDRESS, ["function approve(address spender, uint256 amount) returns (bool)", "function balanceOf(address) view returns (uint256)"], wallet);
const escrow   = new ethers.Contract(ESCROW_ADDR, escrowAbi, wallet);

async function listenAndWork() {
    // Filter for new WorkRequested events aimed at this agent
    escrow.on("WorkRequested", async (payer, workIdHex) => {
        const workId = ethers.toHexString(workIdHex);
        if (workId !== WORK_ID) return; // ignore unrelated jobs

        console.log(`[Agent] Work requested by ${payer}`);

        // 1️⃣ Perform the actual task off‑chain (example: call an LLM)
        const result = await doOffchainWork(payer); // <-- implement your logic

        // 2️⃣ Create a proof – here we simply hash the result
        const proof = ethers.solidityPackedKeccak256(["string"], [result]);

        // 3️⃣ Submit proof to the contract (anyone can do this)
        const txSubmit = await escrow.submitProof(ethers.getBytes(proof));
        await txSubmit.wait();
        console.log("[Agent] Proof submitted");

        // 4️⃣ Wait for verifier (could be the payer, a third‑party, or the agent itself) to call release
        // In this simple demo the agent itself calls release after confirming the proof is valid.
        const txRelease = await escrow.release(eth
Enter fullscreen mode Exit fullscreen mode

Top comments (0)