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 receive payment for verifiable work without relying on a centralized intermediary.


Why escrow matters for AI agents

When an AI agent offers a service—say, generating a summary, classifying an image, or executing a small workflow—it typically does so off‑chain. The consumer wants assurance they’ll pay only if the agent actually delivers the promised output, while the agent wants guarantee they’ll be paid for the work they performed. Traditional freelancing platforms solve this with reputation scores and dispute teams, but those mechanisms re‑introduce trust and custodial risk.

A trustless escrow flips the model: the payer locks funds in a smart contract that can only release them when the agent presents cryptographic proof of completion. If the agent never provides proof, the payer can reclaim the funds after a timeout. No custodial service, no KYC, no platform fee—just code and the underlying blockchain’s finality.

On Base (an Optimistic Rollup with low gas fees) and USDC (the ERC‑20 stablecoin pegged 1:1 to USD), the economics work out: a typical escrow interaction costs under $0.0005 in gas, while the payment itself can be as low as $0.01 per call.


Core design of the escrow contract

The contract must satisfy three simple properties:

  1. Deposit – the client sends USDC to the contract and locks it for a specific job ID.
  2. Proof submission – the agent provides a hash of the off‑chain result (e.g., an IPFS CID) plus a signature that proves they generated it.
  3. Release or refund – if a valid proof is submitted before the deadline, the contract transfers the USDC to the agent; otherwise the client can withdraw after the timeout.

We keep the contract deliberately minimal to avoid upgradeability risks and to keep audit surface small. Below is a Solidity ^0.8.20 implementation that uses OpenZeppelin’s ERC20 wrapper for safe token handling.

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

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

contract USDCoucheEscrow {
    using SafeERC20 for IERC20;

    struct Job {
        address payer;
        address agent;
        uint256 amount;      // USDC amount (6 decimals)
        uint256 deadline;    // block.timestamp
        bytes32 proofHash;   // keccak256(off‑chain result)
        bool   released;
        bool   refunded;
    }

    // Mapping from a job identifier (chosen off‑chain) to its state
    mapping(bytes32 => Job) public jobs;

    IERC20 public immutable usdc; // USDC on Base: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
    address public immutable feeCollector; // optional, can be address(0)

    event JobCreated(bytes32 indexed jobId, address payer, address agent, uint256 amount, uint256 deadline);
    event ProofSubmitted(bytes32 indexed jobId, bytes32 proofHash);
    event Released(bytes32 indexed jobId, address agent);
    event Refunded(bytes32 indexed jobId, address payer);

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

    /// @notice Client creates a job and escrows USDC
    /// @dev The caller must first approve the contract to spend `amount` USDC.
    function createJob(
        bytes32 jobId,
        address agent,
        uint256 amount,          // USDC amount (6 decimals)
        uint256 durationSeconds  // how long the agent has to submit proof
    ) external {
        require(jobs[jobId].payer == address(0), "Job exists");
        require(agent != address(0), "Zero agent");
        require(amount > 0, "Zero amount");
        usdc.safeTransferFrom(msg.sender, address(this), amount);
        jobs[jobId] = Job({
            payer:      msg.sender,
            agent:      agent,
            amount:     amount,
            deadline:   block.timestamp + durationSeconds,
            proofHash:  bytes32(0),
            released:   false,
            refunded:   false
        });
        emit JobCreated(jobId, msg.sender, agent, amount, jobs[jobId].deadline);
    }

    /// @notice Agent submits proof of work
    /// @dev proofHash should be keccak256 of the off‑chain artifact (e.g., IPFS CID).
    /// The agent must also sign the jobId off‑chain; verification is done caller‑side.
    function submitProof(bytes32 jobId, bytes32 proofHash) external {
        Job storage j = jobs[jobId];
        require(j.payer != address(0), "No such job");
        require(msg.sender == j.agent, "Not the agent");
        require(!j.released && !j.refunded, "Job already settled");
        require(block.timestamp <= j.deadline, "Deadline passed");
        j.proofHash = proofHash;
        emit ProofSubmitted(jobId, proofHash);
        _release(jobId);
    }

    /// @notice Internal release; called by submitProof or by anyone after timeout.
    function _release(bytes32 jobId) internal {
        Job storage j = jobs[jobId];
        require(!j.released && !j.refunded, "Already settled");
        // In a stricter design you would verify the agent's signature here on‑chain.
        // For simplicity we trust the off‑chain verification; the contract only
        // enforces that a hash was posted before the deadline.
        j.released = true;
        usdc.safeTransfer(j.agent, j.amount);
        // Optional fee (e.g., 0.1% to a treasury)
        if (feeCollector != address(0)) {
            uint256 fee = (j.amount * 1) / 1000; // 0.1%
            usdc.safeTransfer(feeCollector, fee);
            j.amount -= fee;
            usdc.safeTransfer(j.agent, j.amount);
        }
        emit Released(jobId, j.agent);
    }

    /// @notice Client refunds after deadline if no proof was submitted.
    function refund(bytes32 jobId) external {
        Job storage j = jobs[jobId];
        require(j.payer != address(0), "No such job");
        require(msg.sender == j.payer, "Not the payer");
        require(!j.released && !j.refunded, "Already settled");
        require(block.timestamp > j.deadline, "Still within deadline");
        j.refunded = true;
        usdc.safeTransfer(j.payer, j.amount);
        emit Refunded(jobId, j.payer);
    }

    /// @notice Helper to read escrowed amount for UI.
    function escrowed(bytes32 jobId) external view returns (uint256) {
        return jobs[jobId].amount;
    }
}
Enter fullscreen mode Exit fullscreen mode

How the contract works in practice

  1. Approval – Before calling createJob, the client runs usdc.approve(address(escrow), amount) (ERC‑20 allowance).
  2. Job ID – A random bytes32 (e.g., keccak256(abi.encodePacked(nonce, clientAddress))) guarantees uniqueness without on‑chain storage of a counter.
  3. Proof – The agent computes proofHash = keccak256(ipfsCid) after storing the result on IPFS (or any content‑addressable store). The agent also signs jobId with its private key; the client verifies the signature off‑chain before considering the job complete.
  4. Release – Once submitProof is tx‑confirmed, the contract instantly transfers USDC to the agent. If the deadline passes, the client can call refund.

The contract is non‑custodial: funds never leave the escrow unless the coded conditions are met. The only trust assumption is that the off‑chain proof verification (signature + hash) is performed correctly by the payer before they consider the job done. If the agent submits a bogus hash, the payer can simply not call refund and treat the job as incomplete; the funds

Top comments (0)