Run a service node
Your node staked, registered an identity and listed your services on its own. Underneath, the
runtime in offchain/src/node/ acquires stake, then serves and settles calls; its contract calls
show what each boot line means, what your stake buys, and which decisions are yours: the category,
the descriptor, the price, the accepted assets and your stakers' split. A service node turns an
MCP tool server, a resold API or a model into a live on-chain listing that
agents find on their own and pay per call.
The runtime is idempotent: on every boot it brings the operator online (stake, identity, service) only if it is not already, so restarts against a configured chain are free. ZAI is used only to stake (securing the node, slashable) and to govern the DAO; calls are paid in stablecoins or ETH (USDC by default), and you receive the session asset on settlement, never ZAI.
Prerequisites
You need Node 22, the off-chain dependencies (npm install in offchain/), a funded operator
key, and a running chain with the protocol deployed. On the local development network the operator
key is Anvil account index 1 (mnemonic
test test test test test test test test test test test junk), derived by
signer(ROLES.operator, …) in src/config.ts, so no private key is hardcoded. The chain is the
one from Try it on your computer:
# in offchain/
anvil # terminal 1 — chainId 31337, RPC http://127.0.0.1:8545
npm run deploy # terminal 2 — deploys the stack, writes deployments.json
npm run deploy writes offchain/deployments.json with every contract address, the chainId and
the usdc address (mock USDC on the local development network); the node loads it via
loadDeployments(). On the AIOUM network the protocol is already deployed: point the runtime
at the network's RPC and its deployments.json instead of Anvil.
1. Acquire and stake ZAI
Stake is what makes a stranger's agent willing to pay you. Two floors: you put up at least
minSelfStake of your own to list, and the node is live, taking paid sessions, once its
total stake, yours plus what backers delegate, reaches minNodeStake. The deploy script
sets these parameters (read the live values from the contract):
| Param | Value | Meaning |
|---|---|---|
minSelfStake | 100 ZAI | Your own floor: enough to list |
minNodeStake | 10,000 ZAI | Total stake (own + delegated) at which the node is live |
tokensPerSession | 100 ZAI | Extra stake bonded per concurrent session slot |
unbonding | 7 days | Withdrawal delay after unstaking |
maxCommission | 20% | Cap on the commission a node charges delegators |
Staking is a two-step ERC-20 flow against StakingManager: approve, then stake(amount).
// StakingManager.sol
function stake(uint256 amount) external; // pulls ZAI via safeTransferFrom
function selfStakeOf(address operator) external view returns (uint256);
function totalStakeOf(address operator) external view returns (uint256); // own + delegated
function isOperator(address operator) external view returns (bool); // selfStake >= minSelfStake
function isActiveNode(address operator) external view returns (bool); // isOperator && total >= minNodeStake
The bootstrap (setupNode() in src/node/setup.ts, the stake step in src/node/stake.ts) does
this for you. With no own stake yet it stakes AIOUM_SELF_STAKE; unset, it stakes the full
10,000 when your wallet holds that much, so the node is live at once, else 100, and tells you
what comes next:
[node] staked 100 ZAI of your own; the listing goes live once total stake reaches 10,000 ZAI (request backing from the network's staking node, or delegators); own 100 ZAI · total 100 ZAI now
An operator already at the target is left alone ([node] already staked: own 10,000 ZAI · total 10,000 ZAI: live), which makes restarts cheap and safe. To add more of your own later, set
AIOUM_SELF_STAKE to the new amount and restart, or stake by hand.
2. Register identity (ERC-8004)
With stake in place, the node needs a name on-chain: an ERC-8004 identity, required before it can
list a service. IdentityRegistry.register takes a metadataURI (an off-chain descriptor,
typically ipfs://…) and is one per account.
// IdentityRegistry.sol
function register(string calldata metadataURI) external returns (uint256 id);
function isRegistered(address account) external view returns (bool);
Bootstrap registers ipfs://aioum-demo-node only if isRegistered(operator) is false:
if (!(await identity.isRegistered(operator))) {
await (await identity.register("ipfs://aioum-demo-node")).wait();
}
3. Register a service
A service is the on-chain catalogue entry agents pay against and the listing their marketplace
search returns. It is registered on ServiceRegistry, which requires an operator as caller:
own stake at minSelfStake. The listing is live once the node's total stake reaches
minNodeStake.
// ServiceRegistry.sol — caller must pass staking.isOperator(msg.sender)
function registerService(
string calldata category, // e.g. "mcp:demo-tools", "llm:gpt-oss-120b"
string calldata descriptorURI, // MCP manifest / endpoint descriptor (often ipfs://)
uint256 pricePerCall, // in the settlement asset's smallest unit
address[] calldata acceptedAssets // must be non-empty
) external returns (uint256 serviceId);
The Service struct stored on-chain (and returned by getService) is:
struct Service {
uint256 id;
address operator; // holds the own stake floor; the listing is live at the node floor
string category;
string descriptorURI;
uint256 pricePerCall;
bool active;
}
These four values are yours to decide. category is the routing and discovery key; the demo
uses "mcp:demo-tools", and an agent finding its own tools finds your listing by what it does, so
name what you sell. descriptorURI is where agents fetch your manifest; the demo uses
"ipfs://demo". pricePerCall is in the settlement asset's smallest unit: the demo charges
1 USDC = 1_000_000 (USDC has 6 decimals), the same unit voucher cumulativeAmounts and
on-chain settlement use. acceptedAssets is the non-empty list of assets you accept; the demo
passes [deployments.usdc].
You may also declare a model, surfaced off-chain via /services (and in tools/list /
/health) so agents can route on it. It is an operator claim, not a proof, and is not
recorded on-chain; only descriptorURI is.
You can also set your stakers' split: settlements pay the global delegatorShareBps (10%) to
your delegators by default, and you may override it per service with
PaymentManager.setServiceSplit(serviceId, delegatorShareBps), which only the service's operator
can call. The protocol fee is global and outside operator control; only governance changes it
(step 5).
ensureService(...) is idempotent: it scans servicesByOperator(operator) and reuses any
live service in the same category; only if none matches does it call registerService(...), then
caches the new serviceId from the ServiceRegistered event, falling back to a post-tx operator
scan:
const existing = await serviceRegistry.servicesByOperator(operator);
for (const id of existing) {
const svc = await serviceRegistry.getService(id); // [id, operator, category, descriptorURI, pricePerCall, active]
if (svc.category === "mcp:demo-tools" && svc.active) return BigInt(svc.id); // reuse
}
// else registerService(...) and read serviceId from the ServiceRegistered event
4. Start the node runtime
The three steps above run when you start the node:
# in offchain/, with anvil + npm run deploy already done
npm run node
This runs src/node/server.ts: first setupNode(), the idempotent stake / identity / service
bootstrap of steps 1–3, under a NonceManager-wrapped operator signer so the bootstrap
transactions never collide on a nonce; then an Express server on port 8546 exposing:
GET /health→ readiness:{ ok, operator, serviceId, role, blockNumber, selfStake, totalStake, minSelfStake, minNodeStake, live, staked }; 503 until the node is live (own stake atminSelfStake, total stake atminNodeStake) or when the RPC is unreachable, with ahintsaying what would make it live (GET /live→{ ok, operator, serviceId }is the bare liveness probe)GET /services→[{ serviceId, category, descriptorURI, pricePerCall, acceptedAssets }]POST /mcp→ JSON-RPC 2.0 (tools/list,tools/call);tools/callis payment-gated.POST /settle→ queues the latest stored voucher for settlement; the settlement loop submits it on-chain.
A clean boot logs:
[node] staked 10,000 ZAI of your own; own 10,000 ZAI · total 10,000 ZAI: live
[node] registering identity
[node] registering service
[node] service ready: id=1 category=mcp:demo-tools pricePerCall=1000000
[node] runtime listening on http://127.0.0.1:8546
On a restart against the same chain those lines become already staked /
identity already registered / reusing existing service, and no duplicate state is created.
5. Serve calls and settle
The node is listed; the rest is the life of a session (mechanics:
the MCP server guide). An agent opens a prepaid session against your
serviceId (gaslessly, for example, via the facilitator, which relays
an EIP-3009 authorization), locking a deposit in PaymentManager. For call n the agent signs an
EIP-712 PaymentVoucher(sessionId, cumulativeAmount), the receipt under its on-chain name, with
cumulativeAmount = pricePerCall * n, and sends it under params.payment of a tools/call. Your
node verifies the voucher on-chain, runs the tool, returns the result, and stores the latest
voucher. You redeem it with POST /settle { sessionId }, which calls:
// PaymentManager.sol — only the session's operator may settle
function settle(uint256 sessionId, uint256 cumulativeAmount, bytes calldata signature) external;
Settlement splits the gross: a 5% protocol fee to the treasury (global; governance-settable),
the delegator share to your stakers, the remainder to you, and any unspent deposit back to the
client. The delegator share is operator-chosen per service (setServiceSplit, default 10%);
only the protocol fee is outside your control.
Settle on demand (POST /settle) or leave it to the background settlement loop. Settling is
your responsibility: idle past the inactivityTimeout (1 day) without settling and the client
can reclaim(...) the unspent deposit, the liveness backstop. Settlement also closes the loop
with the buyer: once the session settles, the agent that paid you rates you on-chain, 0–100, from
what it saw, and errors and slow answers cost points. That rating sits beside your stake and price
when the next agent searches, so serving well is how you get found again.
Operational notes
Every runtime setting (auth, rate limits, settlement retries, HA, privacy jitter) is listed with its default in the operator configuration reference. Four things to understand before you run for real.
Capacity scales with total stake. Live needs minNodeStake in total; the number of
concurrent sessions you may serve is bonded on top of it:
// StakingManager.sol
function maxConcurrent(address operator) external view returns (uint256);
// = 1 + (totalStakeOf(operator) - minNodeStake) / tokensPerSession (0 unless live)
With 10,200 ZAI behind the node (AIOUM_SELF_STAKE=10200, or 100 of your own and 10,100
delegated) and the parameters above (minNodeStake 10,000, tokensPerSession 100), that is
1 + (10200 - 10000)/100 = 3 concurrent sessions; 50,000 ZAI allow 401. More stake, yours or delegated, raises
capacity; delegators earn the delegatorShare of your settlements in return.
The latest voucher per session is persisted. The runtime keeps only the latest voucher per
session (each supersedes the last), writes it to disk under AIOUM_NODE_DIR and reloads it on
boot, so a crash never strands vouchers you have earned. The settlement loop drains a durable
pending-settle queue with fee-bumped retries until the transaction is mined. On-chain
session.spent is the source of truth for what has been paid out; the stored voucher guards
against replaying an already-served one (MCP guide).
One node, one signer. All writes go through the single NonceManager-wrapped operator
runner. Never run two node processes on one operator key; they fight over nonces. Shared-store
mode is the way to run several instances under one key.
Idempotency is per-category. ensureService reuses a live service matching its hardcoded
category ("mcp:demo-tools"). To list a different service, register it explicitly with your own
category / descriptorURI / pricePerCall / acceptedAssets.
That is the whole node, from stake to settlement.
Next: Resell fal.ai models — one adapter in full: an image, video or audio model as a paid listing.