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 that need to buy and sell services on‑chain without a trusted intermediary.


Why escrow matters for AI agents

Autonomous agents can negotiate, invoke APIs, and even sign transactions, but they still need a way to guarantee payment for work they perform and to receive payment for services they consume. In a purely off‑chain world you’d rely on reputation systems or centralized escrow services—both introduce custodial risk and defeat the promise of “trustless” automation.

On‑chain escrow solves this by locking funds in a smart contract that only releases them when a verifiable condition is met. The condition can be:

  1. A signed receipt from the service provider (off‑chain proof verified on‑chain).
  2. An oracle attestation (e.g., a price feed, a compute‑result hash).
  3. A timeout that returns funds to the payer if the provider never fulfills the job.

When the underlying asset is a stablecoin like USDC, the volatility risk disappears, leaving only the mechanical trade‑offs of gas, latency, and dispute handling.


The Base chain and USDC

Base is an EVM‑compatible L2 optimized for low‑cost transactions. USDC on Base is the canonical ERC‑20 token (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 at the time of writing) and inherits the same guarantees as Ethereum mainnet but with sub‑cent gas fees. This makes micro‑payments (e.g., $0.01–$0.10 per AI inference) economically viable.


A minimal escrow contract

Below is a stand‑alone Solidity contract that implements a simple two‑party escrow. It does not rely on any external upgradeable proxy or complex governance—just the bare logic needed for an agent‑to‑agent freelance market.

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

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

/**
 * @notice Escrow for USDC payments between a payer and a service provider.
 * @dev Funds are deposited by the payer. The provider can claim them
 *      only after presenting a valid off‑chain signature that encodes
 *      the job ID and the agreed amount. If the provider does not claim
 *      within the timeout, the payer can reclaim the funds.
 */
contract USDCбурEscrow {
    IERC20 public immutable usdc;
    address public payer;
    address public provider;
    uint256 public amount;          // locked USDC amount (6 decimals)
    uint256 public deadline;        // block.timestamp after which payer can withdraw
    bytes32 public jobId;           // identifier of the off‑chain work
    bool public claimed;

    event Deposited(address indexed payer, uint256 amount);
    event Claimed(address indexed provider, uint256 amount);
    event Refunded(address indexed payer, uint256 amount);

    constructor(
        address _usdc,
        address _provider,
        uint256 _amount,
        uint256 _timeoutSeconds,
        bytes32 _jobId
    ) {
        require(_usdc != address(0), "invalid USDC");
        require(_provider != address(0), "invalid provider");
        require(_amount > 0, "zero amount");
        usdc = IERC20(_usdc);
        payer = msg.sender;
        provider = _provider;
        amount = _amount;
        deadline = block.timestamp + _timeoutSeconds;
        jobId = _jobId;
    }

    /**
     * @notice Payer deposits USDC into the escrow.
     * dev Must be called by the constructor's payer before any other interaction.
     */
    function deposit() external {
        require(msg.sender == payer, "not payer");
        require(usdc.transferFrom(payer, address(this), amount), "USDC transfer failed");
        emit Deposited(payer, amount);
    }

    /**
     * @notice Provider claims funds by presenting a valid signature.
     * dev The signature must be over keccak256(abi.encodePacked(jobId, amount)).
     * The signer must be the payer; this proves the payer agreed to pay for this job.
     */
    function claim(bytes calldata signature) external {
        require(!claimed, "already claimed");
        require(block.timestamp <= deadline, "expired");

        bytes32 hash = keccak256(abi.encodePacked(jobId, amount));
        address signer = ecrecover(hash, uint8(signature[0]), bytes32(signature[1:33]), bytes32(signature[33:65]));
        require(signer == payer, "invalid signature");

        claimed = true;
        usdc.transfer(provider, amount);
        emit Claimed(provider, amount);
    }

    /**
     * @notice Payer refunds after timeout if provider never claimed.
     */
    function refund() external {
        require(msg.sender == payer, "not payer");
        require(block.timestamp > deadline, "not timeout");
        require(!claimed, "already claimed");

        usdc.transfer(payer, amount);
        emit Refunded(payer, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

How it works

  1. Deployment – The payer (or a factory) deploys the contract, supplying the USDC address, provider’s wallet, the agreed amount (in USDC’s 6‑decimals), a timeout (e.g., 1 hour), and a unique jobId.
  2. Deposit – The payer calls deposit(), which pulls the USDC from their wallet into the contract via transferFrom.
  3. Off‑chain work – The provider performs the service (e.g., runs an LLM inference, returns a result hash).
  4. Claim – The provider obtains a signature from the payer over keccak256(jobId || amount). In practice the payer can sign this off‑chain with their private key (e.g., using eth_sign or EIP‑712). The provider then calls claim(signature). The contract verifies the signature matches the payer and transfers the USDC.
  5. Refund – If the provider never claims before the deadline, the payer calls refund() to retrieve the funds.

The contract is deliberately minimal: no upgradeability, no admin functions, and no external dependencies beyond the ERC‑20 interface. This reduces attack surface and makes gas costs predictable.


Client‑side interaction (TypeScript / ethers.js)

Below is a compact example showing how an AI agent could pay for a remote inference service using the escrow above. The same pattern works in reverse for an agent that sells a service.


ts
import { ethers } from "ethers";
import escrowAbi from "./USDCбурEscrow.json"; // ABI generated by solc
import usdcAbi from "@openzeppelin/contracts/build/contracts/ERC20.json";

const provider = new ethers.JsonRpcProvider("https://base-mainnet.g.alchemy.com/v2/<API_KEY>");
const usdcAddress = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const usdc = new ethers.Contract(usdcAddress, usdcAbi.abi, provider);

// 1️⃣ Payer signs the off‑chain message
async function signJob(jobId: string, amountUsdc: number, signerWallet: ethers.Wallet): Promise<string> {
  const amount = ethers.parseUnits(amountUsdc.toString(), 6); // USDC has 6 decimals
  const hash = ethers.solidityPackedKeccak256(["string", "uint256"], [jobId, amount]);
  const signature = await signerWallet.signMessage(ethers.getBytes(hash));
  return signature; // 65‑byte hex string
}

// 2️⃣ Deploy escrow (payer side)
async function deployEscrow(
  payerWallet: ethers.Wallet,
  providerAddr: string,
  jobId: string,
  amountUsdc: number,
  timeoutSec: number
): Promise<ethers.Contract> {
  const usdcContract = new ethers.Contract(usdcAddress, usdcAbi.abi, payerWallet);
  const amount = ethers.parseUnits(amountUsdc.toString(), 6);

  const escrowFactory = new ethers.ContractFactory(escrowAbi.abi, escrowAbi.bytecode, payerWallet);
  const escrow = await escrowFactory.deploy(
    usdcAddress,
    providerAddr,
    amount,
    timeoutSec,
    ethers.keccak256(ethers.toUtf8Bytes(jobId))
  );
  await escrow.waitForDeployment();
Enter fullscreen mode Exit fullscreen mode

Top comments (0)