MMashDiv

Operating on-chain staking

The consoles, the doors a person holds, the alerts, the monthly statements, and the drills to run before the first customer stakes.

13 min readUpdated 15 September 2026on-chain, operations, commission, slashing, alerts, statements, drills

Everything that moves coins runs on the scheduled tasks. What a person holds is a small set of doors, each of which refuses by name what it cannot do safely. This page is the runbook for those doors, and the drills to run on a test network before the first customer stakes.

The consoles

Six consoles sit under Staking → On-chain, grouped behind one nav parent. Overview, Pools, Positions, Earnings and Settings sit at the top level beside it. All eleven are reachable in either mode — the engines are gated on rows, not on stakingMode, so a fixed-rate install that once started an activation can still hold a stuck batch and an open incident.

The permission below is the one that opens the screen. The write doors inside ask for their own, and those are named where they appear on this page.

Console Path Opens with What you come here for once the product is live
Overview /admin/staking access.staking Read every wallet and pool without touching anything
Pools /admin/staking/pool access.staking.pool The On-chain tab: tranches, observations, commission
Positions /admin/staking/position access.staking.position The two doors below
Earnings /admin/staking/earning access.staking.earning The commission ledger for every on-chain pool
Chains /admin/staking/chain access.staking.chain Pause and resume intake
Wallets /admin/staking/wallet access.staking.wallet Freeze, unfreeze, the gas reserve floor
Validators /admin/staking/validator-set access.staking.validator Evaluate now after a breach
Batches /admin/staking/batch access.staking.batch Retry now on a batch that errored before broadcast
Incidents /admin/staking/incident access.staking.incident Acknowledge, resolve, reimburse
Compliance /admin/staking/compliance access.staking.settings Statements, consents, exports
Settings /admin/staking/settings access.staking.settings The mode switch and the staking keys

Activating a chain, screening validators, reading a batch's approved intent and the six incident kinds the reconciler opens are all on Chains, wallets and validators. The settings themselves are on Staking settings. What follows is what you do after those are already true.

Overview — per wallet: the native balance the reconciler last read, the gas reserve floor, and the book value the pools on that key say is delegated. Per pool: on-chain value, share price, unallocated value, holders by state, the observer's last read and whether it lags, the validator set's policy verdict, the commission held as treasury shares and what of it has been realised, and the unbonding ladder — what is due back, by day, with anything past the promised bound marked. Open incidents and in-flight batches sit at the bottom. Refreshes every minute while the tab is visible; reads only.

GET/api/admin/staking/dashboard/realpermission: access.staking
The on-chain operations overview: per wallet the native balance, gas floor and book value delegated; per pool the share state, observer lag, validator verdict, commission held and realised, positions by state and the unbonding ladder; open incidents; batches in flight.

Pools — the form has no rate, lock, capacity, early fee, cadence or tiers for an on-chain pool. You choose an activated chain; the venue, the wallet, the validator set, the slashing policy and the commission notice come from the activation and are shown read-only. A pool's detail page gains an On-chain tab: the same commission ledger the Earnings page renders, the tranches (stake accounts or Lido shares) with their validator and observed value, and the observations that moved the share price.

Compliance — the activation record each chain was opened under and the rules it derived, every consent recorded at the stake door, every monthly statement written, and CSV exports of consents, activations and observations. One write: Write missing statements for a period ahead of the nightly run.

POST/api/admin/staking/compliance/statements/generatepermission: edit.staking.settings
Writes a period's missing statements now: period YYYY-MM, omitted = the previous month. Idempotent per user and period.

The Compliance console has no permission key of its own. It opens on access.staking.settings; its records, consents, statements and exports all read view.staking.settings, and writing a period reads edit.staking.settings. Those are the same two keys that open the Settings screen. Grade them as customer-records permissions rather than configuration permissions: they expose every activation declaration, every consent with the customer's name attached, every monthly statement and three CSV exports.

The scheduled tasks

Nothing on the consoles moves coins. Six registered jobs do. Admin → System → Cron lists them by their on-screen title; the logs, the refusal state and the API use the registered name.

Registered name On-screen title Period
processRealStakingBatches On-chain staking: batches 2 min
alertRealStaking On-chain staking: alerts 10 min
observeRealStaking On-chain staking: observe rewards 15 min
reconcileRealStaking On-chain staking: reconcile 1 h
refreshStakingValidatorSets On-chain staking: validator policy 6 h
generateRealStakingStatements On-chain staking: monthly statements 24 h

A seventh staking job, processStakingPositions ("Process Staking Logs", hourly), settles the fixed-rate product and touches no on-chain position.

Three things about these rows that will otherwise mislead you.

The batch runner survives the addon being switched off. processRealStakingBatches is flagged returnsCustomerMoney, and a job with that flag is scheduled on the disabled branch as well as the enabled one — disabling the extension closes its routes so nothing new can start, and leaves the runner claiming unbonded stake and sending it back. The only thing that does bring it down is a hard stop (stopEverything on the addon toggle), which records the addon in the hard-stop list. Nothing else in the product returns a holder's coins, so a hard stop with positions in UNBONDING strands them until you lift it.

REFUSED is a failure state, and it is per job. When @b/api/(ext)/staking/utils/real/cron fails to load, one alert goes out per broken module — not per job — but the refusal state is set on each of the six, and the cron page shows all six rows refused. Read the loader's own message in the refusal: a missing dependency usually means the addon was built against a newer core than the one installed.

A module that is simply absent is silent. addonCronModule distinguishes "failed to load" from "not installed"; the second returns null without an alert, the wrapper returns undefined, and the job is recorded as having completed. Six green rows on an install without the addon mean nothing ran.

The two doors on a position

Positions is the only console with a write door onto an on-chain position, and it holds two. Both take a reason rather than a yes or no, because both are things you do to a holder's position without being asked; both refuse an empty one with 400 and show the reason to the holder. There is no approve, decline, complete or cancel on an on-chain row: a protocol exit needs no human, and the fixed-rate withdrawal pair can never appear for one. The states these doors move a position between are laid out in How a stake moves.

POST/api/admin/staking/position/:id/force-unstakepermission: edit.staking.position
Forces the unstake of an on-chain position, with a reason the holder is shown. Rewards to the request are still paid.
POST/api/admin/staking/position/:id/abandonpermission: edit.staking.position
Abandons an on-chain stake that was gathered but never delegated, returning the coins on-chain.

Force unstake… appears on a position in ACTIVE and nowhere else — once the row is UNSTAKE_REQUESTED the protocol has the request and nothing can be started again. It starts the ordinary exit: the share price freezes, rewards stop at the request, the row walks UNSTAKE_REQUESTED → UNBONDING → WITHDRAWABLE → COMPLETED, and the coins go to the holder's own deposit address when the network releases them. It cannot be cancelled once queued.

Abandon and return coins… appears on a position in PENDING_DELEGATION that has a non-zero on-chain principal, and nowhere else. It is the door for coins that reached the staking wallet and could not be delegated — the batch runner already retries on its own, and this is the other half of that rule. It is the only way a holder whose coins sat in the staking wallet gets them back.

It does not refund from the platform. The position becomes WITHDRAWABLE for the amount that actually reached the staking wallet, the ordinary return pass sends that amount on-chain to the holder's own deposit address, and it is credited only once the transfer has landed — the same rule every other exit follows. The position then finishes FAILED, carrying the reason.

It refuses, by name, three positions that look abandonable and are not:

Refused Because
Any state but PENDING_DELEGATION The message names the state it found
principalOnchain at zero Nothing reached the wallet. A failed gather already refunds that position in full, automatically
The position already holds shares It is staked; unstake it instead

So FAILED has two roads into it, and both start at PENDING_DELEGATION: a gather that never landed, which refunds exactly; and this door, which routes through WITHDRAWABLE first. The model's comment says FAILED is reachable only from PENDING_DELEGATION — it is describing the first road.

Earnings, in REAL mode

/admin/staking/earning renders a different page in each mode. In SYNTHETIC it is the fixed-rate earnings table — what the platform typed, funded and distributed. In REAL the switch renders the commission ledger instead: what the network paid, the share the disclosure said the platform keeps, and what of that has reached the platform's own address. Nothing on it is typed or distributed.

It is not a read-only page. The mode switch renders the ledger with no props and the component defaults canEdit to true, so Observe now and Unstake commission render enabled on every pool row for anyone who can open the screen. The page entry is access.staking.earning; the two buttons call endpoints keyed on edit.staking.pool and edit.staking.earning. The refusal comes from the server, not from a greyed-out button — a holder of access.staking.earning alone sees live controls and is refused only after clicking one.

POST/api/admin/staking/pool/:id/observepermission: edit.staking.pool
Runs the observers now. A window already observed writes nothing twice.
POST/api/admin/staking/pool/:id/commission-exitpermission: edit.staking.earning
Queues a commission exit: shares (omitted = all treasury shares). One open request per pool; refused while the wallet is frozen or the chain has no ecosystem master wallet to pay to.

Each pool card carries four figures — what the treasury holds now (shares × today's price), the commission minted at observations for all time, the amount realised to the platform's address for all time, and the exit in progress if there is one. Accrued and realised will not agree and are not meant to: the first is the accrual ledger the observer writes, the second is what has been paid.

Beneath them, once a pool has queued at least one, is the exit history in seven columns. The QUEUED → UNBONDING → SETTLED → PAID walk below is only legible against them:

Column Reads
Requested When the exit was queued
Status QUEUED, UNBONDING, SETTLED, PAID or FAILED, with the failure reason underneath
Shares Treasury shares in the request, to nine places
Requested value Shares × the share price at the request — an estimate, not an entitlement
Settled What the shares were actually burned for, blank until settlement
Paid to The ecosystem master wallet address, truncated
Transaction The broadcast hash, truncated

Requested value and Settled differ whenever the price moved. Settlement burns at the lower of the request price and the current price, the same rule a customer's exit settles under, so a fall between the request and the return is taken by the treasury and a rise is not kept by it.

The commission exit

Commission is minted as treasury shares on the pool at every observation with a positive gross reward, so it rises and falls with every holder's shares and is slashed with them. Nothing is booked as revenue until the coins reach the platform's own address:

  1. Unstake commission queues N treasury shares (default: all). At most one open request per pool; refused while the staking wallet is frozen, and refused when the chain has no ecosystem master wallet to pay to.
  2. The engine's next exit batch covers the queued value together with the users' exits, users first in FIFO order.
  3. When the coins are back, settlement burns the shares from the treasury at the lower of the request price and the current price — after every user exit ahead of it has been paid. If the users ahead of it starve the unallocated value, the commission waits for the next claim.
  4. The engine transfers the settled amount from the staking wallet to the chain's ecosystem master wallet. The firewall allows that one destination and nothing else; a frozen wallet does not sign it and the row waits.

The row walks QUEUED → UNBONDING → SETTLED → PAID and is the realised ledger; the accrual rows the observer writes are the accrued ledger. The two are shown side by side.

The slashing reimbursement

A validator penalty reduces the pool's value for every holder. What an operator may promise on top is declared at activation: PASS_THROUGH, or REIMBURSE_CAPPED with a cap per incident. Reimburse… on a SLASHING incident pays that promise and nothing beyond it:

  • at most the cap per incident, cumulatively across calls, never more than the recorded loss; in parts if you prefer, each part keyed on what was already paid so a retried request cannot pay twice;
  • pro rata to the holders by their shares (a queued exit's shares still took the loss, so they count);
  • from the Super Admin's ECO wallet for the pool's currency, which must hold the amount — a reimbursement the treasury cannot fund is refused with the shortfall named, never booked as a credit the platform does not have;
  • the incident records the amount and resolves itself when the cap is reached.

Nothing on-chain moves. Each holder is notified with the amount and the incident, under the title Validator penalty reimbursed.

POST/api/admin/staking/incident/:id/reimbursepermission: edit.staking.incident
Reimburses a slashing incident up to the disclosed cap: amount (omitted = the whole remaining allowance), pro rata to the holders, from the Super Admin's ECO wallet, refused with the shortfall named when it cannot fund it.

Alerts

reconcileRealStaking raises hourly what can be checked against the chain: gas reserve, share-ledger and value drift, observer lag, stuck batches, validator policy. alertRealStaking raises every ten minutes what is a matter of time on the platform's own rows:

Kind When Severity
UNBONDING_OVERDUE an exit is past the bound the pool promised warning
DELEGATION_STALE a stake is still pending delegation past twice the activation delay plus a day warning
COMMISSION a commission exit has been settled for a day and not paid, or its chain has no master wallet warning

Every alert dedupes on its key while open, clears itself when the condition ends, and never blocks an exit. New incidents notify every admin who can see them.

POST/api/admin/staking/reconcilepermission: edit.staking.incident
Runs the reconciler now. Opens and clears incidents; moves no coins.

What the holder is told

Every on-chain engine writes an in-app notification against the position, with a link to it, at each point where the holder's money changes hands. These are the titles, so you can match a support ticket to a step without guessing:

Title Written when Solana Lido
Stake accepted The stake is opened and the gather is queued yes yes
Stake delegated The delegation landed; earning starts next epoch yes —
Stake submitted to Lido The submission landed; earning starts at the next daily rebase — yes
Stake refunded The gather never reached the staking wallet; the full amount is back in the holder's balance yes yes
Stake abandoned You used the abandon door; the gathered amount is being sent back on-chain yes yes
Unstake requested The holder asked to leave yes yes
Unstake started by the platform You used force-unstake; the reason is appended to the message yes yes
Coins returned The exit landed in the holder's deposit address and is credited yes —
ETH returned The same, on Lido — yes
Validator penalty reimbursed A capped slashing reimbursement was credited yes yes

The unstake messages carry the unbonding estimate and bound the pool published, not a fixed figure; the reimbursement message names the incident.

The on-chain product sends no email. Every message above is an in-app notification only. A holder who does not open the platform is told nothing about a delegation, an exit, a return or a reimbursement.

The fixed-rate product has exactly one email template, StakingRewardDistribution, sent by processStakingPositions when a matured position completes. A second helper, sendStakingInitiationEmail (StakingInitiationConfirmation), exists in the codebase and has no caller at all — no stake in either product has ever sent it.

Statements

Once a day the statements job writes the previous month's statement for every user who held an on-chain position in it: staked in the period, exited in the period, rewards net of commission, the commission attributed to them (each earning row's net × the observation's commission ÷ net, which is exact under the pro-rata rule the observer applies), and the value of their shares at the time of writing, labelled as such. One CSV per user and month, hashed, never regenerated. Users download theirs from Staking → Statements; the compliance console lists them all and can write a period early.

The liquid exit, and why it is off

An Ethereum pool can be set to sell its stETH on Curve's stETH/ETH pool rather than joining Lido's withdrawal queue, which releases an exit in minutes instead of days. It is off on every pool and you turn it on per pool, with a slippage cap in basis points.

What the cap does: the engine asks the venue what it would pay before it commits. If that price is worse than your cap against the protocol price, it does not sell — the exit joins the withdrawal queue as usual. The difference between the market price and the protocol price is borne by the holder who is leaving, never by the holders who stay, and the pool's disclosure states the cap as a number so they know it before they stake.

What protects it: the platform builds the swap itself against one named contract, and the firewall decodes every argument back out before the key signs — the pool address, the direction, the exact amount, and a minimum-out floor computed from your cap before any price is read. No aggregator is involved. A payload the firewall cannot read in full is refused rather than signed.

Before you enable it on a pool holding customer money: there is no test network for this path. Hoodi, where the rest of the Ethereum engine is rehearsed, has no Curve deployment. Turn it on for a pool with a small position first, watch one exit through end to end, and only then leave it on at scale.

Drills

Run these on a test network (Solana devnet or a local validator; Lido on Hoodi) with a funded staking wallet, before the first customer stakes, and again after any change to the engine.

  1. Kill switch — pause the chain under Chains. Confirm the stake door refuses with the pause named, and that a queued exit still deactivates, claims and returns on schedule. Resume.
  2. Freeze — freeze the staking wallet with a reason. Confirm a stake is refused, a delegation batch waits, and an exit, claim, return and refund still sign. Confirm a commission payout waits. Unfreeze; confirm the waiting batches land.
  3. Force-unstake — force-unstake a test position in ACTIVE with a reason. Confirm the notification reads Unstake started by the platform and carries the reason, the row walks UNSTAKE_REQUESTED → UNBONDING → WITHDRAWABLE → COMPLETED, and the credit carries the return transaction hash. Confirm an empty reason is refused.
  4. Abandon — pause the batch runner (or use a position whose delegation is failing) so a test stake sits in PENDING_DELEGATION with its coins in the staking wallet, then abandon it with a reason. Confirm the notification reads Stake abandoned, the position goes WITHDRAWABLE for the amount that reached the wallet rather than the amount requested, the return is an on-chain transfer to the holder's own deposit address, the credit lands only after the transfer does, and the position finishes FAILED with the reason on it. Confirm the door refuses a position in any other state, a position with nothing on-chain, and an empty reason.
  5. Commission — with treasury shares accrued, queue a commission exit. Confirm it settles after the test positions' exits, that the share invariant reconciles, and that the payout lands in the master wallet with the row PAID.
  6. Slashing — on a REIMBURSE_CAPPED activation, open a SLASHING incident by hand (or wait for a negative observation on the test network), fund the Super Admin's ECO wallet, and reimburse. Confirm the pro-rata credits, the cap, the refusal above the cap, and the refusal with an underfunded treasury.
  7. Observer lag — stop observeRealStaking for longer than two of a pool's observation windows. Confirm OBSERVER_LAG opens, that exits still run, and that it clears when the job resumes.
  8. Overdue exit — set a test position's unbondingBoundAt in the past. Confirm UNBONDING_OVERDUE opens and clears when the position settles.
  9. Statements — write a period from the compliance console. Confirm one row per test user, the CSV's hash matches its content, and a second run writes nothing.
  10. Restore — with the database restored from the last backup and the staking wallet's key material intact, confirm the reconciler reports the on-chain state against the book with no drift incident, and that in-flight batches settle from their recorded signatures without a rebuild.

Record the date and the outcome of each drill in the activation's validatorDueDiligence notes or your own change log.