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 buy and sell services without a human intermediary requires a payment primitive that is both programmable and censorship‑resistant. USDC on Base gives us a stable ERC‑20 token, but moving money between agents still needs a trust layer. The pattern that works in practice is a simple escrow contract funded in advance, released only when the buyer can verifiably prove that the seller fulfilled the agreed‑upon work.


Architecture Overview

+----------------+       +---------------------+       +-----------------+
|   AI Agent A   |------>|  USDC Escrow Contract|<------|   AI Agent B    |
| (buyer)        | 1. deposit   (hold funds)   | 2. release on proof | (seller) |
+----------------+       +---------------------+       +-----------------+
          ^                                                     |
          | 3. off‑chain work result + proof (oracle/Merkle)   |
          +-----------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode
  1. Deposit – The buyer locks USDC in the escrow contract, specifying the seller’s address and a work identifier (e.g., a hash of the task description).
  2. Execution – The seller performs the task off‑chain (or on‑chain via a separate service) and produces a verifiable proof that the work matches the identifier.
  3. Release – The buyer (or an automated verifier) submits the proof to the contract; if valid, the contract transfers the escrowed amount to the seller. If the proof fails or a timeout expires, the buyer can reclaim the funds.

The contract never holds private keys; it only moves USDC according to on‑chain logic. All trust is shifted to the proof‑verification step, which we discuss later.


Smart Contract Implementation

Below is a minimal, auditable escrow contract written in Solidity 0.8.20. It uses OpenZeppelin’s IERC20 interface for USDC and includes a timeout to prevent funds from being locked forever.

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

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

contract USDEscrow {
    IERC20 public immutable usdc;          // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    address public buyer;
    address public seller;
    bytes32 public workId;                 // keccak256(task description || nonce)
    uint256 public amount;                 // in USDC (6 decimals)
    uint256 public depositTime;
    uint256 public constant TIMEOUT = 3 days; // adjustable per use‑case

    enum State { Created, Funded, Released, Refunded }
    State public state;

    event Deposited(address indexed buyer, address indexed seller, bytes32 workId, uint256 amount);
    event Released(address indexed seller, uint256 amount);
    event Refunded(address indexed buyer, uint256 amount);
    event Timeout(address indexed buyer, uint256 amount);

    constructor(address _usdc, address _buyer, address _seller, bytes32 _workId, uint256 _amount) {
        require(_usdc != address(0), "USDC zero");
        require(_buyer != address(0) && _seller != address(0), "Zero address");
        require(_amount > 0, "Zero amount");
        usdc = IERC20(_usdc);
        buyer = _buyer;
        seller = _seller;
        workId = _workId;
        amount = _amount;
        state = State.Created;
    }

    /// @notice Buyer funds the escrow. Must be called exactly once.
    function deposit() external {
        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;
        depositTime = block.timestamp;
        emit Deposited(buyer, seller, workId, amount);
    }

    /// @notice Seller claims payment after presenting a valid proof.
    /// The proof verification logic is deliberately left to the caller;
    /// the contract only checks that the caller is the seller and that
    /// the state is Funded. Off‑chain verification must happen before
    /// calling this function.
    function release() external {
        require(msg.sender == seller, "Only seller");
        require(state == State.Funded, "Not funded");
        require(block.timestamp >= depositTime + TIMEOUT, "Not timed out yet"); // optional: allow early release if proof supplied
        // In practice, the caller would have already verified the proof off‑chain.
        // To keep the contract simple we trust the caller; see trade‑offs below.
        usdc.transfer(seller, amount);
        state = State.Released;
        emit Released(seller, amount);
    }

    /// @notice Buyer reclaims funds after timeout if seller never released.
    function refund() external {
        require(msg.sender == buyer, "Only buyer");
        require(state == State.Funded, "Not funded");
        require(block.timestamp >= depositTime + TIMEOUT, "Still within timeout");
        usdc.transfer(buyer, amount);
        state = State.Refunded;
        emit Refunded(buyer, amount);
    }

    /// @notice Helper to read remaining time (useful for UI).
    function timeLeft() public view returns (uint256) {
        if (state != State.Funded) return 0;
        return (depositTime + TIMEOUT) > block.timestamp ? (depositTime + TIMEOUT) - block.timestamp : 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this shape?

  • Minimal trusted logic – The contract only moves tokens; it does not try to interpret what “work done” means.
  • Timeout – Guarantees the buyer can recover funds if the seller disappears.
  • Explicit roles – Buyer and seller are set at deployment, preventing address‑spoofing attacks.

Agent Integration Code (Node.js + ethers.js)

Below is a compact example showing how an autonomous buyer agent would:

  1. Deploy (or attach to) the escrow contract.
  2. Deposit USDC.
  3. Request a service from a seller agent via HTTP (the seller could be an x402‑enabled endpoint).
  4. Verify the result off‑chain (here we illustrate a simple HMAC check; in production you’d use a zk‑SNARK, optimistic challenge, or trusted oracle).
  5. Call release() if the proof validates, otherwise refund() after timeout.

javascript
// ---------------------------------------------------------------
//  Buyer Agent – USDC escrow workflow on Base
// ---------------------------------------------------------------
require('dotenv').config();
const { ethers } = require('ethers');
const abi = [
  "function deposit()",
  "function release()",
  "function refund()",
  "event Released(address indexed seller, uint256 amount)",
  "event Refunded(address indexed buyer, uint256 amount)"
];

async function main() {
  const provider = new ethers.JsonRpcProvider(process.env.BASE_RPC_URL);
  const wallet   = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
  const usdcAddr = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
  const escrowAddr = process.env.ESCROW_ADDRESS; // pre‑deployed or create2

  const escrow = new ethers.Contract(escrowAddr, abi, wallet);

  // 1️⃣ Deposit – assume we already agreed on workId and amount off‑chain
  const workId = ethers.keccak256(ethers.toUtf8Bytes("Summarize article X + nonce 42"));
  const amount = ethers.parseUnits("0.05", 6); // $0.05 USDC (6 decimals)

  // Approve escrow to pull USDC from our wallet
  const usdc = new ethers.Contract(usdcAddr, ["function approve(address spender, uint25
Enter fullscreen mode Exit fullscreen mode

Top comments (0)