Staking Phase 2 — design decisions and changes (for review)¶
Branch: staking-phase-2. Companion docs: staking-consensus.md (mechanism reference),
staking-next-phases.md (rev 7 product spec), staking-leader-selection-epochs.md.
1. Goal¶
Move from "staking is bookkeeping" to "staking drives consensus", in one coordinated release:
- Stake-weighted, per-block proposer selection that every node (validators, syncing nodes, the VM) derives deterministically from block headers — no epochs, no frozen sets, no VRF.
- Automated validator management: stake crossing the threshold adds/removes validators in
MercataGovernance(the set consensus actually uses); permissionless eligibility inside today's PBFT envelope (~50 validators). - A proposer share of transaction fees (USDST) routed to the proposer's operator and delegators.
- Liveness accounting instead of slashing: missed proposals cost the block's fees and are recorded; no tokens are seized in this phase.
- Stake-weighted PBFT votes, shipped at the same activation height, so permissionless joins cannot buy headcount quorum.
Cryptoeconomics are "good enough to get going"; the accepted limitations are listed at the end.
2. Decisions (with rationale)¶
| # | Decision | Why | Rejected alternative |
|---|---|---|---|
| D1 | Weights travel in the block header (BlockHeaderV3), sourced from MercataGovernance (0x100) events, exactly like currentValidators/newValidators/removedValidators today. |
Consensus is a pure state machine fed by headers; syncing nodes (replayHistoricBlock) never run the VM. Header transport is race-free (weights for height h+1 are fixed when h commits) and lets syncing nodes verify the proposer. |
Out-of-band push from the vm-runner (async → nodes can disagree on the leader); stuffing weights into extraData (opaque; still a format change). |
| D2 | Source of truth stays 0x100 only. Delta.hs consumes a new ValidatorStakeUpdated(validator, stake); StratoStaking calls staking-gated governance entrypoints. |
One trust anchor for consensus data; no per-network staking address in the node; governance is already a Proxy (setLogicContract) so the upgrade is a vote. |
Whitelisting StratoStaking for voteToAddValidator via AdminRegistry (works for add/remove but cannot carry weights). |
| D3 | Selection = deterministic, stake-weighted pick, seed keccak(chainId ‖ height ‖ round). Candidates in address order; x = seed mod totalWeight, walk cumulative weights; uniform fallback while nobody has stake; earlier rounds of the same height are excluded. |
Nothing in the seed is chosen by the proposer → no tx-inclusion grind. Predictable leaders are acceptable for a known set of ≤ 50 operators. Per-block rotation comes from height, round changes land on someone else from round + exclusion. |
Parent-hash seed (grindable by the previous proposer); VRF (new crypto, Phase 3); Tendermint-style priority accumulator (also fine; hash pick was preferred by the team). |
| D4 | PBFT rounds persist across heights (STRATO's existing behaviour) and are now stamped into the header (proposalRound). Live: parentRound ≤ header.round ≤ view.round; historic/VM: round ≥ parent.round; both: seal signer == selected proposer. |
Matches the running code; the only new thing is recording the round so replay/VM can verify. A restarted validator now resumes at the best block's round instead of 0. | Per-height round reset (my first implementation; cleaner "round>0 ⇔ miss" semantics, but diverges from the existing machine — reverted). |
| D5 | No epochs / frozen sets. Parent's validators and weights elect and vote on the child; stake changes apply one block later. | Epoch boundaries are complexity without benefit at ≤ 50 validators; a one-block lag already prevents voting under a set you just changed. Full weight vector kept in the header (no Merkle root) so a later Q-committee needs no new header version. | Lagged snapshot + reject-on-mismatch (halts on every join/kick). |
| D6 | Contracts see the parent block's facts: block.prevProposer, block.prevIntendedProposer, block.prevRound (derived from the parent header and the grandparent's round; stored in BlockSummary). block.proposer stays the current proposer. |
Blockstanbul stamps the round after the VM executed the block; feeding the live round into execution would invalidate the mining cache on every round change. | New per-block platform hook (payFees already runs ≥ 1× per block because empty blocks are not produced). |
| D7 | Fee routing via a swappable fee impl (FeeRouter): voucher, else $0.01 USDST split proposerFeeBps → StratoStaking, rest → FeeCollector; then StratoStaking.processBlock() (try/catch). |
DeciderState.updatePayFeeContract is the existing upgrade path; block.proposer is already visible there. Router runs under DELEGATECALL in the signer's context, so it keeps no storage; staking trusts only block.*. proposerFeeBps = 0 keeps today's behaviour until ops flip it. |
Direct USDST to the validator address (no delegator sharing). |
| D8 | USDST fees have their own accounting and claim path (claimFeeRewards / claimOperatorFeeRewards), mirroring the STRATO index math; STRATO claims stay STRATO-only. |
Spec 4B: fee share is USDST real yield, residual emissions stay STRATO; don't mix tokens in one index. | Paying USDST inside claimRewards. |
| D9 | Liveness, not slashing. processBlock counts blocksProposed / missedProposals / consecutiveMisses per validator, emits ProposalMissed; optional maxConsecutiveMisses → temporary jail (removed from the set, stake untouched, re-activatable after jailCooldown). Notable downtime = admin kick. |
A round change carries no signed "missed proposal" evidence, so a miss is consensus-derived but not cryptographically attributable; real slashing waits for provable round changes (+ domain-separated consensus signatures). PBFT's risk is liveness, not safety. | Self-bond slashing of intended/fill-in proposers (my first implementation — removed); evidence-based header jail (spec rev 7 — too heavy for a first pass). |
| D10 | Eligibility = selfBond ≥ minStake (10k), not jailed, no exit due (revised 2026-09-14; originally selfBond + delegated ≥ minStake with a separate minSelfBond floor). selfBondGraceUntil keeps the delegated rule for validators admitted under it until an admin-chosen deadline. |
A validator with nothing of its own at stake should not validate purely on delegations; the grace deadline lets helium's delegation-only validators bond without being removed on upgrade. | Self-bond + delegated (the original rule). |
| D11 | Lifecycle: leaving is automatic (same-tx), joining is explicit. Status derived (Missing/Registered/Active/Kicked). tryActivate fills a slot or evicts the lowest validator by evictionMarginBps; reconcileSet promotes waiters; requestExit with notice; kick bypasses the per-block mutation budget; inbound stake capped at maxOperatorStakeBps (0 = off, enable after bootstrap). joinsPaused default true (owner may still activate). |
Spec §2A/2B semantics without a stored enum (no storage migration) and without the evidence jail. Same-tx demotion keeps governance and staking from diverging; explicit activation keeps the set bounded. | Implicit auto-add on eligibility (my first implementation — replaced). |
| D12 | Stake-weighted PBFT votes at the same activation height (3·voteStake > 2·totalStake in PREPARE/COMMIT/ROUNDCHANGE and in replayHistoricBlock; zero-stake validators count on neither side; headcount while nobody has stake). joinsPaused=false only after this is live everywhere. |
Key Decision 12: unweighted votes + open join is a cheap seat takeover. Single activation height on Helium and Upquark (decision 2026-08-19). | Leaders-only weighting with open joins. |
| D13 | Validator-set hard cap enforced on-chain (MercataGovernance.hardCapValidators), not as a consensus rejection. |
A node refusing a committed membership change would stall the chain; a revert cannot. | Node-side newValidators rejection. |
| D14 | 1/3-of-stake tooling: inbound cap + ValidatorRegistry.emergencyKick by a dedicated emergencyKicker key, only for an operator over 1/3 (exceedsOneThird). |
Under weighted quorum a >1/3 holder can stall and cannot be jailed (nothing commits without its seal). | Auto-jail (unsatisfiable). |
| D15 | Activation is a per-network config height (stakingActivationBlock; Nothing = from genesis for fresh nets; live nets default to "not scheduled"). roundPeriodS 120 → 3600 as a backstop only. |
Mixed-version validators stall until all upgrade; a height lets nodes upgrade ahead of time. The 2–5 s view watchdog already detects a missed proposal; with per-block selection the round should rarely change under normal operation. | Immediate activation on upgrade; 4 s round period (spec) — unnecessary churn with the watchdog in place. |
3. What changed¶
Node (strato/)¶
BlockHeaderV3(proposalRound,currentStakes,stakeUpdates; RLP tag 3), SQLBlockStakeRef+ nullableproposalRound, JSON/API;Delta.getStakeDeltasFromEvents;verifyBlockgainsStakeMismatch+RoundMismatchand version gating.Blockchain.Strato.Model.ProposerSelection.selectProposer chainId height validators stakes parentRound round.- Blockstanbul:
_stakes,_chainId,_lastRoundin context (checkpoint/BestSequencedBlockcarry stakes + round);stampAndSealstamps round + stakes where it stamps validators;checkProposalHeader; weighted tallies (voteWeights/hasSupermajority); view-keyed timers (Timeout View, sequence-majorOrd View, re-armed per height). ProposalFactsinBlockSummary; SolidVM builtinsblock.prevProposer/prevIntendedProposer/prevRound, test-onlysetBlockContext(...).- Config:
NetworkConf.stakingActivationBlock,roundPeriodS = 3600; genesis installsFeeRouterat0xDEC1DE03. - Tests: strato-model selection properties, vm-tools (RLP/summary/delta/facts), blockstanbul
StakingConsensusSpec(rounds, timers, stakes, quorum, proposal checks).
Contracts (app/contracts, genesis mirror for governance/FeeRouter)¶
MercataGovernanceV2:stakingContract,validatorStake,ValidatorStakeUpdated,addValidatorFromStaking/updateValidatorStake/removeValidatorFromStaking(never drops the last validator),hardCapValidators.ValidatorRegistryV2:validatorAddressbinding (unique), permissionlessregister,setValidatorAddress,emergencyKick/setEmergencyKicker, 4-argsyncOperator+syncValidatorAddress.StratoStakingV2 (new deployment behindProxy; V1 is not upgradeable): governance sync (setGovernancekill switch),setValidatorParams(minStake, minSelfBond, proposerFeeBps, maxConsecutiveMisses, jailCooldown),setSetParams(...), lifecycle (tryActivate,reconcileSet,requestExit,cancelExit,syncValidator,status,eligible,isWaiter,exceedsOneThird),processBlock()(fee attribution + counters + optional jail), USDST fee indexes +claimFeeRewards/claimOperatorFeeRewards,recoverUnattributedFees.FeeRouter.sol(fee impl). Tests: StratoStaking 39, ValidatorRegistry 10, MercataGovernance 7, FeeRouter 5 (solid-vm-cli test).
App (app/backend, app/ui)¶
- Backend:
/staking/infocarries V2 state (status/eligible/isWaiter, blocks proposed/missed, USDST pending fees, set params); user endpointsregister,activate,reconcile,sync,exit,exit/cancel,profile,claim-fees,operator/claim-fees; admin votes for validator address, validator/set params, governance wiring (setStakingContract, hard cap, sync), fee recovery, emergency kicker;addOperator(s)takesvalidatorAddress; activity-feed configs for the new events. - UI: Earn Staking page shows status badges, blocks/missed, USDST fee claims, operator lifecycle (Activate / Request exit / Cancel), "Become a validator" registration; Admin → Staking tab for parameters, governance wiring and operator management; activity cards.
4. Rollout (summary — details in staking-consensus.md)¶
- Ship the node release with
stakingActivationBlockper network (> every node's head);roundPeriodS3600. - Deploy StratoStaking V2 + ValidatorRegistry V2 behind
Proxy; initialize;setValidatorParams,setSetParams(joins paused),setGovernance(0x100, false). - Governance logic upgrade (
setLogicContracton 0x100),setStakingContract,setHardCapValidators(50),setEmergencyKicker. - List every current validator with its node address, bond ≥
minStake,setGovernance(0x100, true),tryActivateeach — all before the activation height (with weighted votes, unstaked validators cannot vote). - Deploy
FeeRouter,updatePayFeeContract;proposerFeeBps> 0 when ready. - After activation is live on all producers:
joinsPaused=false(andmaxOperatorStakeBps=3300). - V1 migration window (stop schedule,
unbondingSeconds=0, users unstake/restake).
5. Accepted limitations / follow-ups¶
- Leaders are predictable; a stake shift can target a future slot only one block later (10k + unbonding make it noise).
- A locked block re-proposed in a later round makes the next height's miss detection false-positive once (counter only).
- Misses are not provable → no slashing until round changes carry signed evidence; then also fix
getHashdomain separation. - ~~Validator-address binding has no proof of possession yet~~ — resolved 2026-09-14: both staking contracts are keyed by validator address and binding an operator needs the validator key's signature (or an admin listing); the funded reward schedule was removed. Validator income is block rewards, proposer fees and (added 2026-09-15) permissionless discretionary rewards in any token: STRATO and USDST are split like block rewards and fees, and other tokens go wholly to the operator.
- Quorum/leader math is per-validator stake; no Q-committee yet (header already carries the full vector for it).
- Not yet done: deploy/migration scripts, PR 0 live measurements, multi-validator Helium chaos run.