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
| Action | Caller | Function |
|---|---|---|
| Open (ETH) | client | openSessionETH(uint256 serviceId) |
| Open (approval) | client | openSession(uint256 serviceId, address asset, uint256 amount) |
| Open (Permit2) | relayer for owner | openSessionWithPermit2(...) |
| Open (EIP-3009, gasless) | relayer for from | openSessionWithAuthorization(...) |
| Sign per-call voucher | client (off-chain) | signVoucher(signer, chainId, paymentManager, sessionId, cumulativeAmount) |
| Settle & collect | operator | settle(uint256 sessionId, uint256 cumulativeAmount, bytes signature) |
| Reclaim idle deposit | client | reclaim(uint256 sessionId) |
| Inspect a session | view | getSession(uint256 sessionId) |
| Voucher digest helper | view | voucherDigest(uint256 sessionId, uint256 cumulativeAmount) |
| Mark / resolve dispute | DisputeManager | markDisputed(...) / 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.