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):

ParamValueMeaning
minSelfStake100 ZAIYour own floor: enough to list
minNodeStake10,000 ZAITotal stake (own + delegated) at which the node is live
tokensPerSession100 ZAIExtra stake bonded per concurrent session slot
unbonding7 daysWithdrawal delay after unstaking
maxCommission20%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 at minSelfStake, total stake at minNodeStake) or when the RPC is unreachable, with a hint saying 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/call is 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.