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 sell their services without a middleman sounds like sci‑fi, but the pieces are already on‑chain. This post walks through a concrete, minimal‑viable design for an escrow‑based payment flow using USDC on Base, shows the actual Solidity and JavaScript you’d need to integrate, and calls out the practical limits you’ll hit when you try to run it in production.


1. The Problem Space

When an AI agent offers a paid API (e.g., “summarize this document for $0.02”), two trust issues appear:

  1. Caller → Agent: The caller must know the agent will actually perform the work before paying.
  2. Agent → Caller: The agent must know it will receive payment after doing the work.

Traditional solutions rely on a centralized platform that holds funds, issues invoices, and mediates disputes. That re‑introduces custodial risk, KYC friction, and a single point of failure. An escrow smart contract can remove the custodian while still giving both parties a deterministic guarantee: funds are locked until a verifiable condition is met, then released automatically.


2. High‑Level Flow

+-----------+        1. Deposit USDC        +-----------+
|   Caller  | ---------------------------> | Escrow    |
+-----------+                              +-----------+
        ^                                         |
        | 2. Agent does work (off‑chain)          | 3. Verify outcome
        |                                         v
        |                               +-----------+
        +------------------------------ |  Oracle   |
                                        +-----------+
Enter fullscreen mode Exit fullscreen mode
  1. Deposit – The caller sends USDC to the escrow contract and records the intended price and a job ID (a hash of the request parameters).
  2. Work – The agent reads the job ID from the contract, performs the computation off‑chain (or on‑chain if cheap), and produces a result.
  3. Verification – An oracle (could be a simple trusted server, a ZK‑proof, or a reputation‑based committee) checks that the result matches the request and signals the escrow to release funds to the agent.
  4. Release – The escrow transfers USDC to the agent; the caller can reclaim funds if the oracle disputes the work.

All steps after deposit are trustless: the contract cannot be tampered with, and the oracle’s decision is the only external input.


3. Minimal Escrow Contract (Solidity)

Below is a bare‑bones implementation that works on any EVM‑compatible chain (we’ll deploy to Base because USDC is native there). It follows the ERC‑20 standard for USDC and uses a simple binary oracle: the oracle address can call resolve(jobId, bool success); if success is true, funds go to the agent, otherwise they are refunded.

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

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

contract USDCбольшойEscrow {
    // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    IERC20 public immutable usdc;

    struct Job {
        address payer;
        address agent;
        uint256 amount;      // in USDC wei (6 decimals)
        bytes32 jobId;       // keccak256(abi.encodePacked(requestData))
        bool   resolved;
        bool   outcome;      // true = agent paid, false = refund
    }

    mapping(bytes32 => Job) public jobs;
    address public oracle;   // set once at deployment

    event JobCreated(bytes32 indexed jobId, address payer, address agent, uint256 amount);
    event JobResolved(bytes32 indexed jobId, bool success);

    constructor(address _usdc, address _oracle) {
        require(_usdc != address(0), "USDC zero");
        require(_oracle != address(0), "Oracle zero");
        usdc = IERC20(_usdc);
        oracle = _oracle;
    }

    /// @notice Caller deposits USDC and creates a job
    /// @param _agent The AI agent that will do the work
    /// @param _amount USDC amount (in wei, 6 decimals)
    /// @param _jobId Hash of the request (must be unique per caller)
    function createJob(
        address _agent,
        uint256 _amount,
        bytes32 _jobId
    ) external {
        require(_amount > 0, "Zero amount");
        require(jobs[_jobId].payer == address(0), "Job exists");

        // Transfer USDC from caller to escrow
        require(usdc.transferFrom(msg.sender, address(this), _amount), "Transfer failed");

        jobs[_jobId] = Job({
            payer:   msg.sender,
            agent:   _agent,
            amount:  _amount,
            jobId:   _jobId,
            resolved:false,
            outcome: false
        });

        emit JobCreated(_jobId, msg.sender, _agent, _amount);
    }

    /// @notice Oracle calls this to settle a job
    /// @param _jobId The job identifier
    /// @param _success true if work was satisfactory, false otherwise
    function resolveJob(bytes32 _jobId, bool _success) external {
        require(msg.sender == oracle, "Not oracle");
        Job storage j = jobs[_jobId];
        require(!j.resolved, "Already resolved");
        require(j.payer != address(0), "No such job");

        j.resolved = true;
        j.outcome  = _success;

        if (_success) {
            // Pay agent
            require(usdc.transfer(j.agent, j.amount), "USDC transfer fail");
        } else {
            // Refund payer
            require(usdc.transfer(j.payer, j.amount), "USDC refund fail");
        }

        emit JobResolved(_jobId, _success);
    }

    /// @notice Allows caller to reclaim funds if oracle never resolves (timeout handling off‑chain)
    function refund(bytes32 _jobId) external {
        Job storage j = jobs[_jobId];
        require(j.payer == msg.sender, "Not payer");
        require(!j.resolved, "Already resolved");
        // Optional: add a block.timestamp > deadline check here
        require(usdc.transfer(j.payer, j.amount), "USDC refund fail");
        delete jobs[_jobId];
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • USDC handling – Uses transferFrom on deposit, then transfer for payout/refund. No custom ERC‑20 logic needed.
  • Job ID – Must be unique per request; a typical pattern is keccak256(abi.encodePacked(caller, nonce, parameters)). The agent can compute the same hash off‑chain to locate the job.
  • Oracle – A single address keeps the contract simple. In production you’d replace this with a multisig, a threshold signature scheme, or a ZK‑proof verifier to reduce trust.
  • Gas – Deposit (~70k), resolve (~45k), refund (~45k). On Base, USDC transfers are cheap (~0.0001 USD) but still non‑zero; factor this into pricing.

4. Agent‑Side Integration (JavaScript/TypeScript)

Below is a minimal example using viem (the modern ethers‑alternative) to interact with the escrow contract on Base. The agent watches for new jobs, does the work, and then signals the oracle (here we simulate an oracle call; in reality the oracle would be a separate service).


ts
import { createPublicClient, http, parseAbi } from "viem";
import { base } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";

// ---------- Configuration ----------
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ADDRESS = "0xYourEscrowHere"; // deployed address
const ORACLE_PRIVATE_KEY = "0x..."; // oracle's key (kept off‑chain)
const AGENT_PRIVATE_KEY = "0x..."; // agent's key (holds USDC for gas)

// ---------- Clients ----------
const publicClient = createPublicClient({
  chain: base,
  transport: http(),
});
const oracleAccount = privateKeyToAccount(ORACLE_PRIVATE_KEY);
const agentAccount = privateKeyToAccount(AGENT_PRIVATE_KEY);

// ---------- ABI (minimal) ----------
const escrowAbi = parseAbi([
  "function createJob(address agent, uint256 amount, bytes32 jobId) external",
  "function resolveJob(bytes32 jobId, bool success) external",
  "event JobCreated(bytes32 indexed jobId, address payer, address agent, uint256 amount)",
]);

// ---------- Helper: compute jobId ----------
function hashRequest(request: any): `0x${string}` {
  // Example: keccak256 of JSON‑stringified request + caller + nonce
  const encoded = JSON.stringify(request);
  return keccak256(toHex(encoded)); // viem utility
}

// ---------- Main loop ----------
async function main() {
  // Watch for new JobCreated events
  const unwatch = publicClient.watchEvent({
    address: ESCROW_ADDRESS,
    event: escrowAbi.event("JobCreated"),
    onLogs: (logs) => {
      for (const log of logs) {
        const { jobId, payer, agent, amount } = log.args;
        console.log(`New job ${jobId} from ${payer} for ${agent} worth ${amount}`);

        // 1️⃣ Verify the job is meant for this agent
        if (agent.toLowerCase() !== agentAccount.address.toLowerCase()) continue;

        // 2️⃣ Do the work (off‑chain)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)