staking economics: slashing (storage + equivocation) + ordering rewards#440
staking economics: slashing (storage + equivocation) + ordering rewards#440adamkrellenstein wants to merge 12 commits into
Conversation
| .map(|e| e.status() == STATUS_ACTIVE) | ||
| .unwrap_or(false) | ||
| }) | ||
| .collect(); |
There was a problem hiding this comment.
Denominator includes pending-exit stakes excluded from distribution
Medium Severity
total is read from model.total_active_stake() which includes STATUS_PENDING_EXIT validators' stakes (since begin_unstake doesn't decrement the total), but the actives filter on line 497 only selects STATUS_ACTIVE validators. This mismatch means the per-validator shares (amount * stake / total) sum to less than amount, with the entire shortfall going to the last sorted active validator via the remainder branch — violating the stated proportional-to-stake invariant whenever any validator is pending exit.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6f27c9a. Configure here.
6f27c9a to
b418ff1
Compare
b418ff1 to
2152d60
Compare
| entry.set_stake(stake.sub(actual)?); | ||
| if entry.status() == STATUS_ACTIVE { | ||
| model.try_update_total_active_stake(|s| s.sub(actual))?; | ||
| } |
There was a problem hiding this comment.
Slash misses total_active_stake update for PENDING_EXIT validators
High Severity
The slash function only updates total_active_stake when the slashed validator has STATUS_ACTIVE, but total_active_stake also tracks STATUS_PENDING_EXIT validators' stakes (since begin_unstake does not subtract from the total upon transition). If a PENDING_EXIT validator is slashed, their individual stake decreases but total_active_stake is never reduced, causing permanent drift. This inflated total then corrupts proportional distributions in distribute_ordering_reward. The same issue exists in distribute_slash where crediting a PENDING_EXIT recipient doesn't update the total.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 2152d60. Configure here.
| entry.set_stake(stake.sub(actual)?); | ||
| if entry.status() == STATUS_ACTIVE { | ||
| model.try_update_total_active_stake(|s| s.sub(actual))?; | ||
| } |
There was a problem hiding this comment.
Slash skips total_active_stake adjustment for PENDING_EXIT validators
High Severity
Both slash and slash_equivocation only adjust total_active_stake (and active_count) when entry.status() == STATUS_ACTIVE, but PENDING_EXIT validators' stakes are also included in these aggregates — begin_unstake does not reduce them, deferring to process_pending_validators. Slashing a PENDING_EXIT validator reduces its individual stake without updating the aggregates, permanently inflating total_active_stake. For slash_equivocation, the validator is set directly to INACTIVE, so process_pending_validators never runs the deactivation path, making both total_active_stake and active_count permanently stale.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5ac53a4. Configure here.
| let share = if i + 1 == n { | ||
| amount.sub(distributed)? | ||
| } else { | ||
| let s = amount.mul(entry.stake())?.div(total)?; |
There was a problem hiding this comment.
Ordering reward denominator includes non-recipient pending-exit stakes
Medium Severity
distribute_ordering_reward uses model.total_active_stake() as the denominator for proportional share calculation, but this value includes PENDING_EXIT validators' stakes. The function only distributes to STATUS_ACTIVE validators (line 582 filter). This inflates the denominator relative to actual recipients, causing all non-last active validators to receive smaller-than-correct shares. The last active validator absorbs the excess via the remainder mechanism, breaking the stake-weighted proportionality guarantee.
Reviewed by Cursor Bugbot for commit 5ac53a4. Configure here.
| if entry.status() == STATUS_ACTIVE { | ||
| model.try_update_total_active_stake(|s| s.sub(penalty))?; | ||
| model.update_active_count(|c| c.saturating_sub(1)); | ||
| } |
There was a problem hiding this comment.
Slashing PENDING_EXIT validators corrupts total_active_stake permanently
High Severity
Both slash and slash_equivocation only update total_active_stake (and active_count) when entry.status() == STATUS_ACTIVE, but total_active_stake also includes PENDING_EXIT validators (since begin_unstake doesn't reduce it — that happens later in process_pending_validators). Slashing a PENDING_EXIT validator reduces their stake without adjusting the aggregate counters. In slash_equivocation this is especially severe: the status is set to STATUS_INACTIVE, so process_pending_validators will never match or clean up this validator, leaving both total_active_stake and active_count permanently inflated.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit afd8b2b. Configure here.
| let share = if i + 1 == n { | ||
| amount.sub(distributed)? | ||
| } else { | ||
| let s = amount.mul(entry.stake())?.div(total)?; |
There was a problem hiding this comment.
Ordering reward denominator includes non-rewarded PENDING_EXIT stakes
High Severity
distribute_ordering_reward uses model.total_active_stake() as the denominator for stake-weighted proportional shares, but this value includes PENDING_EXIT validators' stakes. The function explicitly filters those validators out, only rewarding STATUS_ACTIVE ones. When PENDING_EXIT validators exist, the inflated denominator causes each non-last active validator's share (amount * stake / total) to be too small. The last active validator (in sorted order) absorbs the entire excess via the remainder logic, creating an arbitrary and unfair reward skew based solely on sort position.
Reviewed by Cursor Bugbot for commit afd8b2b. Configure here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
There are 10 total unresolved issues (including 8 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2e7e76e. Configure here.
| entry.set_stake(stake.sub(actual)?); | ||
| if entry.status() == STATUS_ACTIVE { | ||
| model.try_update_total_active_stake(|s| s.sub(actual))?; | ||
| } |
There was a problem hiding this comment.
Slashing PENDING_EXIT validators permanently inflates total active stake
High Severity
Both slash and slash_equivocation only adjust total_active_stake (and active_count) when entry.status() == STATUS_ACTIVE, but PENDING_EXIT validators' stakes are also counted in those aggregates (since begin_unstake defers the adjustment to process_pending_validators). Slashing a PENDING_EXIT validator reduces its stake without decrementing the aggregate, leaving total_active_stake permanently inflated. For slash_equivocation this is worse: the status is set to INACTIVE, so process_pending_validators will never fire the PENDING_EXIT deactivation path, permanently orphaning both the stake amount and the active_count entry.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 2e7e76e. Configure here.
| } else { | ||
| let s = amount.mul(entry.stake())?.div(total)?; | ||
| distributed = distributed.add(s)?; | ||
| s |
There was a problem hiding this comment.
Ordering reward denominator includes pending-exit validators excluded from distribution
Medium Severity
distribute_ordering_reward uses model.total_active_stake() as the proportional denominator, but that aggregate includes PENDING_EXIT validators' stakes. The actives filter on the other hand only selects STATUS_ACTIVE validators. When any validator is pending exit, the proportional shares sum to less than the full pool, and the last active validator (sorted) absorbs the excess via the remainder-absorption rule, receiving a disproportionately large reward.
Reviewed by Cursor Bugbot for commit 2e7e76e. Configure here.
Adds the slashing parameters as tunable contract state (lambda_slash=30, tau_slash_bps=5000 from specs/params.typ + v1-parameters), seeded at init, with a core-context set_slash_params setter and get_slash_params view — the admin-tunable config pattern for the beta. Adds a core-context slash(x_only_pubkey, amount): saturating stake reduction (min(amount, k_n), shortfall not carried, per spec), burns the tau_slash share from the contract's escrowed stake, and returns slashed/burned/redistributable so the reactor can redistribute the (1-tau_slash) remainder to the file's other nodes (next slice). The node is not removed here — the caller handles role unwinding. Verified clippy-clean across the workspace. Slash amount (lambda_slash * k_f for storage, full stake for equivocation) is computed by the caller; redistribution wiring + a slash unit test land next.
Core-context distribute_slash(recipients, amount) credits the (1-tau_slash) slash remainder — already held by the contract as escrowed stake — equally across the file's other nodes by increasing their pooled stake. Exact conservation regardless of Decimal division rounding: the first n-1 recipients (sorted) each receive amount/n and the last absorbs the remainder. Recipients processed in sorted order for cross-indexer determinism. Completes the slash -> redistribute contract surface; the reactor orchestrates slash() then distribute_slash() on a failed challenge (seam, next).
Regenerate native-contracts/binaries/staking.wasm.br (via native-contracts/build.sh) so the deployed contract actually carries the new slash/distribute_slash/set_slash_params methods — the indexer include_bytes!s this committed blob (database/native_contracts.rs), so source changes aren't live until it's rebuilt. Adds lite (in-process, no bitcoind) tests driving the core-context methods via the auto-generated staking::api wrappers over test_runtime_with_genesis: slash reduces stake and burns the tau_slash share (100->60, burn 20, redistributable 20), saturates at the node's stake, and distribute_slash credits recipients evenly (15 each) leaving non-recipients untouched. All 3 pass locally in ~2.6s.
Core-context distribute_ordering_reward(amount) distributes one block's ordering emission — moved into the staking contract by the reactor — across active validators in proportion to stake, crediting pooled stake. Exact conservation (last sorted validator absorbs the rounding remainder); sorted order for cross-indexer determinism. Rebuilt staking.wasm.br; lite test confirms stake-weighting (stakes 100/300, reward 40 -> 110/330, total 400->440). NOTE: credits stake (compounding), mirroring distribute_slash; whether ordering rewards should instead pay to spendable balance is a spec detail to confirm. The reactor moves the ordering emission (token CORE pool -> staking) before calling this (block-end seam).
Adds slash_equivocation to the staking-economics surface. Equivocation is provable malice (a staker signing two conflicting batches at the same batch_id), so the penalty is the entire stake (λ_equiv = 100%) and the staker is ejected from the active set immediately (status → inactive, active_count and total_active_stake adjusted). The slashed stake is split: a fraction r_evid (default 5%, spec econ.rEvid) is paid to the evidence publisher's spendable balance as a publication bounty — making report publication strictly profitable for any observer and mitigating the free-rider problem — and the remaining (1 − r_evid) is burned. Both move out of the contract's escrowed-stake balance via token::transfer / token::burn. r_evid_bps is admin-tunable and folded into the existing slash-params surface (set_slash_params / get_slash_params / SlashParams now carry it). Core-context only: the reactor verifies the two-conflicting-batches evidence and supplies the offender and publisher keys. Lite test covers the full-stake slash, the 5/95 bounty/burn split, ejection from the active set, and that the bounty lands as spendable (not staked) balance.
Adversarial audit found an exploitable value leak: slash_equivocation paid the r_evid bounty to the named publisher with no check that the publisher wasn't the offender — so an equivocator could name their own key as publisher and recover 5% of their slashed stake, which the spec (§Evidence) explicitly forbids. Fix: when publisher == offender, the bounty is zero and the entire penalty is burned. The broader rule (publisher must not have co-signed either conflicting batch) needs the batch signer sets, which the contract doesn't hold — the reactor must reject such a publisher before calling; documented inline. Lite test covers the self-publication case (bounty 0, full 100% burn, still ejected) alongside the legitimate-publisher case.
… exactly Over 24 deterministic pseudo-random cases (varied stake distributions and pool sizes), asserts the active-stake total grows by exactly the pool and the sum of individual stakes equals the reported total — the last-absorbs-remainder rule leaves no dust unaccounted. (Genesis stakes capped at 1000 on this branch since the #437 mint-cap lift isn't here yet.)
Remove `lambda_slash` from the staking contract — the field, default, init, the `set_slash_params` parameter, and the `slash_params` record. It was stored and exposed but never read by any staking computation: `slash(amount)` takes an explicit amount, so the storage-slash magnitude was dead config here. The storage-slash magnitude (λ_slash · k_f) is filestorage-domain policy and now lives in the filestorage contract; staking's `slash(amount)` stays a generic primitive that applies a caller-supplied amount plus the consensus-domain τ_slash burn/redistribute split. `set_slash_params` now carries only the consensus-domain shares (τ_slash, r_evid).
ff3e3a5 to
006cd58
Compare
Add seeded-random property tests over the slashing surface, mirroring the existing distribute_ordering_reward_conserves pattern (fresh test_runtime_with_genesis per case, core-context calls, post-state asserts): - slash: burned + redistributable == slashed; burned is exactly the τ_slash 50% share; slashed == min(amount, stake) (saturates, never negative); stake drops by exactly slashed. - distribute_slash: Σ recipient stake == n·base + amount (exact, no dust). - slash_equivocation: λ_equiv=100% (full stake taken); burned + bounty == slashed; offender zeroed; self-publication ⇒ no bounty. Slashing is security-critical and adversarially exposed (the audit found a real self-publication leak here), so it warrants conservation properties beyond the point tests. Test-only; no contract change.
…perties tests: slashing-conservation property tests (staking)


The complete new economic surface for the staking contract, as one reviewable PR (folds in the former #438). All changes confined to
native-contracts/staking+ lite tests — no indexer-proper.Storage slashing
λ_slash(default 30) andτ_slash(burn share bps, default 5000), admin-tunable viaset_slash_params/get_slash_params.slash(validator, k_f)slashesλ_slash · k_ffrom pooled stake (saturating), returning the burn/redistribute split. Does not remove from the set — caller handles role unwinding.distribute_slashreturns the deterministic stake-weighted redistribution of the non-burned remainder to a file's other nodes.Equivocation slashing
slash_equivocation(offender, publisher)— equivocation is provable malice, so the penalty is the entire stake (λ_equiv = 100%) and the staker is ejected immediately (status → inactive;active_count/total_active_stakeadjusted).r_evid(default 5%, admin-tunable) is paid to the evidence publisher's spendable balance as a publication bounty (makes reporting strictly profitable for any observer); the remaining 95% is burned. Both move out of the contract's escrowed stake viatoken::transfer/token::burn.r_evid_bpsfolded into the existing slash-params surface.Ordering rewards
distribute_ordering_reward(pool)splits an ordering-reward pool across the active set stake-weighted, deterministic sorted iteration, last-recipient-absorbs-remainder for exact conservation.Notes
Decimal(consensus-safe).staking_slash.rs): slash math + saturation, redistribution conservation, equivocation full-slash + 5/95 split + ejection + spendable-bounty, and ordering-reward stake-weighting.Independent of the token PRs (slashing burns rather than mints).
Note
High Risk
Changes consensus-critical token flows (burn, transfer bounty) and validator stake/active-set accounting; incorrect math or rounding would directly affect economic safety.
Overview
Adds reactor-orchestrated staking economics on the native staking contract and WIT surface, plus in-process lite tests (
staking_slash).Slashing: New storage fields and defaults for
τ_slash(50% burn, bps) andr_evid(5% equivocation bounty).slashreduces a validator’s pooled stake by a caller-supplied amount (saturating), burns theτ_slashshare viatoken::burn, and returns the redistributable remainder without ejecting.distribute_slashcredits that remainder across named recipients with sorted, deterministic equal splits (last recipient absorbs rounding).slash_equivocationtakes the full stake, ejects the offender (inactive, active totals updated), pays an external publisher’s spendable balance (not stake) with no bounty on self-publication, and burns the rest.set_slash_params/get_slash_paramstune the two bps params.Rewards:
distribute_ordering_rewardallocates a core-context pool stake-weighted across active validators, with sorted iteration and remainder absorption so totals conserve.Tests: Cover default slash math, saturation, redistribution, equivocation ejection/bounty rules, ordering rewards, and randomized conservation properties over many seeds.
Reviewed by Cursor Bugbot for commit c6e0daf. Bugbot is set up for automated code reviews on this repo. Configure here.