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 without relying on a central intermediary.


Why escrow matters for agent‑to‑agent payments

When an AI agent offers a compute‑heavy service (e.g., LLM inference, data labeling, or signal processing) it usually wants payment before expending resources. Conversely, a buyer wants assurance that the service will be delivered as agreed. In a world where agents operate 24/7 and may be owned by different parties, a simple “pay‑then‑hope” model creates counterparty risk that can quickly erode trust.

An escrow contract solves this by holding the buyer’s funds in a neutral smart contract until predefined conditions are met. The contract is trustless because its code, not a third party, enforces the release logic. When the escrow token is a stablecoin like USDC, the value remains stable across chains, making accounting straightforward for both humans and agents.


Contract design overview

We’ll implement a minimal escrow contract on Base (an Ethereum L2) that:

  1. Accepts USDC (ERC‑20) deposits from a buyer.
  2. Allows the seller (agent) to submit a proof that the service was completed.
  3. Releases the funds to the seller after a configurable challenge period, unless the buyer disputes the proof.
  4. Returns the funds to the buyer if the challenge period expires without a valid proof.

The contract deliberately avoids complex oracle logic; instead, the proof is a hash commitment that the buyer can verify off‑chain. This keeps gas costs low and moves the heavy verification to the agent’s own environment.

Solidity (Solidity ^0.8.20)

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

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

contract USDC"Escrow is Ownable {
    IERC20 public immutable usdc;          // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
    address public buyer;
    address public seller;
    uint256 public amount;                // escrowed USDC (6 decimals)
    bytes32 public commitment;            // keccak256(off‑chain result || nonce)
    uint256 public depositTime;
    uint256 public challengePeriod;       // seconds, set at deployment

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

    event Deposited(address indexed buyer, uint256 amount);
    event CommitmentSubmitted(address indexed seller, bytes32 commitment);
    event Challenged(address indexed challenger);
    event Released(address indexed seller, uint256 amount);
    event Refunded(address indexed buyer, uint256 amount);

    constructor(address _usdc, uint256 _challengePeriod) Ownable(msg.sender) {
        require(_usdc != address(0), "USDC address zero");
        usdc = IERC20(_usdc);
        challengePeriod = _challengePeriod;
    }

    /// @notice Buyer funds the escrow. Must be called after deployment.
    function deposit() external payable {
        require(state == State.Created, "not created");
        require(msg.value == 0, "send USDC via ERC20 transfer, not ETH");
        uint256 escrowAmount = usdc.balanceOf(address(this));
        require(escrowAmount == 0, "already funded");
        // buyer must have approved the contract to pull USDC
        usdc.transferFrom(msg.sender, address(this), amount);
        buyer = msg.sender;
        state = State.Funded;
        depositTime = block.timestamp;
        emit Deposited(msg.sender, amount);
    }

    /// @notice Seller sets amount and commits to a future result.
    /// The buyer must have approved the escrow to pull USDC before calling deposit.
    function setTermsAndDeposit(uint256 _amount) external {
        require(state == State.Created, "invalid state");
        require(msg.sender == buyer, "only buyer");
        amount = _amount;
        // buyer approves the contract to pull their USDC
        usdc.approve(address(this), amount);
        deposit();
    }

    /// @notice Seller submits a hash commitment of the off‑chain result.
    /// The commitment hides the actual output but binds the seller to it.
    function submitCommitment(bytes32 _commitment) external {
        require(state == State.Funded, "not funded");
        require(msg.sender == seller, "only seller");
        commitment = _commitment;
        state = State.Challenged;
        emit CommitmentSubmitted(msg.sender, commitment);
    }

    /// @notice Buyer can challenge if they think the commitment is invalid.
    /// They must provide the pre‑image (result + nonce) that hashes to commitment.
    function challenge(bytes calldata preimage) external {
        require(state == State.Challenged, "no commitment to challenge");
        require(msg.sender == buyer, "only buyer can challenge");
        require(keccak256(preimage) == commitment, "invalid preimage");
        // If the preimage reveals a result that does NOT meet the agreed spec,
        // the buyer can call refund() after the challenge period.
        // For simplicity, we treat any valid preimage as a buyer win.
        state = State.Refunded;
        emit Challenged(msg.sender);
    }

    /// @notice Seller releases funds after challenge period expires without a successful challenge.
    function release() external {
        require(state == State.Challenged, "not in challenge period");
        require(block.timestamp >= depositTime + challengePeriod, "challenge period not over");
        require(msg.sender == seller, "only seller");
        state = State.Released;
        usdc.transfer(seller, amount);
        emit Released(seller, amount);
    }

    /// @notice Buyer refunds funds if they successfully challenged or if the seller never committed.
    function refund() external {
        require(state == State.Challenged || state == State.Funded, "invalid state");
        require(block.timestamp >= depositTime + challengePeriod, "challenge period not over");
        require(msg.sender == buyer, "only buyer");
        state = State.Refunded;
        usdc.transfer(buyer, amount);
        emit Refunded(buyer, amount);
    }

    /// @notice Allow the owner to reclaim stuck USDC (emergency only).
    function rescueUSDC() external onlyOwner {
        uint256 balance = usdc.balanceOf(address(this));
        require(balance > 0, "nothing to rescue");
        usdc.transfer(owner(), balance);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The escrow holds USDC via ERC‑20 transferFrom. The buyer must approve the contract before calling deposit() (or we combine approval and deposit in setTermsAndDeposit).
  • The seller’s proof is a hash commitment (keccak256(off‑chain result || nonce)). This hides the actual result on‑chain, saving gas, while still binding the seller.
  • A challenge period (e.g., 1 hour) gives the buyer time to submit a pre‑image that either proves the seller’s result is invalid or simply reveals the pre‑image to claim a refund if the seller never committed.
  • If no challenge succeeds, the seller calls release() and receives the funds.

Client (buyer) workflow – TypeScript / viem


ts
import { createPublicClient, http, parseAbi } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { base } from 'viem/chains';
import { encodeFunctionData } from 'viem';
import USDC_ABI from './usdc-abi.json'; // standard ERC20 ABI
import ESCROW_ABI from './escrow-abi.json'; // the contract above

const rpc = 'https://mainnet.base.org';
const publicClient = createPublicClient({ chain: base, transport: http(rpc) });

// Addresses (Base mainnet)
const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const ESCROW = '0xYourEscrowAddressHere'; // deployed with 1‑hour challenge period

const buyerKey = '0xbuyerPrivateKey';
const sellerKey = '0xsellerPrivateKey';
const buyerAcct = privateKeyToAccount(buyerKey);
const sellerAcct = privateKeyToAccount(sellerKey);

// Helper to send a transaction
async function send(tx) {
  const hash = await buyerAcct.signTransaction({ ...tx, chainId: base.id });
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
  return receipt;
}

// 1. Buyer approves escrow to pull USDC
async function approveUSDC(amount) {
  const usdcContract = {
    address: USDC as `0x${string}`,
    abi: USDC_ABI,
  };
  const data = encodeFunctionData({
    abi: usdcContract.abi,
    functionName: 'approve',
    args: [ESCROW, amount],
  });
  await send({
    to: USDC,
    data,
    value: 0n,
  });
}

//
Enter fullscreen mode Exit fullscreen mode

Top comments (0)