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 who are building autonomous AI agents that need to pay (or get paid) for services without relying on a custodial intermediary.


Why escrow matters for agent‑to‑agent payments

AI agents today can call APIs, run ML inference, or execute smart‑contract transactions autonomously. When two agents agree to exchange a service—say, one agent runs a data‑labeling job and another returns the labeled dataset—they need a payment mechanism that guarantees:

  1. Atomicity – the payer only loses funds if the service is provably delivered.
  2. Non‑custodial – no third party holds the money; the agents themselves control the escrow.
  3. Deterministic settlement – the outcome can be verified on‑chain (or via a trusted off‑chain proof) so that disputes are minimized.

USDC on Base (an Optimistic Rollup anchored to Ethereum) satisfies the first two points: it’s a widely‑used, ERC‑20‑compliant stablecoin with low transaction fees (~$0.001 per transfer). Adding a simple escrow layer gives us the third point without introducing complex dispute‑resolution mechanisms.


System overview

+----------------+        +----------------+        +----------------+
|   Agent A      |  <---> |  Escrow Contract| <--> |   Agent B      |
| (payer)        |  USDC  | (holds USDC)    |  USDC  | (service prov.)|
+----------------+        +----------------+        +----------------+
        ^                         ^                         ^
        |                         |                         |
   deposit & approve        verify service          claim payout
   (off‑chain signature)   (on‑chain or oracle)   (escrow.release)
Enter fullscreen mode Exit fullscreen mode
  1. Deposit – Agent A approves the escrow contract to pull USDC from its wallet and calls deposit(amount, serviceId).
  2. Service execution – Agent B performs the work off‑chain (or on‑chain) and produces a verifiable artifact (e.g., a Merkle root of labeled data, a signed ML model hash, or a transaction receipt).
  3. Verification – Either:
    • the escrow contract reads the artifact directly (if it’s on‑chain), or
    • an off‑chain oracle (or a trusted verifier) calls verifyProof(serviceId, proof) which updates an internal fulfilled[serviceId] flag.
  4. Payout – Agent B calls withdraw(serviceId). The contract checks that fulfilled[serviceId] == true and transfers the escrowed USDC to Agent B. If the service is not fulfilled within a timeout, Agent A can call refund(serviceId).

The contract is deliberately minimal: no upgradeability, no governance, and no external dependencies beyond the USDC token address.


The escrow contract (Solidity ^0.8.20)

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

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

/**
 * @notice Simple USDC escrow for agent‑to‑agent freelancing.
 * @dev Assumes USDC follows the standard ERC‑20 interface with 6 decimals.
 *      No reentrancy guard is needed because the only external call is
 *      a safe ERC‑20 transfer after all state changes.
 */
contract USdCEscrow {
    IERC20 public immutable usdc;          // USDC token on Base
    address public immutable owner;        // Deployer – can pause in emergencies

    struct Job {
        address payer;          // Agent that deposited funds
        address provider;       // Agent that will receive payout
        uint256 amount;         // USDC amount (6‑decimals)
        bool fulfilled;        // Set true when proof is verified
        uint256 deadline;      // Block.timestamp after which payer can refund
    }

    mapping(bytes32 => Job) public jobs;   // key = serviceId (hash of job description)

    event Deposit(bytes32 indexed serviceId, address payer, uint256 amount);
    event Fulfilled(bytes32 indexed serviceId, address provider);
    event Withdrawn(bytes32 indexed serviceId, address provider, uint256 amount);
    event Refunded(bytes32 indexed serviceId, address payer, uint256 amount);

    constructor(address _usdc) {
        require(_usdc != address(0), "Zero token address");
        usdc = IERC20(_usdc);
        owner = msg.sender;
    }

    /**
     * @notice Agent A deposits USDC for a particular service.
     * @dev The caller must have approved the contract to spend `amount` USDC.
     *      `serviceId` is typically keccak256(abi.encodePacked(jobDesc, nonce)).
     */
    function deposit(bytes32 serviceId, address provider, uint256 amount) external {
        require(amount > 0, "Zero amount");
        require(jobs[serviceId].amount == 0, "Job already exists");

        // Pull USDC from payer
        require(
            usdc.transferFrom(msg.sender, address(this), amount),
            "Transfer failed"
        );

        jobs[serviceId] = Job({
            payer: msg.sender,
            provider: provider,
            amount: amount,
            fulfilled: false,
            deadline: block.timestamp + 7 days   // configurable timeout
        });

        emit Deposit(serviceId, msg.sender, amount);
    }

    /**
     * @notice Off‑chain verifier (or oracle) signals that the service is done.
     * @dev Only callable by a trusted verifier address; in a fully trustless
     *      setup the verifier logic could be replaced by on‑chain data
     *      inspection (e.g., checking a Merkle root stored in calldata).
     */
    function fulfill(bytes32 serviceId) external {
        Job storage j = jobs[serviceId];
        require(j.amount > 0, "No such job");
        require(!j.fulfilled, "Already fulfilled");
        require(msg.sender == owner, "Only verifier"); // replace with your verifier logic

        j.fulfilled = true;
        emit Fulfilled(serviceId, j.provider);
    }

    /**
     * @notice Provider withdraws payment after fulfillment.
     * @dev Reverts if the job is not yet fulfilled or if the deadline passed
     *      (the latter allows the payer to refund first).
     */
    function withdraw(bytes32 serviceId) external {
        Job storage j = jobs[serviceId];
        require(j.amount > 0, "No such job");
        require(j.fulfilled, "Service not fulfilled");
        require(
            !j.fulfilled || block.timestamp <= j.deadline,
            "Can't withdraw after deadline"
        );

        j.amount = 0; // zero out to prevent re‑entrancy
        usdc.transfer(j.provider, j.amount);
        emit Withdrawn(serviceId, j.provider, j.amount);
    }

    /**
     * @notice Payer reclaims funds if the provider never fulfills before deadline.
     */
    function refund(bytes32 serviceId) external {
        Job storage j = jobs[serviceId];
        require(j.amount > 0, "No such job");
        require(block.timestamp > j.deadline, "Deadline not passed");
        require(!j.fulfilled, "Already fulfilled; provider should withdraw");

        j.amount = 0;
        usdc.transfer(j.payer, j.amount);
        emit Refunded(serviceId, j.payer, j.amount);
    }

    /* Optional: allow owner to pause contract in case of emergency */
    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }
    function pause() external onlyOwner { /* implement via OpenZeppelin Pausable if needed */ }
    function unpause() external onlyOwner { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Key points in the code

  • The contract holds USDC via transferFrom; the payer must first approve the contract.
  • fulfilled is set by a trusted verifier (owner in the example). In a production system you’d replace that check with an on‑chain proof verification (e.g., validating a zk‑SNARK or checking a Merkle root against data stored on IPFS/Filecoin).
  • No external calls happen before state updates, eliminating classic re‑entrancy risks. The only external call (usdc.transfer) occurs after the job’s amount is zeroed out.
  • The timeout (deadline) is hard‑coded to 7 days; adjust based on expected service latency.

Interacting from an agent (TypeScript + ethers.js)

Below is a minimal snippet that an AI agent could run in a Node.js environment to deposit, wait for fulfillment, and withdraw. Error handling is kept explicit

Top comments (0)