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

By a senior engineer who’s built both on‑chain agents and off‑chain services


Introduction

Autonomous agents need a way to buy and sell micro‑services without relying on a central marketplace that can censor, charge high fees, or disappear overnight. The most straightforward trust‑less primitive is an escrow that holds a stablecoin (USDC) until both parties cryptographically prove that the agreed work has been delivered. This article walks through a minimal, auditable escrow design, shows how an agent can interact with it using the x402 payment standard, and outlines the real‑world trade‑offs you’ll face when you put it into production.


Why USDC?

  • Stable value – USDC’s 1:1 peg to USD eliminates the need for agents to constantly reprice tasks in volatile tokens.
  • Wide support – USDC is ERC‑20 compatible on Ethereum, Base, Polygon, Arbitrum, etc., so the same escrow contract can be deployed once and used across L2s.
  • Regulatory clarity – While not a legal endorsement, USDC is issued by a regulated entity and enjoys broader exchange coverage than many algorithmic stables.

Escrow Contract Overview

The escrow is a simple deterministic contract with three roles:

Role Responsibility
Creator (the agent that wants a service) Deposits USDC, specifies the service hash and a deadline.
Performer (the agent that executes the service) Claims the escrow by providing a pre‑image that hashes to the service hash before the deadline.
Arbitrator (optional) Can refund the creator if the deadline passes without a valid claim. In the minimal version we set the arbitrator to address(0), making the contract self‑executed.

The core logic:

  1. Deposit – Creator calls deposit(bytes32 serviceHash, uint256 deadline). The contract transfers USDC from msg.sender to itself and stores the hash+deadline.
  2. Claim – Performer calls claim(bytes32 serviceHash, bytes preimage). If keccak256(preimage) == serviceHash and block.timestamp <= deadline, the contract transfers the escrowed USDC to msg.sender.
  3. Refund – If block.timestamp > deadline and no claim has been made, anyone can call refund() to return the funds to the creator (only works if the arbitrator is set; with address(0) the function reverts, forcing the creator to withdraw via a separate withdraw() after deadline).

The contract is deliberately tiny—under 200 bytes of bytecode—to keep deployment cheap (~0.0005 ETH on Base) and to minimize attack surface.


Solidity Implementation

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

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

contract USDCreditEscrow {
    IERC20 public immutable usdc;
    address public creator;
    bytes32 public serviceHash;
    uint256 public deadline;
    bool public claimed;
    bool public refunded;

    event Deposited(address indexed creator, uint256 amount);
    event Claimed(address indexed performer, bytes preimage);
    event Refunded(address indexed to);

    constructor(address _usdc) {
        usdc = IERC20(_usdc);
        creator = msg.sender;
    }

    /// @notice Deposit USDC and lock the service hash.
    /// @dev The caller must approve the contract to spend at least `amount` USDC.
    function deposit(bytes32 _serviceHash, uint256 _deadline, uint256 amount) external {
        require(!claimed && !refunded, "Escrow already resolved");
        require(_deadline > block.timestamp, "Deadline must be in future");
        require(usdc.transferFrom(msg.sender, address(this), amount), "Transfer failed");
        creator = msg.sender;
        serviceHash = _serviceHash;
        deadline = _deadline;
        emit Deposited(msg.sender, amount);
    }

    /// @notice Claim the escrow by providing a valid preimage.
    function claim(bytes calldata preimage) external {
        require(!claimed && !refunded, "Escrow already resolved");
        require(block.timestamp <= deadline, "Deadline passed");
        require(keccak256(preimage) == serviceHash, "Invalid preimage");
        claimed = true;
        uint256 balance = usdc.balanceOf(address(this));
        require(usdc.transfer(msg.sender, balance), "USDC transfer failed");
        emit Claimed(msg.sender, preimage);
    }

    /// @notice Refund the creator after the deadline (only if arbitrator set).
    function refund() external {
        require(!claimed && !refunded, "Escrow already resolved");
        require(block.timestamp > deadline, "Deadline not reached");
        require(msg.sender == creator, "Only creator can refund");
        refunded = true;
        uint256 balance = usdc.balanceOf(address(this));
        require(usdc.transfer(creator, balance), "USDC transfer failed");
        emit Refunded(creator);
    }

    /// @notice Allow the creator to pull funds after deadline if no arbitrator.
    function withdraw() external {
        require(!claimed && !refunded, "Escrow already resolved");
        require(block.timestamp > deadline, "Deadline not reached");
        require(msg.sender == creator, "Only creator can withdraw");
        refunded = true;
        uint256 balance = usdc.balanceOf(address(this));
        require(usdc.transfer(creator, balance), "USDC transfer failed");
        emit Refunded(creator);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract does not hold any upgradeable proxy or admin keys—once deployed, its behavior is immutable.
  • USDC is treated as an untrusted ERC‑20; we use transferFrom/transfer and check return values (OpenZeppelin’s ERC20 wrapper already reverts on false).
  • The only external dependency is the USDC token address, which you pass at deployment time.

Agent‑Side Interaction (JavaScript/TypeScript)

Below is a minimal agent that wants to purchase a sentiment‑analysis micro‑service. It uses ethers.js and the x402 payment header format defined by the x402 spec (https://x402.org).

import { ethers } from "ethers";
import { USDCreditEscrow } from "./abis/USDCreditEscrow.json";

// Configuration (Base mainnet, USDC address)
const RPC = "https://mainnet.base.org";
const USDC_ADDR = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ADDR = "0xYourDeployedEscrowHere"; // replace after deployment
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!;

const provider = new ethers.JsonRpcProvider(RPC);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDR, erc20Abi, wallet);
const escrow = new ethers.Contract(ESCROW_ADDR, USDCreditEscrow.abi, wallet);

// Helper: approve USDC for escrow (call once per session)
async function approveEscrow(amount: bigint) {
  const tx = await usdc.approve(ESCROW_ADDR, amount);
  await tx.wait();
}

// Step 1: Deposit and lock the service hash
async function fundEscrow(serviceHash: string, deadline: number, amountUSD: number) {
  const amount = ethers.parseUnits(amountUSD.toString(), 6); // USDC has 6 decimals
  await approveEscrow(amount);
  const tx = await escrow.deposit(serviceHash, deadline, amount);
  const receipt = await tx.wait();
  console.log("Deposit tx:", receipt.hash);
}

// Step 2: Perform work off‑chain, generate preimage, claim
async function claimEscrow(preimage: string) {
  const tx = await escrow.claim(preimage);
  const receipt = await tx.wait();
  console.log("Claim tx:", receipt.hash);
}

// Example usage
(async () => {
  // Imagine we have a service that returns a sentiment score.
  // The service provider publishes the hash of the expected output:
  const serviceHash = ethers.keccak256(ethers.toUtf8Bytes('{"score":0.87}'));
  const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
  await fundEscrow(serviceHash, deadline, 0.05); // $0.05 escrow

  // Off‑chain: call the actual AI model, get result, ensure it matches hash
  const result = await runSentimentModel("I love this product!"); // your model
  const preimage = ethers.toUtf8Bytes(JSON.stringify({ score: result }));
  // Verify that hash matches before committing on‑chain
  if (ethers.keccak256(preimage) !== serviceHash) {
    throw new Error("Model output does not match expected hash");
  }

  // On‑chain claim
  await claimEscrow(preimage);
})();
Enter fullscreen mode Exit fullscreen mode

What the code does

  1. Approves the escrow to pull USDC from the agent’s wallet

Top comments (0)