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 pay for external services (data, compute, APIs) without relying on a centralized intermediary.


Why escrow matters for AI agents

An AI agent that can act autonomously still needs to acquire resources it cannot produce itself—think of a language model calling a paid sentiment‑analysis API, or a trading bot buying market‑data feeds. In a traditional setting the agent would:

  1. Send a request to a service provider.
  2. Receive an invoice.
  3. Pay via a bank card or custodial wallet.
  4. Wait for the provider to confirm receipt before the work starts.

Each step introduces a trust boundary: the agent must trust the provider to honor the invoice, and the provider must trust the agent to pay. For truly autonomous agents that operate 24/7 on blockchains, those boundaries become bottlenecks and single points of failure.

A trustless escrow removes the need for mutual trust by locking funds in a smart contract that can only be released when predefined, verifiable conditions are met. The agent deposits USDC, the provider performs the work, and the contract releases the funds automatically—provided the work can be proved on‑chain or via a trusted off‑chain oracle.


Core components of a minimal escrow flow

Component Role Typical implementation
USDC token ERC‑20 stablecoin used for payment 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 on Base (mainnet‑compatible)
Escrow contract Holds USDC, enforces release conditions Solidity contract (see below)
Agent client Creates escrow, calls provider, signals completion JavaScript/TypeScript with ethers.js or viem
Provider Performs the off‑chain work, emits a proof Can be a simple HTTP service that signs a message or calls an oracle
Oracle / verification (optional) Supplies on‑chain proof of work completion Chainlink, API3, or a custom keeper that calls escrow.release()

The flow is:

  1. Agent approves the escrow contract to spend USDC (standard ERC‑20 approve).
  2. Agent deposits funds into escrow via escrow.deposit(value, provider).
  3. Provider does the work off‑chain.
  4. Provider calls escrow.confirmWork(jobId, proof) (or an oracle calls it).
  5. Escrow verifies the proof (e.g., a signature matching the provider’s known address) and transfers the USDC to the provider.
  6. If the provider never calls confirmWork within a timeout, the agent can reclaim the funds via escrow.refund(jobId).

Solidity escrow contract (Base‑compatible)

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

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

/// @notice Simple USDC escrow for AI‑agent freelancing.
/// @dev Assumes USDC follows the ERC‑20 spec with 6 decimals.
contract USDCRegulatedEscrow is Ownable {
    IERC20 public immutable usdc;
    uint64 public constant TIMEOUT = 12 hours; // refund window

    struct Job {
        address provider;
        uint256 amount;          // USDC amount (6‑decimals)
        uint256 depositedAt;     // block.timestamp when funds locked
        bool completed;
        bytes32 jobId;           // caller‑chosen identifier
    }

    mapping(bytes32 => Job) public jobs;

    event Deposit(address indexed agent, bytes32 indexed jobId, uint256 amount);
    event WorkConfirmed(address indexed provider, bytes32 indexed jobId);
    event Refunded(address indexed agent, bytes32 indexed jobId);
    event Withdrawal(address indexed provider, bytes32 indexed jobId, uint256 amount);

    constructor(address _usdc) {
        require(_usdc != address(0), "ZERO_ADDRESS");
        usdc = IERC20(_usdc);
    }

    /// @dev Agent approves USDC to this contract, then calls deposit.
    function deposit(bytes32 jobId, address provider, uint256 amountUsdc6) external {
        require(amountUsdc6 > 0, "ZERO_AMOUNT");
        require(jobs[jobId].amount == 0, "JOB_EXISTS");
        require(usdc.transferFrom(msg.sender, address(this), amountUsdc6), "TRANSFER_FAILED");

        jobs[jobId] = Job({
            provider: provider,
            amount: amountUsdc6,
            depositedAt: block.timestamp,
            completed: false,
            jobId: jobId
        });

        emit Deposit(msg.sender, jobId, amountUsdc6);
    }

    /// @notice Called by provider (or an oracle) once work is done.
    /// @dev In a real system you would verify a signature or off‑chain proof here.
    function confirmWork(bytes32 jobId) external {
        Job storage j = jobs[jobId];
        require(j.amount > 0, "NO_JOB");
        require(!j.completed, "ALREADY_COMPLETED");
        require(msg.sender == j.provider, "NOT_PROVIDER");

        j.completed = true;
        emit WorkConfirmed(j.provider, jobId);

        // Transfer USDC to provider
        require(usdc.transfer(j.provider, j.amount), "TRANSFER_FAILED");
        emit Withdrawal(j.provider, jobId, j.amount);
    }

    /// @notice Agent can refund if provider never confirms before timeout.
    function refund(bytes32 jobId) external {
        Job storage j = jobs[jobId];
        require(j.amount > 0, "NO_JOB");
        require(!j.completed, "ALREADY_COMPLETED");
        require(block.timestamp >= j.depositedAt + TIMEOUT, "NOT_TIMED_OUT");

        j.completed = true; // prevent double‑refund
        emit Refunded(msg.sender, jobId);

        require(usdc.transfer(msg.sender, j.amount), "TRANSFER_FAILED");
    }

    /// @owner Only: withdraw stuck USDC (emergency).
    function rescue(uint256 amountUsdc6) external onlyOwner {
        require(usdc.transfer(owner(), amountUsdc6), "TRANSFER_FAILED");
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract is deliberately minimal: no upgradeability, no complex dispute resolution.
  • confirmWork currently trusts the caller’s address (msg.sender) as proof. In production you would replace this with a signature verification (ecrecover) or an oracle call that attests to off‑chain completion.
  • USDC on Base has 6 decimals; the contract works with raw uint256 amounts (e.g., 10 * 10**6 for $10).
  • The TIMEOUT constant (12 h) is a parameter you can tune per job type.

Agent‑side interaction (TypeScript + ethers.js)


ts
import { ethers } from "ethers";
import escrowAbi from "./USDCRegulatedEscrow.json"; // ABI generated by solc
import usdcAbi from "./IERC20.json";

// ==== Configuration ====
const RPC_URL = "https://base.mainnet.rpc.dev"; // replace with your provider
const PRIVATE_KEY = "0x..."; // agent's wallet (must hold USDC)
const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ESCROW_ADDRESS = "0xEscrow..."; // address of deployed contract
const PROVIDER = "0xProvider..."; // address of the service you will call
const JOB_ID = ethers.utils.id("sentiment-analysis-2024-09-24-01");
const AMOUNT_USDC = ethers.utils.parseUnits("0.05", 6); // $0.05

// ==== Setup ====
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, wallet);
const escrow = new ethers.Contract(ESCROW_ADDRESS, escrowAbi, wallet);

// ==== Helper: ensure allowance ====
async function approveEscrow() {
  const allowance = await usdc.allowance(wallet.address, ESCROW_ADDRESS);
  if (allowance < AMOUNT_USDC) {
    const tx = await usdc.approve(ESCROW_ADDRESS, AMOUNT_USDC);
    await tx.wait();
    console.log("Approval tx:", tx.hash);
  }
}

// ==== Main flow ====
(async () => {
  await approveEscrow();

  // Deposit into escrow
  const depTx = await escrow.deposit(JOB_ID, PROVIDER, AMOUNT_USDC);
  const depReceipt = await depTx.wait();
  console.log(`Deposited, tx=${depTx.hash}`);

  // ---- Off‑chain work ----
  // In reality
Enter fullscreen mode Exit fullscreen mode

Top comments (0)