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 a custodial intermediary.


Why escrow matters for AI‑to‑AI commerce

Autonomous agents can already call HTTP APIs, sign transactions, and read on‑chain data. What they cannot do reliably is trust a counterparty to pay after the work is done—or to deliver the work before receiving payment. In a world where agents spin up, execute a task, and shut down within seconds, any reliance on reputation systems or off‑chain invoicing introduces failure modes that are hard to recover from.

A trustless escrow solves this by locking the payer’s funds in a smart contract that only releases them when a verifiable condition is met (e.g., a signed receipt, a hash of the result, or an oracle attestation). The agent never needs to hold a custodial wallet; it only needs to sign a transaction that moves money into the escrow and later a transaction that pulls it out—both of which can be performed atomically with the service call.

The x402 protocol (a lightweight extension of HTTP 402 Payment Required) gives us a standard way to convey price, payment token, and a payment‑validation endpoint. When combined with a minimal escrow contract, x402 lets agents negotiate and settle payments in a single round‑trip HTTP exchange.


The escrow contract in practice

Below is a minimal, auditable escrow that works with any ERC‑20 token (we’ll use USDC on Base). It holds funds, releases them to the provider when the consumer supplies a valid receipt (a signed message), and can refund the consumer if the provider fails to respond.

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

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

contract USDC meant for x402 escrow is Ownable {
    IERC20 public immutable usdc;
    address public provider;   // set at deployment
    address public consumer;   // set when first deposit occurs
    uint256 public price;      // in USDC (6 decimals)

    // Emitted when escrow is funded, paid out, or refunded
    event Deposit(address indexed from, uint256 amount);
    event Payout(address indexed to, uint256 amount);
    event Refund(address indexed to, uint256 amount);

    constructor(address _usdc, address _provider, uint256 _price) {
        require(_usdc != address(0), "zero token");
        require(_provider != address(0), "zero provider");
        require(_price > 0, "zero price");
        usdc = IERC20(_usdc);
        provider = _provider;
        price = _price;
    }

    /// @notice Consumer funds the escrow. Must be called exactly once.
    function deposit() external {
        require(msg.sender != provider, "provider cannot deposit");
        require(consumer == address(0), "already deposited");
        uint256 amt = usdc.balanceOf(address(this));
        require(amt == 0, "already funded");
        // consumer must have approved usdc to spend `price`
        usdc.transferFrom(msg.sender, address(this), price);
        consumer = msg.sender;
        emit Deposit(msg.sender, price);
    }

    /// @notice Provider calls this after verifying a valid receipt.
    /// The receipt is a signature from the consumer over:
    ///   keccak256(abi.encodePacked(address(this), price, serviceId))
    function payout(bytes32 serviceId, bytes calleeSignature) external {
        require(msg.sender == provider, "only provider");
        require(consumer != address(0), "no deposit yet");

        // Recover consumer address from signature
        bytes32 hash = keccak256(
            abi.encodePacked(address(this), price, serviceId)
        );
        address recovered = ecrecover(hash, v, r, s); // we unpack signature below
        require(recovered == consumer, "invalid signature");

        // Transfer USDC to provider
        usdc.transfer(provider, price);
        emit Payout(provider, price);
        // Reset for next use (optional)
        consumer = address(0);
    }

    /// @notice Consumer can reclaim funds if provider never pays out.
    /// Callable after a timeout (handled off‑chain) or if both parties agree.
    function refund() external {
        require(msg.sender == consumer, "only consumer");
        require(provider != address(0), "provider not set");
        usdc.transfer(consumer, price);
        emit Refund(consumer, price);
        consumer = address(0);
    }

    // Helper to unpack signature (v,r,s) from calleeSignature (65 bytes)
    function _splitSignature(bytes memory sig)
        internal
        pure
        returns (uint8 v, bytes32 r, bytes32 s)
    {
        require(sig.length == 65, "invalid signature length");
        assembly {
            // first 32 bytes, after the length prefix
            r := mload(add(sig, 32))
            s := mload(add(sig, 64))
            v := byte(0, mload(add(sig, 96)))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The contract is ownerless after deployment—only the provider and consumer addresses matter.
  • Price is baked in at deployment (you can redeploy a new escrow per job if prices vary).
  • The provider must present a receipt signed by the consumer; this is the verifiable condition that triggers payment.
  • Refunds are possible but require off‑chain coordination (e.g., a timeout or mutual agreement).

Agent‑side workflow (code snippets)

We’ll use viem (the lightweight ethers‑compatible library) for brevity. The same logic works with ethers.js or web3.js.

1. Provider – expose an x402‑protected endpoint

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

const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base USDC
const ESCROW_ADDRESS = "0xYourEscrowHere"; // deployed as shown above
const PRICE_USDC = 005000n; // $0.005 = 5,000 USDC (6 decimals)
const PROVIDER_KEY = "0x..."; // provider’s private key

const account = privateKeyToAccount(PROVIDER_KEY);
const publicClient = createPublicClient({ chain: base, transport: http() });

// Helper: verify consumer signature over (escrow, price, serviceId)
function verifyReceipt(
  consumer: `0x${string}`,
  signature: `0x${string}`,
  serviceId: string
): boolean {
  const hash = keccak256(
    abi.encodePacked(
      ["address", "uint256", "string"],
      [ESCROW_ADDRESS, PRICE_USDC, serviceId]
    )
  );
  const { v, r, s } = splitSignature(signature);
  return recoverAddress({ hash, v, r, s }) === consumer;
}

// Express‑style handler (adapt to your framework)
async function handler(req, res) {
  // x402 header: `X-Payment-Required: usdc://<escrow>?price=5000`
  const paymentReq = req.headers["x-payment-required"];
  if (!paymentReq) {
    res.status(402).set(
      "X-Payment-Required",
      `usdc://${ESCROW_ADDRESS}?price=${PRICE_USDC}`
    );
    return res.end();
  }

  // Consumer must have already deposited (we trust the escrow contract)
  const { consumer, signature, serviceId } = req.body; // JSON payload
  if (
    !consumer ||
    !signature ||
    !verifyReceipt(consumer, signature, serviceId)
  ) {
    return res.status(401).json({ error: "invalid receipt" });
  }

  // Call escrow to release funds
  const escrowAbi = parseAbi([
    "function payout(bytes32 serviceId, bytes calldata signature)",
  ]);
  const { request } = await publicClient.simulateContract({
    address: ESCROW_ADDRESS,
    abi: escrowAbi,
    functionName: "payout",
    args: [keccak256(toHex(serviceId)), signature],
    account,
  });

  const hash = await publicClient.writeContract(request);
  await publicClient.waitForTransactionReceipt({ hash });

  // Now perform the actual work and return result
  const result = await doTheWork(serviceId);
  res.json({ result });
}
Enter fullscreen mode Exit fullscreen mode

The provider only needs to hold a private key to sign the escrow call; the actual USDC never touches their wallet until the escrow releases it.

2. Consumer (AI agent) – request and

Top comments (0)