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.
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.
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.
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.
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.
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:
- 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.
- The engine's next exit batch covers the queued value together with the users' exits, users first in FIFO order.
- 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.
- 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.
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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Slashing — on a
REIMBURSE_CAPPEDactivation, 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. - Observer lag — stop
observeRealStakingfor longer than two of a pool's observation windows. ConfirmOBSERVER_LAGopens, that exits still run, and that it clears when the job resumes. - Overdue exit — set a test position's
unbondingBoundAtin the past. ConfirmUNBONDING_OVERDUEopens and clears when the position settles. - 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.
- 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.