ERC-8183 - Agentic Commerce

Created 2026-02-25
Status Draft
Category ERC
Type Standards Track
Authors
Requires

Abstract

This specification defines the Agentic Commerce Protocol: a job with escrowed budget, four states (Open → Funded → Submitted → Terminal), and an evaluator who alone may mark the job completed. The client funds the job; the provider submits work; the evaluator attests completion or rejection once submitted (or the evaluator rejects while Funded before submission, or the client rejects while Open, or the job expires and the client is refunded). Optional attestation reason (e.g. hash) on complete/reject enables audit and composition with reputation (e.g. ERC-8004).

Motivation

Many use cases need only: client locks funds, provider submits work, one attester (evaluator) signals "done" and triggers payment—or client rejects or timeout triggers refund. The Agentic Commerce Protocol specifies that minimal surface so implementations stay small and composable. The evaluator can be the client (e.g. evaluator = client at creation) when there is no third-party attester.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

State Machine

A job has exactly one of six states:

State Meaning
Open Created; budget not yet set or not yet funded. Client may set budget, then fund or reject.
Funded Budget escrowed. Provider may submit work; evaluator may reject. After expiredAt, anyone may trigger refund.
Submitted Provider has submitted work. Only evaluator may complete or reject. After expiredAt, anyone may trigger refund.
Completed Terminal. Escrow released to provider (minus optional platform fee).
Rejected Terminal. Escrow refunded to client.
Expired Terminal. Same as Rejected; escrow refunded to client.

Allowed transitions:

No other transitions are valid.

Roles

Job Data

Each job SHALL have at least:

Payment SHALL use a single ERC-20 token (global for the contract or specified at creation). Implementations MAY support a per-job token; the specification only requires one token per contract.

Optional provider (set later)

Jobs MAY be created without a provider by passing provider = address(0) to createJob. In that case the client SHALL set the provider later via setProvider(jobId, provider) before funding. This supports flows such as bidding or assignment after creation.

Core Functions

Attestation

Fees

Implementations MAY charge a platform fee (basis points) on Completed, paid to a configurable treasury. The specification does not require a fee. If present, fee SHALL be deducted only on completion (not on refund).

Hooks (OPTIONAL)

Implementations MAY support an optional hook contract per job to extend the core protocol without modifying it. The hook address is set at job creation (or address(0) for no hook) and stored on the job. A non‑hooked kernel that ignores the hook field (or always sets it to address(0)) is fully compliant with this specification; the reference AgenticCommerce contract follows this minimal pattern, while AgenticCommerceHooked is an extension that layers the hook callbacks on top of the same lifecycle.

A hook contract SHALL implement the IACPHook interface — just two functions:

interface IACPHook {
    function beforeAction(uint256 jobId, bytes4 selector, bytes calldata data) external;
    function afterAction(uint256 jobId, bytes4 selector, bytes calldata data) external;
}

The selector parameter identifies which core function is being called (e.g. the function selector for fund). The data parameter contains function-specific parameters encoded as bytes (see Data encoding below). The hook uses the selector to route internally:

function beforeAction(uint256 jobId, bytes4 selector, bytes calldata data) external {
    if (selector == FUND_SELECTOR) {
        // custom pre-fund logic using data (optParams)
    } else if (selector == COMPLETE_SELECTOR) {
        // custom pre-complete logic using data (reason, optParams)
    }
}

When a job has a hook set, the core contract SHALL call hook.beforeAction(...) and hook.afterAction(...) around each hookable function:

Core function Hookable
setProvider Yes
setBudget Yes
fund Yes
submit Yes
complete Yes
reject Yes
claimRefund No — permissionless safety mechanism, SHALL NOT be hookable

Data encoding

The data parameter passed to hooks contains the core function's parameters encoded as bytes. The encoding per selector:

Core function data encoding
setProvider abi.encode(address provider, bytes optParams)
setBudget abi.encode(uint256 amount, bytes optParams)
fund optParams (raw bytes)
submit abi.encode(bytes32 deliverable, bytes optParams)
complete abi.encode(bytes32 reason, bytes optParams)
reject abi.encode(bytes32 reason, bytes optParams)

Hook behaviour

Hook security

Convenience base contract (non-normative)

Implementations MAY provide a BaseACPHook that routes the generic beforeAction/afterAction calls to named virtual functions (e.g. _preFund, _postComplete) so hook developers only override what they need. This is NOT part of the standard — only IACPHook is normative.

Example use cases


Example 1 — Fund Transfer Hook (two-phase escrow)

Problem: A client hires an agent to convert/bridge/swap tokens (e.g. USDC → DAI). The client provides capital to the provider, who uses it to produce output tokens. The hook must ensure the provider deposits the output tokens before the job completes, then release them to the designated buyer.

Solution: A FundTransferHook that (a) stores a transfer commitment at setBudget, (b) forwards capital to the provider at fund, (c) pulls output tokens from the provider at submit, and (d) releases them to the buyer at complete.

Step 1  createJob
  Client  createJob(provider, evaluator, expiredAt, desc, hook=FundTransferHook)
  Job created (Open), hook address stored.

Step 2  setBudget
  Client  setBudget(jobId, serviceFee, optParams=abi.encode(buyer, transferAmount))
     hook.beforeAction: decode optParams, store {buyer, transferAmount} as commitment.
     core: job.budget = serviceFee

Step 3  fund
  Client approves: core contract for serviceFee, hook for transferAmount.
  Client  fund(jobId, serviceFee, "")
     hook.beforeAction: verify client approved hook for transferAmount. Revert if not.
     core: pull serviceFee into escrow, set Funded.
     hook.afterAction: pull transferAmount from client, forward to provider (capital).

Step 4  provider uses capital to produce output tokens

Step 5  submit
  Provider approves hook for transferAmount (output tokens).
  Provider  submit(jobId, deliverable, "")
     hook.beforeAction: pull transferAmount from provider into hook (escrow).
     core: set Submitted.

Step 6  complete
  Evaluator  complete(jobId, reason, "")
     core: release serviceFee to provider (minus platform fee).
     hook.afterAction: release transferAmount from hook to buyer.

Recovery:
  - reject: hook.afterAction returns escrowed tokens to provider (if deposited).
  - expiry: claimRefund (not hookable) refunds serviceFee to client.
    Provider calls recoverTokens(jobId) on hook to recover deposited tokens.

Key properties: (1) The provider cannot submit without depositing output tokens. (2) The buyer only receives tokens when the evaluator completes the job. (3) On rejection or expiry, tokens are returned to the provider.


Example 2 — Bidding Hook

Problem: A client wants to hire the cheapest (or best) agent for a job but does not know upfront who to assign. The selection should be determined by an open bidding process, not unilaterally by the client after the fact.

Solution: A BiddingHook that verifies off-chain signed bids. Providers sign bid commitments off-chain; the client collects bids, selects the winner, and submits the winning bid's signature via setProvider. The hook's beforeAction callback recovers the signer and verifies it matches the chosen provider — proving the provider actually committed to that price.

Zero direct calls to the hook. All interactions flow through the core contract → hook callbacks.

Step 1  createJob
  Client  createJob(provider=0, evaluator, expiredAt, desc, hook=BiddingHook)
  Job created (Open), provider = address(0).

Step 2  setBudget (opens bidding via hook callback)
  Client  setBudget(jobId, maxBudget, optParams=abi.encode(biddingDeadline))
     hook.beforeAction: store deadline for this jobId.

Step 3  bidding happens OFF-CHAIN
  Providers sign: keccak256(abi.encode(chainId, hookAddress, jobId, bidAmount))
  Client collects signed bids and selects the winner.
  Core contract is unaware of bids.

Step 4  setProvider + setBudget (hook verifies winning bid signature and enforces budget)
  Client  setProvider(jobId, winnerAddress, optParams=abi.encode(bidAmount, signature))
     hook.beforeAction: verify deadline passed, recover signer from signature,
      validate signer == provider, store committed bidAmount. Revert if invalid.
     core: job.provider = winnerAddress
     hook.afterAction: mark bidding finalised (no further setProvider possible).
  Client  setBudget(jobId, bidAmount, "")
     hook.beforeAction: enforce budget == committedAmount. Revert if mismatch.

Step 5  job continues normally
  Client  fund(jobId, bidAmount, "")
  Provider  submit(jobId, deliverable, "")
  Evaluator  complete(jobId, reason, "")

Key property: The client cannot fabricate a provider commitment. The hook verifies the chosen provider actually signed a bid at the claimed price. The client is incentivised to pick the lowest bidder since they are the one paying.


Events

Implementations SHOULD emit at least:

Rationale

Extensions (OPTIONAL)

The following extensions are OPTIONAL and do not modify the core protocol. Implementations MAY adopt them independently.

Reputation / Attestation Interop (ERC-8004)

Agentic Commerce is intentionally minimal and does not embed a reputation system. For on-chain reputation and trust relationships between agents, implementations are RECOMMENDED to integrate with ERC-8004 (Trustless Agents).

The following patterns are RECOMMENDED:


Meta-Transactions / Facilitator Relay (ERC-2771)

To support gasless execution — where a client, provider, or evaluator signs an intent off-chain and a facilitator submits the transaction on their behalf — implementations SHOULD support ERC-2771 (Secure Protocol for Native Meta Transactions).

How it works:

  1. A participant (client, provider, or evaluator) signs a meta-transaction off-chain (e.g. createJob, fund, submit).
  2. A facilitator submits the signed payload to a trusted forwarder contract.
  3. The forwarder verifies the signature and calls the ACP contract, appending the original signer's address.
  4. The ACP contract uses _msgSender() (from ERC2771Context) instead of msg.sender to identify the caller.

Implementation requirements:

import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";

contract AgenticCommerce is ERC2771Context, ... {
    constructor(address trustedForwarder, ...)
        ERC2771Context(trustedForwarder) { ... }

    // Example: fund() using _msgSender() instead of msg.sender
    function fund(uint256 jobId, uint256 expectedBudget) external {
        Job storage job = jobs[jobId];
        if (_msgSender() != job.client) revert Unauthorized();
        if (job.budget != expectedBudget) revert BudgetMismatch();
        // ...
    }
}

Token approvals: For functions that pull tokens (e.g. fund), the signer SHOULD use ERC-2612 (permit) to approve token spending via signature. The facilitator can then call permit and fund in a single transaction — no on-chain approval tx needed from the signer.

x402 compatibility: This extension enables compatibility with HTTP-native payment protocols such as x402, where an AI agent signs payment intents off-chain and a payment facilitator handles on-chain execution. The agent only needs a private key and tokens — no gas, no RPC management, no chain-specific logic.


Backwards Compatibility

No backward compatibility issues found.

Reference Implementation

The reference implementation consists of two contracts: IACPHook, the optional and minimal hook interface that developers implement, and AgenticCommerce, the core Job primitive with escrow and optional hook extension points.

IACPHook.sol

pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/introspection/IERC165.sol";

interface IACPHook is IERC165 {
    function beforeAction(uint256 jobId, bytes4 selector, bytes calldata data) external;
    function afterAction(uint256 jobId, bytes4 selector, bytes calldata data) external;
}

AgenticCommerce.sol

pragma solidity ^0.8.28;

import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
import "./IACPHook.sol";
import "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";

contract AgenticCommerce is Initializable, AccessControlUpgradeable, ReentrancyGuardTransient, UUPSUpgradeable {
    using SafeERC20 for IERC20;

    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");

    enum JobStatus {
        Open,
        Funded,
        Submitted,
        Completed,
        Rejected,
        Expired
    }

    struct Job {
        uint256 id;
        address client;
        address provider;
        address evaluator;
        string description;
        uint256 budget;
        uint256 expiredAt;
        JobStatus status;
        address hook;
    }

    IERC20 public paymentToken;
    uint256 public platformFeeBP;
    address public platformTreasury;
    uint256 public evaluatorFeeBP;

    mapping(uint256 => Job) public jobs;
    uint256 public jobCounter;
    mapping(address => bool) public whitelistedHooks;
    mapping(uint256 jobId => bool hasBudget) public jobHasBudget;

    event JobCreated(
        uint256 indexed jobId, address indexed client, address indexed provider,
        address evaluator, uint256 expiredAt, address hook
    );
    event ProviderSet(uint256 indexed jobId, address indexed provider);
    event BudgetSet(uint256 indexed jobId, uint256 amount);
    event JobFunded(uint256 indexed jobId, address indexed client, uint256 amount);
    event JobSubmitted(uint256 indexed jobId, address indexed provider, bytes32 deliverable);
    event JobCompleted(uint256 indexed jobId, address indexed evaluator, bytes32 reason);
    event JobRejected(uint256 indexed jobId, address indexed rejector, bytes32 reason);
    event JobExpired(uint256 indexed jobId);
    event PaymentReleased(uint256 indexed jobId, address indexed provider, uint256 amount);
    event EvaluatorFeePaid(uint256 indexed jobId, address indexed evaluator, uint256 amount);
    event Refunded(uint256 indexed jobId, address indexed client, uint256 amount);
    event HookWhitelistUpdated(address indexed hook, bool status);

    error InvalidJob();
    error WrongStatus();
    error Unauthorized();
    error ZeroAddress();
    error ExpiryTooShort();
    error ZeroBudget();
    error ProviderNotSet();
    error FeesTooHigh();
    error HookNotWhitelisted();

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address paymentToken_, address treasury_) public initializer {
        if (paymentToken_ == address(0) || treasury_ == address(0))
            revert ZeroAddress();

        __AccessControl_init();

        paymentToken = IERC20(paymentToken_);
        platformTreasury = treasury_;
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(ADMIN_ROLE, msg.sender);
        whitelistedHooks[address(0)] = true;
    }

    function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {}

    // ──────────────────── Admin ────────────────────

    function setPlatformFee(uint256 feeBP_, address treasury_) external onlyRole(ADMIN_ROLE) {
        if (treasury_ == address(0)) revert ZeroAddress();
        if (feeBP_ + evaluatorFeeBP > 10000) revert FeesTooHigh();
        platformFeeBP = feeBP_;
        platformTreasury = treasury_;
    }

    function setEvaluatorFee(uint256 feeBP_) external onlyRole(ADMIN_ROLE) {
        if (feeBP_ + platformFeeBP > 10000) revert FeesTooHigh();
        evaluatorFeeBP = feeBP_;
    }

    function setHookWhitelist(address hook, bool status) external onlyRole(ADMIN_ROLE) {
        if (hook == address(0)) revert ZeroAddress();
        whitelistedHooks[hook] = status;
        emit HookWhitelistUpdated(hook, status);
    }

    // ──────────────────── Hook Helpers ────────────────────

    function _beforeHook(address hook, uint256 jobId, bytes4 selector, bytes memory data) internal {
        if (hook != address(0)) {
            IACPHook(hook).beforeAction(jobId, selector, data);
        }
    }

    function _afterHook(address hook, uint256 jobId, bytes4 selector, bytes memory data) internal {
        if (hook != address(0)) {
            IACPHook(hook).afterAction(jobId, selector, data);
        }
    }

    // ──────────────────── Job Lifecycle ────────────────────

    function createJob(
        address provider, address evaluator, uint256 expiredAt,
        string calldata description, address hook
    ) external nonReentrant returns (uint256) {
        if (evaluator == address(0)) revert ZeroAddress();
        if (expiredAt <= block.timestamp + 5 minutes) revert ExpiryTooShort();
        if (!whitelistedHooks[hook]) revert HookNotWhitelisted();
        if (hook != address(0)) {
            if (!ERC165Checker.supportsInterface(hook, type(IACPHook).interfaceId))
                revert InvalidJob();
        }

        uint256 jobId = ++jobCounter;
        jobs[jobId] = Job({
            id: jobId,
            client: msg.sender,
            provider: provider,
            evaluator: evaluator,
            description: description,
            budget: 0,
            expiredAt: expiredAt,
            status: JobStatus.Open,
            hook: hook
        });

        emit JobCreated(jobId, msg.sender, provider, evaluator, expiredAt, hook);
        _afterHook(hook, jobId, msg.sig, abi.encode(msg.sender, provider, evaluator));

        return jobId;
    }

    function setProvider(uint256 jobId, address provider_) external {
        Job storage job = jobs[jobId];
        if (job.id == 0) revert InvalidJob();
        if (job.status != JobStatus.Open) revert WrongStatus();
        if (msg.sender != job.client) revert Unauthorized();
        if (job.provider != address(0)) revert WrongStatus();
        if (provider_ == address(0)) revert ZeroAddress();
        job.provider = provider_;
        emit ProviderSet(jobId, provider_);
    }

    function setBudget(uint256 jobId, uint256 amount, bytes calldata optParams) external nonReentrant {
        Job storage job = jobs[jobId];
        if (job.id == 0) revert InvalidJob();
        if (job.status != JobStatus.Open) revert WrongStatus();
        if (msg.sender != job.provider) revert Unauthorized();

        bytes memory data = abi.encode(msg.sender, amount, optParams);
        _beforeHook(job.hook, jobId, msg.sig, data);

        job.budget = amount;
        emit BudgetSet(jobId, amount);
        jobHasBudget[jobId] = true;

        _afterHook(job.hook, jobId, msg.sig, data);
    }

    function fund(uint256 jobId, bytes calldata optParams) external nonReentrant {
        Job storage job = jobs[jobId];
        if (job.id == 0) revert InvalidJob();
        if (job.status != JobStatus.Open) revert WrongStatus();
        if (msg.sender != job.client) revert Unauthorized();
        if (job.provider == address(0)) revert ProviderNotSet();
        if (block.timestamp >= job.expiredAt) revert WrongStatus();

        bytes memory data = abi.encode(msg.sender, optParams);
        _beforeHook(job.hook, jobId, msg.sig, data);

        job.status = JobStatus.Funded;
        if (job.budget > 0) {
            paymentToken.safeTransferFrom(job.client, address(this), job.budget);
        }
        emit JobFunded(jobId, job.client, job.budget);

        _afterHook(job.hook, jobId, msg.sig, data);
    }

    function submit(uint256 jobId, bytes32 deliverable, bytes calldata optParams) external nonReentrant {
        Job storage job = jobs[jobId];
        if (job.id == 0) revert InvalidJob();
        if (
            job.status != JobStatus.Funded &&
            (job.status != JobStatus.Open || job.budget > 0)
        ) revert WrongStatus();
        if (msg.sender != job.provider) revert Unauthorized();

        bytes memory data = abi.encode(msg.sender, deliverable, optParams);
        _beforeHook(job.hook, jobId, msg.sig, data);

        job.status = JobStatus.Submitted;
        emit JobSubmitted(jobId, job.provider, deliverable);

        _afterHook(job.hook, jobId, msg.sig, data);
    }

    function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external nonReentrant {
        Job storage job = jobs[jobId];
        if (job.id == 0) revert InvalidJob();
        if (job.status != JobStatus.Submitted) revert WrongStatus();
        if (msg.sender != job.evaluator) revert Unauthorized();

        bytes memory data = abi.encode(msg.sender, reason, optParams);
        _beforeHook(job.hook, jobId, msg.sig, data);

        job.status = JobStatus.Completed;

        uint256 amount = job.budget;
        uint256 platformFee = (amount * platformFeeBP) / 10000;
        uint256 evalFee = (amount * evaluatorFeeBP) / 10000;
        uint256 net = amount - platformFee - evalFee;

        if (platformFee > 0) {
            paymentToken.safeTransfer(platformTreasury, platformFee);
        }
        if (evalFee > 0) {
            paymentToken.safeTransfer(job.evaluator, evalFee);
            emit EvaluatorFeePaid(jobId, job.evaluator, evalFee);
        }
        if (net > 0) {
            paymentToken.safeTransfer(job.provider, net);
        }

        emit JobCompleted(jobId, job.evaluator, reason);
        emit PaymentReleased(jobId, job.provider, net);

        _afterHook(job.hook, jobId, msg.sig, data);
    }

    function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external nonReentrant {
        Job storage job = jobs[jobId];
        if (job.id == 0) revert InvalidJob();

        if (job.status == JobStatus.Open) {
            if (msg.sender != job.client) revert Unauthorized();
        } else if (job.status == JobStatus.Funded || job.status == JobStatus.Submitted) {
            if (msg.sender != job.evaluator) revert Unauthorized();
        } else {
            revert WrongStatus();
        }

        bytes memory data = abi.encode(msg.sender, reason, optParams);
        _beforeHook(job.hook, jobId, msg.sig, data);

        JobStatus prev = job.status;
        job.status = JobStatus.Rejected;

        if ((prev == JobStatus.Funded || prev == JobStatus.Submitted) && job.budget > 0) {
            paymentToken.safeTransfer(job.client, job.budget);
            emit Refunded(jobId, job.client, job.budget);
        }

        emit JobRejected(jobId, msg.sender, reason);

        _afterHook(job.hook, jobId, msg.sig, data);
    }

    function claimRefund(uint256 jobId) external nonReentrant {
        Job storage job = jobs[jobId];
        if (job.id == 0) revert InvalidJob();
        if (job.status != JobStatus.Funded && job.status != JobStatus.Submitted)
            revert WrongStatus();
        if (block.timestamp < job.expiredAt) revert WrongStatus();

        job.status = JobStatus.Expired;

        if (job.budget > 0) {
            paymentToken.safeTransfer(job.client, job.budget);
            emit Refunded(jobId, job.client, job.budget);
        }

        emit JobExpired(jobId);
    }

    // ──────────────────── View ────────────────────

    function getJob(uint256 jobId) external view returns (Job memory) {
        return jobs[jobId];
    }
}

Security Considerations

Copyright

Copyright and related rights waived via CC0.