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 and sell services without a trusted intermediary.


1. Why escrow matters for AI agents

Autonomous agents often operate in a loop: they request a capability (e.g., “summarize this PDF”, “run a SQL query”, “generate a thumbnail”), receive a result, and then decide what to do next. If the agent must pay for each capability, two problems arise:

  1. Counterparty risk – a malicious service could take payment and never return the promised output.
  2. Atomicity – the agent cannot safely commit funds before knowing the work will be done; likewise, the service cannot safely start work without assurance of payment.

An escrow contract solves both by holding funds in a neutral smart contract until predefined conditions are met. The agent deposits USDC, the service performs the work, and the contract releases the funds only when the agent signs off on a verifiable proof of completion.


2. High‑level flow

+----------------+          1. Deposit          +-----------------+
|   AI Agent     | ---------------------------> |  Escrow (USDC)  |
+----------------+                              +-----------------+
        ^                                           |
        | 2. Request + signed proof               | 3. Release on
        |    (off‑chain)                          |    agent signature
        |                                           v
+----------------+          4. Withdraw        +-----------------+
| Service Agent  | <-------------------------- |  Escrow (USDC)  |
+----------------+                              +-----------------+
Enter fullscreen mode Exit fullscreen mode

Step 1 – The agent locks USDC in the escrow contract, specifying the maximum price and a unique jobId.

Step 2 – The agent calls the service’s off‑chain API, includes the jobId and a cryptographic nonce. The service performs the work and returns a signed receipt (e.g., EIP‑712 signed hash of the output).

Step 3 – The agent forwards the receipt to the escrow contract. If the signature validates against the service’s known public key and the output matches the agreed‑upon specification, the contract transfers the locked USDC to the service.

Step 4 – The service can withdraw the funds at any time after the release.

If the agent disputes the receipt (e.g., output is garbage or missing), they can call a dispute function within a challenge period; the contract then reverts the funds to the agent after a timeout.


3. Why USDC on Base?

  • Low transaction fees (~$0.0001) make micro‑payments viable.
  • USDC is a regulated stablecoin with 1:1 USD backing, reducing price volatility risk for both parties.
  • Base’s EVM compatibility lets us reuse existing Solidity tooling.

3. Contract design – a minimal escrow

Below is a Solidity ^0.8.20 implementation that fits the flow above. It is deliberately simple: no upgradeability, no governance, just the core escrow logic. Feel free to extend it with timelocks, multi‑sig admins, or integration with a payment‑rail like x402.


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

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

/**
 * @title USDC Escrow for AI Agent freelancing
 * @notice Holds USDC until a signed receipt from a service provider is verified.
 * @dev Assumes the service's public key is known off‑chain and supplied via `setServiceKey`.
 */
contract AgentEscrow is Ownable, ReentrancyGuard {
    IERC20 public immutable usdc; // USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)

    struct Job {
        address agent;          // who deposited
        address service;        // who will receive payment
        uint256 amount;         // USDC amount locked (wei)
        uint256 deadline;       // block.timestamp after which agent can reclaim
        bytes32 jobId;          // unique identifier supplied by agent
        bool   released;        // payment already sent?
        bool   disputed;        // agent opened a dispute?
    }

    mapping(bytes32 => Job) public jobs;

    // Service's ECDSA public key (x, y) for verifying off‑chain signatures.
    uint256 public serviceKeyX;
    uint256 public serviceKeyY;

    event Deposit(bytes32 indexed jobId, address indexed agent, uint256 amount);
    event Release(bytes32 indexed jobId, address indexed service, uint256 amount);
    event DisputeOpened(bytes32 indexed jobId, address indexed agent);
    event DisputeResolved(bytes32 indexed jobId, bool refunded); // true = agent got funds back

    constructor(address _usdc) {
        usdc = IERC20(_usdc);
    }

    /* ------------------------------------------------------------------ */
    /* Admin helpers – set the service’s verification key */
    /* ------------------------------------------------------------------ */
    function setServiceKey(uint256 _x, uint256 _y) external onlyOwner {
        serviceKeyX = _x;
        serviceKeyY = _y;
    }

    /* ------------------------------------------------------------------ */
    /* 1️⃣ Agent deposits USDC for a job */
    /* ------------------------------------------------------------------ */
    function deposit(
        bytes32 jobId,
        address service,
        uint256 amount,
        uint256 challengePeriod // seconds the agent has to dispute after release
    ) external nonReentrant {
        require(amount > 0, "zero deposit");
        require(usdc.transferFrom(msg.sender, address(this), amount), "USDC transfer failed");

        jobs[jobId] = Job({
            agent:    msg.sender,
            service:  service,
            amount:   amount,
            deadline: block.timestamp + challengePeriod,
            jobId:    jobId,
            released: false,
            disputed: false
        });

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

    /* ------------------------------------------------------------------ */
    /* 2️⃣ Service submits a signed receipt; agent calls release */
    /* ------------------------------------------------------------------ */
    /**
     * @dev The agent must call this after receiving the signed receipt.
     *      `receipt` is an EIP‑712 signed message: keccak256("\x19\x01"
     *      || DOMAIN_SEPARATOR || hashStruct(jobId, outputHash)).
     *      The contract recovers the signer and checks it matches the service key.
     */
    function release(
        bytes32 jobId,
        bytes32 outputHash,          // keccak256 of the service's actual output
        uint8 v, bytes32 r, bytes32 s // ECDSA signature components
    ) external nonReentrant {
        Job storage job = jobs[jobId];
        require(!job.released, "already released");
        require(!job.disputed, "job under dispute");
        require(block.timestamp <= job.deadline, "challenge period expired");

        // Reconstruct the signed message per EIP‑712
        bytes32 structHash = keccak256(abi.encodePacked(jobId, outputHash));
        bytes32 eip191Header = keccak256(abi.encodePacked(
            "\x19\x01",
            keccak256(abi.encodePacked(
                keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
                keccak256(bytes("AgentEscrow")),
                keccak256(bytes("1")),
                keccak256(abi.encode(address(this)))
            )),
            structHash
        ));

        address recovered = ecrecover(eip191Header, v, r, s);
        require(recovered != address(0), "invalid signature");
        // Compare recovered address to the service's known key (derived from x,y)
        address serviceKey = address(uint160(uint256(keccak256(abi.encodePacked(serviceKeyX, serviceKeyY)))));
        require(recovered == serviceKey, "signature not from service");
        require(recovered == job.service, "signature mismatches job service");

        // Release funds
        usdc.transfer(job.service, job.amount);
        job.released = true;
        emit Release(jobId, job.service, job.amount);
    }

    /* ------------------------------------------------------------------ */
    /* 3️⃣ Agent opens a dispute (if output is bad or missing) */
    /* ------------------------------------------------------------------ */
    function openDispute(bytes32 jobId) external nonReentrant {
        Job storage job = jobs[jobId];
        require(msg.sender == job.agent, "only agent can dispute");
        require(!job.released, "cannot dispute after release");
        require(!job.disputed, "already disputed");
        job.disputed = true;
        emit DisputeOpened(jobId, msg.sender);
    }

    /* ------------------------------------------------------------------ */
    /* 4️⃣ Agent reclaims funds after dispute period */
    /* ------------------------------------------------------------------ */
    function reclaim(bytes32 jobId) external nonReentrant {
        Job storage job = jobs[jobId];
        require(msg.sender == job.agent, "only agent can reclaim");
        require(job.disputed, "no dispute opened");
        require(block.timestamp >= job.deadline, "challenge period not over");
        usdc.transfer(job.agent, job.amount);
        job.disputed = false; // reset for clarity
        emit DisputeResolved(jobId, true);
    }

    /* ------------------------------------------------------------------ */
    /*
Enter fullscreen mode Exit fullscreen mode

Top comments (0)