Payments

Every signature an agent makes ends up in the PaymentManager. The shape is simple: a client opens a prepaid session for a service, pays per call by signing off-chain vouchers (the x402 model), and a node settles on-chain to collect. Payments are always in USDC / USDT / ETH, never ZAI, and the USDC can start as anything: deposit any asset on any chain and the network converts it to USDC in the AI wallet (Funding). Plain wallet or smart account with a session key, it is the same flow: settlement checks both signatures through one path.

The on-chain logic is PaymentManager. The off-chain glue is the facilitator (a gas-only relayer for gasless sessions) and the voucher SDK.

Related: Run a node · Stake & delegate

The session lifecycle

client opens session (deposit)  ──▶  Open
        │  per-call: client signs PaymentVoucher(sessionId, cumulativeAmount)
        │            node verifies, serves the call, keeps the latest voucher
        ▼
node submits highest voucher ──▶ settle() ──▶ Settled   (split paid out, remainder refunded)
        or, if node goes idle ──▶ reclaim() ──▶ Refunded (client takes back unspent deposit)
        or, if disputed        ──▶ markDisputed ──▶ Disputed ──▶ resolveDispute

A session starts with a real deposit, pulled into the contract up front, then drawn down by monotonically increasing cumulative vouchers. Each says "I authorize this much in total so far", so the node needs only the single latest one to settle. That one choice keeps per-call payment cheap: nothing touches the chain between open and settle, however many calls the session carries.


Part 1 — Paying as a client

The client is your agent, or the script or wallet standing in for it. It opens a session with a deposit, then keeps signing vouchers as the calls go by.

Open a session

Four ways to open, by asset and by whether you pay gas yourself. All take a deposit that bounds what the session can ever spend, and all check that the service is active, the asset accepted, the node active and under capacity.

// PaymentManager.sol
function openSessionETH(uint256 serviceId) external payable returns (uint256);
function openSession(uint256 serviceId, address asset, uint256 amount) external returns (uint256);
function openSessionWithPermit2(uint256 serviceId, address asset, uint256 amount, address owner,
    IPermit2.PermitTransferFrom calldata permit, bytes calldata signature) external returns (uint256);
function openSessionWithAuthorization(uint256 serviceId, address asset, uint256 amount, address from,
    uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s)
    external returns (uint256);

Each returns a sessionId, also emitted in SessionOpened; parse it from the receipt's event and keep it, because every voucher and the settlement refer to it.

a) ETH

The simplest path sends the deposit as the transaction value.

# Open a session for serviceId 1 with a 0.01 ETH deposit.
cast send $PAYMENT "openSessionETH(uint256)" 1 --value 0.01ether \
  --rpc-url http://127.0.0.1:8545 --private-key $CLIENT_PK

b) ERC-20 via approval (any token, including USDT)

For any ERC-20, approve the PaymentManager, then open.

cast send $USDC "approve(address,uint256)" $PAYMENT 10000000 \
  --rpc-url http://127.0.0.1:8545 --private-key $CLIENT_PK     # 10 USDC (6 decimals)

cast send $PAYMENT "openSession(uint256,address,uint256)" 1 $USDC 10000000 \
  --rpc-url http://127.0.0.1:8545 --private-key $CLIENT_PK

c) Permit2 (gasless approval, any ERC-20)

After a one-time Permit2 approval, a relayer can submit openSessionWithPermit2 on the payer's behalf: the payer signs a Permit2 PermitTransferFrom and spends no gas, and the deposit is pulled from owner via Permit2.

d) EIP-3009 receiveWithAuthorization (USDC, fully gasless)

The payer signs an EIP-3009 transfer authorization that moves USDC straight from from into the PaymentManager; a relayer submits the transaction and pays the gas. The facilitator exposes this, and the demo takes it:

// POST http://127.0.0.1:8547/open/eip3009
const opened = await httpPost(`${FACILITATOR_URL}/open/eip3009`, {
  serviceId: serviceId.toString(),
  asset: deployments.usdc,
  amount: depositAmount.toString(),     // e.g. 10_000_000 = 10 USDC
  from: clientAddr,                     // the payer who signed the authorization
  validAfter: "0",
  validBefore: ((1n << 48n) - 1n).toString(),
  nonce,                                // random 32-byte hex
  v, r, s,                              // the EIP-3009 signature
});
const sessionId = BigInt(opened.sessionId);

The facilitator is non-custodial: a gas key only (Anvil account #3 on the local development network), never client funds. It calls openSessionWithAuthorization, waits for the receipt, parses sessionId from SessionOpened, and returns { sessionId, txHash }. Full flow: offchain/src/demo/run.ts.

Sign per-call vouchers (x402)

With the session open, paying for a call means signing an EIP-712 PaymentVoucher. Each carries the cumulative total authorized so far, so call n authorizes n * pricePerCall. The signature is domain-separated to this chain and this PaymentManager, so it cannot be forged or replayed anywhere else.

PaymentVoucher(uint256 sessionId, uint256 cumulativeAmount)
domain: name "AIOUMPayments", version "1", chainId, verifyingContract = PaymentManager

Use signVoucher from the SDK:

import { signVoucher } from "../sdk/index.js";

// Call #n: authorize a higher cumulative total than last time.
const cumulativeAmount = pricePerCall * BigInt(n);
const voucher = await signVoucher(
  client,            // ethers Signer = the session client
  CHAIN_ID,
  deployments.payment,
  sessionId,
  cumulativeAmount,
);
// voucher = { sessionId, cumulativeAmount, signature }

When the client is a smart account, the session key signs this same voucher and the node and the contract accept it through ERC-1271. Nothing in the voucher changes.

The x402 call flow

The voucher rides with the tool call: the node runs an MCP-style JSON-RPC server, and the payment object goes inside tools/call:

const rpcReq = {
  jsonrpc: "2.0",
  id: n,
  method: "tools/call",
  params: {
    name: "wordcount",
    arguments: { text },
    payment: {
      sessionId: sessionId.toString(),
      cumulativeAmount: cumulativeAmount.toString(),
      signature: voucher.signature,
    },
  },
};
const rpcRes = await httpPost(`${NODE_URL}/mcp`, rpcReq);   // node runtime on :8546

The node verifies before it serves: the session must be Open, the signer the session client, and spent + pricePerCall <= cumulativeAmount <= deposited. If payment is missing or short, the node answers with the x402 signal, a JSON-RPC error with code 402 whose data says exactly what to pay:

{ "error": { "code": 402, "message": "payment required: insufficient cumulative amount",
  "data": { "required": "2000000", "asset": "0x...USDC", "sessionId": "1" } } }

The client re-signs a voucher for at least required and retries. The off-chain check lives in verifyPayment.

Get your deposit back if the node goes dark

A node that takes your deposit and never settles cannot trap it. Once the session has idled past inactivityTimeout (1 day at deploy), you reclaim the unspent deposit yourself:

function reclaim(uint256 sessionId) external; // client-only; refunds deposited - spent
cast send $PAYMENT "reclaim(uint256)" 1 \
  --rpc-url http://127.0.0.1:8545 --private-key $CLIENT_PK

The liveness backstop is what pushes nodes to settle promptly: an operator sitting on vouchers is racing the client's right to take the remainder back.


Part 2 — Collecting as a node

The same session from the other side. The operator has served calls and kept the latest voucher per session; collecting means redeeming it.

Settle

To collect, the operator submits the highest voucher the client signed. The node runtime caches the latest voucher per session and redeems it via POST /settle, which calls:

// PaymentManager.sol — only the service operator may call.
function settle(uint256 sessionId, uint256 cumulativeAmount, bytes calldata signature) external;
// node/server.ts /settle handler
const tx = await ctx.payment.settle(
  voucher.sessionId,
  voucher.cumulativeAmount,
  voucher.signature,
);
await tx.wait();

settle re-verifies the EIP-712 signature on-chain against the session client and requires spent <= cumulativeAmount <= deposited. In one atomic step it marks the session Settled, frees a capacity slot, distributes the gross, and refunds the unspent remainder. You settle the latest voucher only, never the intermediate ones; a deposit larger than what was spent carries no penalty.

Settlement also grants the client one rating for the operator in the ReputationRegistry. An agent run by the app spends it after every settled session, rating the operator on-chain, 0–100, from what the run saw, errors and slow answers costing points. The ratings other agents read when picking a node come from real results, not forms, and feed routing.

The fee split

On settle the gross (the voucher's cumulativeAmount) is divided. The protocol fee and the delegator share both come off the gross, not one after the other; the operator keeps the rest:

// PaymentManager._distribute(...)
protocolFee    = gross * protocolFeeBps    / BPS;   // → treasury     (5% at deploy)
delegatorShare = gross * delegatorShareBps / BPS;   // → node stakers (10% default)
operatorNet    = gross - protocolFee - delegatorShare; // → operator
refund         = deposited - gross;                 // → client

operatorNet goes to the operator address in the session asset. protocolFee goes to the DAO treasury. delegatorShare is forwarded into StakingManager.notifyReward(operator, asset, amount), which credits the node's stakers (Stake & delegate → rewards); the funds move to the StakingManager before notifyReward, so accrual is always backed, and if the asset is not an allowed reward asset this share falls back to the treasury. refund is the unused deposit returned to the client.

The protocol fee is global (5% at deploy; only governance changes it, since setFees is onlyOwner). The delegator share is set per service by the operator via setServiceSplit(serviceId, delegatorShareBps) (anyone else reverts with only service operator); the global delegatorShareBps (10%) is the default for services that have not overridden it.

These four amounts sum exactly to the deposit. Read the live bps from the contract:

cast call $PAYMENT "protocolFeeBps()(uint16)"    --rpc-url http://127.0.0.1:8545   # 500  = 5%  (governance-set)
cast call $PAYMENT "delegatorShareBps()(uint16)" --rpc-url http://127.0.0.1:8545   # 1000 = 10% (default)

The SessionSettled event reports the exact numbers, gross, operatorNet, protocolFee, delegatorShare, refund, which the demo decodes and reconciles against on-chain balance deltas.

Value chains: opt-in revenue-share / attribution

A node often builds on sub-providers; a guide node that pays a data source, say. The operator may optionally declare those contributors and credit them a share of its own net in the same settlement:

// PaymentManager.sol — operator-only; settle + split the operator's net among declared payees.
function settleWithAttribution(
    uint256 sessionId, uint256 cumulativeAmount, bytes signature,
    address[] payees, uint16[] shareBps
) external;

Post-hoc, opt-in, node-controlled: the node decides who it credits and how much. Shares are fractions of the operator's net (after the protocol fee and the stakers' share), sum(shareBps) <= 100%. Contributors are paid and recorded on-chain (ContributionPaid) atomically with the settlement. Plain settle is unchanged; attribution is additive. It is the seed of value chains: a node pays forward to whatever it depended on, out of its own cut.

Refunds

Refunds are automatic and need no node action. On settle, deposited - gross returns to the client in the same transaction. On reclaim, if the node goes idle, the client takes back the unspent deposit themselves.

Disputes

A session can be challenged through DisputeManager, which drives PaymentManager's dispute hooks (only the DisputeManager may call them):

function markDisputed(uint256 sessionId) external;                  // Open → Disputed (freezes settle)
function resolveDispute(uint256 sessionId, bool refundClient) external;

markDisputed freezes an Open session so it cannot be settled while under review. resolveDispute(true) refunds the client the unspent deposit and closes the session Refunded, while resolveDispute(false) clears the node: the session returns to Open and the normal settle path resumes.

If the arbitrator never rules, funds are not frozen forever. After DisputeManager.disputeTimeout (3 days at deploy) anyone can call resolveStale(disputeId) to force-close it; the client's unspent deposit is refunded and the bond returned, no slash. With the reclaim backstop, there is no state a session can get stuck in.

If a node is found to have misbehaved, slashing of its ZAI stake happens separately in the StakingManager; see Stake & delegate → slashing risk.


Function reference

ActionCallerFunction
Open (ETH)clientopenSessionETH(uint256 serviceId)
Open (approval)clientopenSession(uint256 serviceId, address asset, uint256 amount)
Open (Permit2)relayer for owneropenSessionWithPermit2(...)
Open (EIP-3009, gasless)relayer for fromopenSessionWithAuthorization(...)
Sign per-call voucherclient (off-chain)signVoucher(signer, chainId, paymentManager, sessionId, cumulativeAmount)
Settle & collectoperatorsettle(uint256 sessionId, uint256 cumulativeAmount, bytes signature)
Reclaim idle depositclientreclaim(uint256 sessionId)
Inspect a sessionviewgetSession(uint256 sessionId)
Voucher digest helperviewvoucherDigest(uint256 sessionId, uint256 cumulativeAmount)
Mark / resolve disputeDisputeManagermarkDisputed(...) / resolveDispute(...)

Try it end-to-end

The demo runs it all on the local development network (Anvil): a gasless session, three metered calls, a settlement, and a money-flow report you can reconcile line by line against the fee split.

cd offchain
anvil                 # terminal 1 (chainId 31337, :8545)
npm run deploy        # terminal 2
npm run node          # terminal 2 — node runtime on :8546
npm run facilitator   # terminal 3 — relayer on :8547
npm run demo          # terminal 4

All of it happened against one serviceId. Before a session opens, though, the agent chooses which of the competing nodes in a category gets that deposit, and after it settles, the rating it leaves changes that choice for everyone who comes next.

Next: Routing and discovery — how an agent finds services on its own and picks the node that serves each call.