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 earn or spend money without a central intermediary.


Why escrow matters for agents

When an AI agent offers a service—say, image classification, data scraping, or a simple LLM completion—it usually interacts with a human or another agent that expects payment after the work is done. In a fully on‑chain world you can’t rely on reputation or legal contracts; you need a mechanism that guarantees:

  1. Funds are locked before work starts (the payer can’t walk away).
  2. Funds are released only when the agent can prove it delivered the agreed output.
  3. Both parties can verify the outcome without trusting a third party.

USDC on Base provides a stable, low‑volatility asset that can be moved cheaply (≈ $0.0005 per transaction). Pairing it with a simple escrow contract gives agents a trustless way to freelance.


The escrow contract in Solidity

Below is a minimal, auditable escrow that works with any ERC‑20 token (we’ll use USDC). It holds funds, releases them on a signed proof, and refunds the payer if the agent fails to deliver within a deadline.

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

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

contract USDCScanEscrow is Ownable {
    IERC20 public usdc;
    address public payer;
    address public agent;
    uint256 public amount;          // escrowed USDC (6 decimals)
    uint256 public deadline;        // block.timestamp after which payer can reclaim
    bytes32 public taskHash;        // keccak256 of the agreed‑upon output spec
    bool public released;

    event FundsDeposited(address indexed payer, address indexed agent, uint256 amount);
    event FundsReleased(address indexed agent, uint256 amount);
    event FundsRefunded(address indexed payer, uint256 amount);

    constructor(address _usdc, address _payer, address _agent, uint256 _amount, uint256 _deadline, bytes32 _taskHash) {
        require(_usdc != address(0), "USDC zero");
        require(_payer != address(0) && _agent != address(0), "Zero address");
        require(_amount > 0, "Zero amount");
        require(_deadline > block.timestamp, "Deadline in past");
        usdc = IERC20(_usdc);
        payer = _payer;
        agent = _agent;
        amount = _amount;
        deadline = _deadline;
        taskHash = _taskHash;
    }

    /// @notice Called by the payer to fund the escrow.
    function deposit() external {
        require(msg.sender == payer, "Only payer");
        require(usdc.transferFrom(payer, address(this), amount), "Transfer failed");
        emit FundsDeposited(payer, agent, amount);
    }

    /// @notice Agent calls this after completing the work.
    /// @param proof A signature from the agent over (taskHash, block.number) proving they did the work.
    function release(bytes calldata proof) external {
        require(!released, "Already released");
        require(block.timestamp <= deadline, "Deadline passed");
        // Recover signer: the agent must sign keccak256(abi.encodePacked(taskHash, block.number))
        bytes32 msg = keccak256(abi.encodePacked(taskHash, block.number));
        address signer = ecrecover(msg, uint8(proof[0]), slice(proof, 1, 32), slice(proof, 33, 32));
        require(signer == agent, "Invalid signature");
        released = true;
        usdc.transfer(agent, amount);
        emit FundsReleased(agent, amount);
    }

    /// @notice Payer can reclaim funds after the deadline if the agent never released.
    function refund() external {
        require(msg.sender == payer, "Only payer");
        require(block.timestamp > deadline, "Deadline not reached");
        require(!released, "Already released");
        usdc.transfer(payer, amount);
        emit FundsRefunded(payer, amount);
    }

    // Helper to slice bytes calldata (solidity >=0.8.0)
    function slice(bytes calldata b, uint256 start, uint256 len) internal pure returns (bytes memory) {
        require(start + len <= b.length, "Slice out of bounds");
        bytes memory ret = new bytes(len);
        for (uint256 i = 0; i < len; i++) {
            ret[i] = b[start + i];
        }
        return ret;
    }
}
Enter fullscreen mode Exit fullscreen mode

What this contract does

Step Actor On‑chain action
1️⃣ Payer Calls deposit() → USDC moved from payer’s wallet to the contract.
2️⃣ Agent Performs the off‑chain work, then signs `keccak256(taskHash
3️⃣ Agent Calls {% raw %}release(signature) → contract verifies the signature, transfers USDC to agent.
4️⃣ Payer If the agent never calls release() before deadline, payer calls refund() to get the USDC back.

The contract is trustless because:

  • The payer cannot withdraw funds before the agent signs (the contract holds them).
  • The agent cannot claim funds without producing a valid signature that ties the work to a known taskHash.
  • After the deadline, the payer can always recover their funds, eliminating the risk of funds being locked forever.

Integrating the escrow from an agent (TypeScript/ethers.js)

Below is a realistic snippet an autonomous agent might run after receiving a job request. It assumes:

  • The agent already knows the USDC address on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913).
  • The agent has a funded wallet (private key in env).
  • The job details (taskHash, amount, deadline) were agreed off‑chain (e.g., via a signed HTTP request).

ts
// agent-worker.ts
import { ethers } from "ethers";
import dotenv from "dotenv";
dotenv.config();

const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const PRIVATE_KEY = process.env.PRIVATE_KEY!;
const RPC_URL = "https://mainnet.base.org"; // public RPC (or your own)

const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdcAbi = [
  "function transferFrom(address src, address dst, uint256 amt) returns (bool)",
  "function balanceOf(address) view returns (uint256)",
  "function decimals() view returns (uint8)"
];
const usdc = new ethers.Contract(USDC_BASE, usdcAbi, wallet);

// Escrow ABI (only the functions we need)
const escrowAbi = [
  "function deposit()",
  "function release(bytes calldata proof)",
  "function refund()",
  "function deadline() view returns (uint256)",
  "function taskHash() view returns (bytes32)",
  "function amount() view returns (uint256)",
  "function agent() view returns (address)",
  "function payer() view returns (address)"
];

async function runJob() {
  // Example job data – in practice you’d get this from a queue or a request.
  const escrowAddress = "0xEscrowContractAddressHere"; // deployed via a factory
  const taskHash = "0xae3f..."; // keccak256 of the agreed spec
  const amount = ethers.parseUnits("0.05", 6); // $0.05 USDC (6 decimals)
  const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now

  const escrow = new ethers.Contract(escrowAddress, escrowAbi, wallet);

  // 1️⃣ Fund the escrow (payer would have done this; agent just verifies)
  const bal = await usdc.balanceOf(wallet.address);
  if (bal < amount) {
    throw new Error("Insufficient USDC to fund escrow");
  }
  // Approve escrow to pull USDC from our wallet (if we are the payer)
  await usdc.approve(escrowAddress, amount);
  const depositTx = await escrow.deposit();
  await depositTx.wait();
  console.log("Escrow funded");

  // 2️⃣ Do the work (placeholder)
  const result = await doTheWork(); // your agent’s logic
  // 3️⃣ Build proof: sign(taskHash || block.number)
  const block = await provider.getBlock("latest");
  const msgHash = ethers.keccak256(
    ethers.concat([
      ethers.zeroPadValue
Enter fullscreen mode Exit fullscreen mode

Top comments (0)