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 exchange verifiable work for on‑chain payment without relying on a trusted intermediary.


1. Why escrow matters for AI agents

When an AI agent offers a service (e.g., image generation, data enrichment, code review) it typically performs the work before receiving payment. A client that refuses to pay after the work is done creates a classic principal‑agent problem. On‑chain escrow solves this by locking the client’s funds in a deterministic contract that can only be released when pre‑agreed conditions are met—usually proof that the agent has produced the expected output. The agent never needs to trust the client’s promise, and the client never needs to trust the agent’s honesty about delivery.

The pattern is not new; it mirrors traditional freelance escrow services, but the implementation can be fully trustless when using a programmable token like USDC on a low‑cost L2 such as Base.


2. Core components

Component Role Typical tech
USDC token ERC‑20 stablecoin used as the escrow asset 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 on Base
Escrow contract Holds USDC, releases to agent on proof, allows client to reclaim on timeout or dispute Solidity (ERC‑20 compatible)
Agent off‑chain service Performs the AI work, generates a cryptographic proof of completion, calls the escrow contract to claim payment Node.js/TypeScript + ethers.js + any ML inference library
Client front‑end Deposits USDC into escrow, supplies job parameters, verifies proof, triggers release or dispute Web app or SDK

The flow is:

  1. Client creates an escrow, depositing price * amount USDC.
  2. Client posts a job description (input data, expected output format, timeout).
  3. Agent watches for new jobs, executes the AI model, produces output and a proof (e.g., a Merkle root of the generated data, or a signature over a hash of the output).
  4. Agent calls escrow.claim(jobId, proof). The contract verifies the proof against the stored job hash; if valid, it transfers the locked USDC to the agent.
  5. If the agent fails to claim before the timeout, the client can call escrow.refund(jobId) to recover funds.
  6. Optional dispute: a third‑party arbitrator can adjudicate if the proof is rejected.

3. Minimal escrow contract (Solidity)

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

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

interface IPaymaster {
    function refund(address user, uint256 amount) external;
}

/**
 * @notice Simple escrow for USDC payments between a client and an AI agent.
 * @dev The contract assumes the client funds it before any agent interacts.
 */
contract USDCové {
    IERC20 public immutable usdc;
    address public immutable client; // set at construction
    uint256 public constant TIMEOUT = 12 hours; // adjustable per job

    struct Job {
        bytes32 jobHash;      // keccak256(abi.encodePacked(input, outputSpec))
        address agent;        // set when agent claims
        uint256 deadline;     // block.timestamp + TIMEOUT
        bool    claimed;
        bool    refunded;
    }

    mapping(bytes32 => Job) public jobs;

    event JobCreated(bytes32 indexed jobId, address indexed client, uint256 amount);
    event JobClaimed(bytes32 indexed jobId, address indexed agent);
    event JobRefunded(bytes32 indexed jobId, address indexed client);
    event JobDisputed(bytes32 indexed jobId, address indexed arbiter);

    constructor(address _usdc, address _client) {
        require(_usdc != address(0), "bad token");
        require(_client != address(0), "bad client");
        usdc = IERC20(_usdc);
        client = _client;
    }

    /**
     * @notice Fund the escrow for a new job.
     * @dev Caller must be the client and must approve the contract to spend USDC.
     */
    function createJob(bytes32 _jobHash, uint256 _amount) external {
        require(msg.sender == client, "only client");
        require(_amount > 0, "zero amount");
        require(usdc.transferFrom(client, address(this), _amount), "transfer failed");

        bytes32 jobId = keccak256(abi.encodePacked(_jobHash, block.timestamp));
        jobs[jobId] = Job({
            jobHash: _jobHash,
            agent: address(0),
            deadline: block.timestamp + TIMEOUT,
            claimed: false,
            refunded: false
        });

        emit JobCreated(jobId, client, _amount);
    }

    /**
     * @notice Agent calls this after producing output that matches _jobHash.
     * @dev The proof is a signature over the jobHash; any off‑chain verification
     *      scheme can be substituted (e.g., SNARK, Merkle proof).
     */
    function claim(bytes32 jobId, bytes calleeProof) external {
        Job storage j = jobs[jobId];
        require(!j.claimed, "already claimed");
        require(!j.refunded, "already refunded");
        require(block.timestamp <= j.deadline, "expired");

        // ----- Proof verification -----
        // Here we expect an ECDSA signature from the agent's known key.
        // Replace with your preferred verification logic.
        address signer = ecrecover(
            keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", j.jobHash)),
            0,
            bytes20(bytes)
        );
        require(signer != address(0) && signer == msg.sender, "invalid proof");
        // --------------------------------

        j.agent = msg.sender;
        j.claimed = true;

        // Release funds to agent
        uint256 amount = usdc.balanceOf(address(this));
        require(usdc.transfer(msg.sender, amount), "transfer failed");

        emit JobClaimed(jobId, msg.sender);
    }

    /**
     * @notice Client can refund if the agent never claimed before timeout.
     */
    function refund(bytes32 jobId) external {
        Job storage j = jobs[jobId];
        require(msg.sender == client, "only client");
        require(!j.claimed, "already claimed");
        require(!j.refunded, "already refunded");
        require(block.timestamp > j.deadline, "not expired yet");

        j.refunded = true;
        uint256 amount = usdc.balanceOf(address(this));
        require(usdc.transfer(client, amount), "transfer failed");

        emit JobRefunded(jobId, client);
    }

    /**
     * @notice Optional dispute resolution via an arbiter.
     */
    function dispute(bytes32 jobId, address arbiter, bool agentWins) external {
        Job storage j = jobs[jobId];
        require(arbiter != address(0), "bad arbiter");
        require(!j.claimed && !j.refunded, "already settled");

        if (agentWins) {
            j.agent = arbiter; // arbiter acts as proxy for agent
            j.claimed = true;
            uint256 amount = usdc.balanceOf(address(this));
            require(usdc.transfer(arbiter, amount), "transfer failed");
        } else {
            j.refunded = true;
            uint256 amount = usdc.balanceOf(address(this));
            require(usdc.transfer(client, amount), "transfer failed");
        }

        emit JobDisputed(jobId, arbiter);
    }

    // Fallback to reject plain ether
    receive() external payable {
        revert("No ETH");
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract never mints or burns USDC; it only moves existing tokens.
  • createJob expects the caller (the client) to have already approved the contract to spend the required USDC amount via usdc.approve(address(this), amount).
  • Proof verification is intentionally left abstract; you can plug in any off‑chain attestation (signature, ZK‑proof, TLS notarization) as long as it resolves to an address that matches msg.sender.
  • TIMEOUT is a simple block‑timestamp based expiry; for production you may want a more flexible mechanism (e.g., Chainlink Keepers or a scheduler).

4. Agent side – TypeScript/ethers.js example


ts
import { ethers } from "ethers";
import * as dotenv from "dotenv";
dotenv.config();

// Configuration (fill with your own values)
const RPC_URL   = process.env.BASE_RPC!;          // e.g. https://base.mainnet.rpc.cloud
const USDC_ADDR = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ADDR = process.env.ESCROW_ADDRESS!; // deployed escrow
const AGENT_PRIVATE_KEY = process.env.AGENT_PK!; // funds for gas only

const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet   = new ethers.Wallet(AGENT_PRIVATE_KEY, provider);
const usdcAbi  = ["function approve(address spender, uint256 amount) returns (bool)"];
const us
Enter fullscreen mode Exit fullscreen mode

Top comments (0)