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 sell or buy services without relying on a centralized intermediary.


Why an escrow contract?

When an AI agent offers a paid service (e.g., image generation, data enrichment, code review) the two parties face a classic chicken‑and‑egg problem:

Party Risk if paid up‑front Risk if paid after delivery
Client Agent may never deliver or deliver low‑quality work. Client may refuse to pay after receiving the result.
Agent Must trust the client to lock funds before doing work. Must trust the client to pay after seeing the output.

A simple ERC‑20 escrow removes the need for mutual trust by letting a neutral smart contract hold the USDC until a verifiable condition is met. The condition can be:

  • a signed attestation from the client that the work is satisfactory, or
  • a cryptographic proof (e.g., IPFS CID) that the agent posted the agreed artifact.

Both approaches keep the logic on‑chain, so the settlement is deterministic and censorship‑resistant on Base (a low‑cost Ethereum L2).


Core contract design

Below is a minimal, auditable escrow written in Solidity 0.8.20. It assumes the USDC contract on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) and uses the standard allow‑transfer pattern.

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

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

/**
 * @notice Simple USDC escrow for AI‑agent freelancing.
 * @dev 1. Client deposits USDC and calls `requestService`.
 *      2. Agent performs work off‑chain, posts proof (IPFS CID) and calls `fulfill`.
 *      3. Client confirms satisfaction via `confirm` (or agent can auto‑claim after a timeout).
 *      4. Either party can withdraw their share after the escrow settles.
 */
contract USDCeEscrow {
    IERC20 public immutable usdc;
    address public immutable client;
    address public agent; // set to zero until agent registers

    struct Job {
        uint256 amount;          // USDC amount locked (6 decimals)
        string   proofCID;       // IPFS CID of the deliverable
        bool     fulfilled;      // true after agent calls fulfill
        bool     confirmed;      // true after client calls confirm
        uint256  deadline;       // block.timestamp after which agent can claim
    }

    mapping(uint256 => Job) public jobs;
    uint256 public nextJobId;

    event JobRequested(uint256 indexed jobId, address indexed client, uint256 amount);
    event JobFulfilled(uint256 indexed jobId, string proofCID);
    event JobConfirmed(uint256 indexed jobId);
    event JobWithdrawn(uint256 indexed jobId, address to, uint256 amount);

    constructor(address _usdc, address _client) {
        require(_usdc != address(0), "zero USDC");
        usdc = IERC20(_usdc);
        client = _client;
    }

    /// @notice Client locks USDC for a new job.
    /// @param amount  Amount in USDC (6‑decimal units). Must approve escrow first.
    function requestService(uint256 amount) external {
        require(amount > 0, "zero amount");
        require(usdc.allowance(client, address(this)) >= amount, "insufficient allowance");
        require(usdc.transferFrom(client, address(this), amount), "transfer failed");

        uint256 id = nextJobId++;
        jobs[id] = Job({
            amount: amount,
            proofCID: "",
            fulfilled: false,
            confirmed: false,
            deadline: block.timestamp + 7 days   // one‑week grace period
        });
        emit JobRequested(id, client, amount);
    }

    /// @notice Agent submits proof of work.
    /// @param jobId   Identifier returned from requestService.
    /// @param cid     IPFS CID (or any off‑chain reference) of the deliverable.
    function fulfill(uint256 jobId, string calldata cid) external {
        Job storage j = jobs[jobId];
        require(j.agent != address(0) || msg.sender == agent, "not authorized");
        require(!j.fulfilled, "already fulfilled");
        require(block.timestamp <= j.deadline, "deadline passed");
        j.proofCID = cid;
        j.fulfilled = true;
        emit JobFulfilled(jobId, cid);
    }

    /// @notice Client signals satisfaction (optional). If not called, agent can claim after deadline.
    function confirm(uint256 jobId) external {
        require(msg.sender == client, "only client");
        Job storage j = jobs[jobId];
        require(j.fulfilled, "work not yet fulfilled");
        require(!j.confirmed, "already confirmed");
        j.confirmed = true;
        emit JobConfirmed(jobId);
    }

    /// @notice Withdraw funds according to the current state.
    /// - If client confirmed → agent gets full amount.
    /// - If deadline passed and not confirmed → agent can claim (penalty‑free for simplicity).
    /// - Otherwise client can refund.
    function withdraw(uint256 jobId) external {
        Job storage j = jobs[jobId];
        require(j.amount > 0, "nothing to withdraw");

        uint256 toSend;
        address payable recipient;

        if (j.confirmed) {
            toSend = j.amount;
            recipient = payable(agent);
        } else if (block.timestamp > j.deadline && !j.confirmed) {
            // Agent can claim after timeout; client could still call withdraw to get refund.
            // Here we let agent take the funds; a more sophisticated contract could split.
            toSend = j.amount;
            recipient = payable(agent);
        } else {
            // Client refund before confirmation or deadline.
            toSend = j.amount;
            recipient = payable(client);
        }

        j.amount = 0; // prevent re‑entry
        require(usdc.transfer(recipient, toSend), "USDC transfer failed");
        emit JobWithdrawn(jobId, recipient, toSend);
    }

    /// @notice Allows the agent to register after a job is created.
    /// Useful when the agent is discovered via a registry or marketplace.
    function setAgent(address _agent) external {
        require(msg.sender == client, "only client");
        require(agent == address(0), "agent already set");
        agent = _agent;
    }
}
Enter fullscreen mode Exit fullscreen mode

How the flow works in practice

  1. Client side (TypeScript / ethers.js)
import { ethers } from "ethers";
import escrowAbi from "./USDCeEscrow.json";

const provider = new ethers.JsonRpcProvider("https://mainnet.base.org");
const signer   = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
const usdcAddr = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const escrowAddr = "0xDeployedEscrowAddress";

const usdc = new ethers.Contract(usdcAddr, ["function approve(address,uint256)"], signer);
const escrow = new ethers.Contract(escrowAddr, escrowAbi, signer);

// 1️⃣ Approve escrow to pull USDC
const amount = ethers.parseUnits("5.0", 6); // $5 USDC (6 decimals)
await usdc.approve(escrowAddr, amount);

// 2️⃣ Request a job
const tx = await escrow.requestService(amount);
await tx.wait();
// tx.events[0].args.jobId gives you the identifier to share with the agent
Enter fullscreen mode Exit fullscreen mode
  1. Agent side (off‑chain work)

Perform the servicestore result on IPFSobtain CIDcall fulfill


js
const jobId = /* received from client */;
const cid   = await ipfs.add(JSON.stringify({ result: "..."})); // your payload
Enter fullscreen mode Exit fullscreen mode

Top comments (0)