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 or sell services without a trusted intermediary.


1. Why escrow matters for AI‑to‑AI commerce

Autonomous agents can negotiate, invoke APIs, and even sign transactions, but they still face the classic “pay‑or‑get‑nothing” dilemma:

Scenario Risk for the buyer Risk for the seller
Pay up‑front, then receive result May lose funds if the agent fails or returns garbage None
Receive result, then pay later None May never get paid if the buyer disappears
Trust a third‑party escrow service Central point of failure, KYC, censorship Same

A trustless escrow removes the third party by locking funds in a smart contract that can only release them when a pre‑agreed condition is provably satisfied on‑chain (or via a trusted off‑chain attestation). USDC on Base is a good fit because:

  • It’s a stable ERC‑20 token, so price volatility doesn’t affect the agreement.
  • Base’s low gas fees (~$0.0001 per transaction) make micro‑payments viable.
  • The contract can be kept simple, reducing attack surface.

2. Core design of a minimal USDC escrow contract

We’ll implement a single‑use escrow that holds funds for one job. The flow:

  1. Creator (buyer) deposits USDC and specifies the seller address and a validation hash (keccak256 of the expected result).
  2. Solver (seller) performs the work off‑chain, computes the result, and submits the pre‑image (the actual result) to the contract.
  3. The contract checks keccak256(submitted) == validationHash. If true, it releases the USDC to the seller; otherwise, after a timeout the buyer can reclaim the funds.

Why a hash?

It lets the buyer verify correctness without revealing the result until the seller claims payment, preventing a “free‑rider” who could simply copy the answer.

2.1 Solidity (v0.8.20) implementation

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

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

contract USDC lourdeEscrow {
    IERC20 public immutable usdc;
    address public buyer;
    address public seller;
    bytes32 public validationHash; // keccak256(expectedResult)
    uint256 public amount;        // in USDC (6 decimals on Base)
    uint256 public deadline;      // block.timestamp after which buyer can refund
    enum State { Created, Funded, Solved, Refunded }
    State public state;

    constructor(address _usdc, address _buyer, address _seller, bytes32 _hash, uint256 _amount, uint256 _timeoutSeconds) {
        require(_usdc != address(0), "USDC address zero");
        require(_buyer != address(0) && _seller != address(0), "Zero party");
        require(_amount > 0, "Amount must be > 0");
        usdc = IERC20(_usdc);
        buyer = _buyer;
        seller = _seller;
        validationHash = _hash;
        amount = _amount;
        deadline = block.timestamp + _timeoutSeconds;
        state = State.Created;
    }

    /// @notice Buyer funds the escrow. Must be called exactly once.
    function fund() external payable {
        require(msg.sender == buyer, "Only buyer");
        require(state == State.Created, "Already funded");
        require(usdc.transferFrom(buyer, address(this), amount), "USDC transfer failed");
        state = State.Funded;
    }

    /// @notice Seller submits the pre‑image. If hash matches, funds are released.
    function solve(bytes calldata preimage) external {
        require(msg.sender == seller, "Only seller");
        require(state == State.Funded, "Not funded yet");
        require(keccak256(preimage) == validationHash, "Invalid solution");
        usdc.transfer(seller, amount);
        state = State.Solved;
    }

    /// @notice Buyer can reclaim funds after deadline if seller never solved.
    function refund() external {
        require(msg.sender == buyer, "Only buyer");
        require(state == State.Funded, "Not in fundable state");
        require(block.timestamp >= deadline, "Deadline not reached");
        usdc.transfer(buyer, amount);
        state = State.Refunded;
    }

    /// @notice Helper for off‑chain verification: returns true if escrow is ready to payout.
    function isSolved() external view returns (bool) {
        return state == State.Solved;
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract holds USDC via transferFrom; the buyer must approve the contract to spend their USDC beforehand (usdc.approve(address(escrow), amount)).
  • The validationHash is set at deployment; the seller never learns the expected result until they compute it.
  • A simple timeout prevents funds from being locked forever.
  • No external oracle is needed; verification is pure on‑chain hashing.

3. Agent‑side workflow (TypeScript + ethers.js)

Below is a minimal, production‑ready snippet that an autonomous agent could embed in its decision loop. It assumes the agent already possesses a private key (or wallet) with USDC on Base.

import { ethers } from "ethers";
import escrowAbi from "./USDC lourdeEscrow.json"; // ABI generated by solc

// -----------------------------------------------------------------------------
// Configuration (replace with your own values)
// -----------------------------------------------------------------------------
const RPC_URL = "https://mainnet.base.org"; // Base mainnet RPC
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const PRIVATE_KEY = process.env.PRIVATE_KEY!; // funds must be loaded beforehand
const BUYER = new ethers.Wallet(PRIVATE_KEY, new ethers.JsonRpcProvider(RPC_URL));
const SELLER = "0xSellerAddress..."; // set when the job is posted
const AMOUNT_USDC = ethers.parseUnits("0.05", 6); // $0.05 (USDC has 6 decimals)
const TIMEOUT_SECONDS = 3600; // 1 hour to solve
// -----------------------------------------------------------------------------

async function createEscrow(jobHash: BytesLike): Promise<string> {
  const factory = new ethers.ContractFactory(escrowAbi, escrowAbi, BUYER);
  const escrow = await factory.deploy(
    USDC_ADDRESS,
    BUYER.address,
    SELLER,
    jobHash,
    AMOUNT_USDC,
    TIMEOUT_SECONDS
  );
  await escrow.waitForDeployment();
  const address = await escrow.getAddress();

  // Approve USDC spending
  const usdc = new ethers.Contract(
    USDC_ADDRESS,
    ["function approve(address spender, uint256 amount) returns (bool)"],
    BUYER
  );
  const approveTx = await usdc.approve(address, AMOUNT_USDC);
  await approveTx.wait();

  // Fund the escrow
  const fundTx = await escrow.fund();
  await fundTx.wait();

  return address;
}

async function attemptSolve(escrowAddr: string, solution: Uint8Array): Promise<boolean> {
  const escrow = new ethers.Contract(escrowAddr, escrowAbi, BUYER);
  const tx = await escrow.solve(solution);
  const receipt = await tx.wait();
  // Check if state moved to Solved (optional)
  const state = await escrow.state();
  return state === 2; // enum State.Solved == 2
}

async function refundIfNeeded(escrowAddr: string): Promise<void> {
  const escrow = new ethers.Contract(escrowAddr, escrowAbi, BUYER);
  const now = (await BUYER.provider.getBlock("latest")).timestamp;
  const deadline = await escrow.deadline();
  if (now >= Number(deadline)) {
    const tx = await escrow.refund();
    await tx.wait();
  }
}

/* Example usage inside an agent loop */
export async function handleJob(job: { hash: BytesLike; compute: () => Promise<Uint8Array> }) {
  const escrowAddr = await createEscrow(job.hash);
  try {
    const result = await job.compute(); // off‑chain work (ML inference, data fetch, etc.)
    const solved = await attemptSolve(escrowAddr, result);
    if (!solved) {
      console.error("Solution rejected – likely hash mismatch");
      await refundIfNeeded(escrowAddr);
    } else {
      console.log("Job solved and paid");
    }
  } catch (err) {
    console.error("Agent failed to compute:", err);
    await refundIfNeeded(escrowAddr);
  }
}
Enter fullscreen mode Exit fullscreen mode

Explanation of the flow

  1. Deploy a new escrow contract with the hash of the expected output.
  2. Approve and fund the contract with the agreed USDC amount.
  3. The agent (acting as either buyer or seller) computes the result off‑chain.
  4. It calls solve(bytes); if the hash matches, the contract transfers USDC to the seller.
  5. If the timeout elapses without a valid solve, the buyer calls refund() to retrieve funds.

Top comments (0)