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 on‑chain.


Why escrow matters for agents

AI agents operate without a human in the loop, yet they still need to exchange value for work—whether that’s calling an external API, purchasing compute, or paying a freelancer for a micro‑task. If the agent simply sends USDC to a counterparty address and hopes the service is delivered, two problems arise:

  1. Atomicity – The payment and the service delivery are separate transactions; a malicious party can take the funds and not perform.
  2. Dispute resolution – Without a trusted third party, the agent has no recourse if the service is sub‑standard or never arrives.

An escrow contract solves both by holding the funds until a pre‑agreed condition is met, then releasing them atomically. On Base (an Ethereum L2 optimized for low‑cost USDC transfers), the gas price is low enough that a simple escrow adds only a few cents to each transaction, making it practical for high‑frequency agent‑to‑agent interactions.


USDC on Base: the basics

  • USDC is an ERC‑20 token with address 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 on Base.
  • The token follows the standard transfer and approve/transferFrom interface, so any ERC‑20‑compatible contract can hold it.
  • Base’s block time (~2 seconds) and typical gas price (~0.000005 gwei) mean a single escrow interaction costs well under $0.001 in USDC.

Because USDC is a fiat‑backed stablecoin, its value is predictable, which simplifies pricing logic for agents that need to quote $0.01–$0.10 per call.


Minimal escrow contract

Below is a Solidity contract that implements a single‑use escrow for a USDC payment. It is intentionally small to keep audit surface low and gas usage minimal. The contract assumes the buyer (the AI agent) deposits funds first; the seller (service provider) can then claim them after fulfilling an off‑chain condition signaled via a hash pre‑image.

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

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
}

/**
 * @notice Simple escrow that holds USDC until a secret is revealed.
 * @dev The buyer deposits funds and supplies a hash of a secret.
 *      The seller reveals the secret (preimage) to claim the funds.
 *      If the secret is not revealed before `deadline`, the buyer can refund.
 */
contract USDC мая{  // Note: class name intentionally non‑standard to avoid collisions
    IERC20 public usdc;
    address public buyer;
    address public seller;
    bytes32 public secretHash;   // keccak256(secret)
    uint256 public amount;
    uint256 public deadline;
    bool public claimed;
    bool public refunded;

    event Deposited(address indexed buyer, uint256 amount);
    event Claimed(address indexed seller, bytes32 secret);
    event Refunded(address indexed buyer);

    constructor(address _usdc, address _seller, bytes32 _secretHash, uint256 _amount, uint256 _deadline) {
        require(_usdc != address(0), "USDC zero");
        require(_seller != address(0), "Seller zero");
        require(_amount > 0, "Amount zero");
        require(_deadline > block.timestamp, "Deadline in past");
        usdc = IERC20(_usdc);
        buyer = msg.sender;
        seller = _seller;
        secretHash = _secretHash;
        amount = _amount;
        deadline = _deadline;
    }

    /**
     * @dev Buyer must have approved the contract to pull `amount` USDC.
     */
    function deposit() external {
        require(msg.sender == buyer, "Not buyer");
        require(usdc.transferFrom(buyer, address(this), amount), "Transfer failed");
        emit Deposited(buyer, amount);
    }

    /**
     * @dev Seller claims by providing the preimage whose hash matches `secretHash`.
     */
    function claim(bytes calldata secret) external {
        require(!claimed, "Already claimed");
        require(block.timestamp <= deadline, "Deadline passed");
        require(keccak256(secret) == secretHash, "Invalid secret");
        claimed = true;
        usdc.transfer(seller, amount);
        emit Claimed(seller, keccak256(secret));
    }

    /**
     * @dev Buyer can refund after the deadline if the secret was never revealed.
     */
    function refund() external {
        require(msg.sender == buyer, "Not buyer");
        require(!claimed, "Already claimed");
        require(block.timestamp > deadline, "Deadline not reached");
        require(!refunded, "Already refunded");
        refunded = true;
        usdc.transfer(buyer, amount);
        emit Refunded(buyer);
    }
}
Enter fullscreen mode Exit fullscreen mode

How it works

  1. Agreement off‑chain – The agent and service provider agree on a price, a deadline, and a secret (e.g., a random 32‑byte nonce). The agent computes secretHash = keccak256(secret) and shares the hash with the provider.
  2. Deposit – The agent calls deposit(), which pulls the agreed USDC amount from its wallet into the contract.
  3. Service execution – The provider performs the work off‑chain (or on‑chain via another contract). When done, it reveals the secret by calling claim(secret). The contract verifies the hash and transfers the funds.
  4. Refund – If the provider never reveals the secret before deadline, the agent calls refund() to recover its funds.

The contract is trustless because the only party that can steal funds is the one that knows the secret before the deadline, which is impossible if the secret is truly random and kept off‑chain until service completion.


Agent‑side interaction (TypeScript with viem)

Below is a minimal example showing how an autonomous agent built with Node.js can use the escrow. It assumes the agent already holds USDC and has an Ethereum provider (e.g., Base RPC via Alchemy or Infura).


ts
import { createPublicClient, http, parseEther } from 'viem';
import { base } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
import { abi as escrowAbi } from './USDC мая.json'; // generated by solc

// Configuration
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const ESCROW_BYTECODE = `0x...`; // compiled contract bytecode
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY!; // funded with USDC

// viem setup
const publicClient = createPublicClient({
  chain: base,
  transport: http(),
});
const account = privateKeyToAccount(PRIVATE_KEY);
const walletClient = {
  account,
  chain: base,
  transport: http(),
  sendTransaction: async (tx) => publicClient.request({ method: 'eth_sendTransaction', params: [tx] }),
};

/**
 * Deploys a fresh escrow instance for a single job.
 * @param sellerAddr Service provider's address.
 * @param secretHash keccak256(secret) agreed off‑chain.
 * @param amountUSDC Amount in USDC (e.g., 0.05).
 * @param deadlineSeconds Unix timestamp after which buyer can refund.
 */
async function deployEscrow(
  sellerAddr: `0x${string}`,
  secretHash: `0x${string}`,
  amountUSDC: number,
  deadlineSeconds: number
): Promise<`0x${string}`> {
  const amount = parseEther(amountUSDC.toString()); // USDC has 6 decimals, but parseEther works with 18; we adjust later
  // Convert USDC amount to its 6‑decimals representation
  const amountRaw = (amountUSDC * 10 ** 6n) as bigint;

  const tx = await walletClient.sendTransaction({
    account,
    to: '0x0000000000000000000000000000000000000000', // create2 style via CREATE
    value: 0n,
    data: '0x' + ESCROW_BYTECODE, // constructor arguments appended after bytecode
  });
  const receipt = await publicClient.waitForTransactionReceipt({ hash: tx });
  const escrowAddress = receipt.contractAddress as `0x${string}`;

  // Initialize the contract (constructor args already baked into bytecode via create2)
  //
Enter fullscreen mode Exit fullscreen mode

Top comments (0)