Skip to content

staking economics: slashing (storage + equivocation) + ordering rewards#440

Open
adamkrellenstein wants to merge 12 commits into
mainfrom
feat/ordering-rewards
Open

staking economics: slashing (storage + equivocation) + ordering rewards#440
adamkrellenstein wants to merge 12 commits into
mainfrom
feat/ordering-rewards

Conversation

@adamkrellenstein

@adamkrellenstein adamkrellenstein commented May 30, 2026

Copy link
Copy Markdown
Contributor

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

  • Params λ_slash (default 30) and τ_slash (burn share bps, default 5000), admin-tunable via set_slash_params / get_slash_params.
  • slash(validator, k_f) slashes λ_slash · k_f from pooled stake (saturating), returning the burn/redistribute split. Does not remove from the set — caller handles role unwinding.
  • distribute_slash returns 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_stake adjusted).
  • The slashed stake splits: 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 via token::transfer / token::burn.
  • r_evid_bps folded 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

  • All math on the host's deterministic fixed-point Decimal (consensus-safe).
  • The reactor verifies evidence / supplies pools and recipients; these methods compute + apply the allocations (cross-contract token seam handled here for burns/bounty, returned for redistribution).
  • Lite tests (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) and r_evid (5% equivocation bounty). slash reduces a validator’s pooled stake by a caller-supplied amount (saturating), burns the τ_slash share via token::burn, and returns the redistributable remainder without ejecting. distribute_slash credits that remainder across named recipients with sorted, deterministic equal splits (last recipient absorbs rounding). slash_equivocation takes 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_params tune the two bps params.

Rewards: distribute_ordering_reward allocates 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.

.map(|e| e.status() == STATUS_ACTIVE)
.unwrap_or(false)
})
.collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6f27c9a. Configure here.

@adamkrellenstein
adamkrellenstein force-pushed the feat/ordering-rewards branch from 6f27c9a to b418ff1 Compare May 30, 2026 20:00
@adamkrellenstein adamkrellenstein changed the title staking: ordering-reward distribution (stake-weighted) [economic layer 3/N] staking economics: slashing + stake-weighted ordering rewards May 30, 2026
@adamkrellenstein
adamkrellenstein changed the base branch from feat/staking-slashing to main May 30, 2026 21:00
@adamkrellenstein
adamkrellenstein force-pushed the feat/ordering-rewards branch from b418ff1 to 2152d60 Compare May 31, 2026 01:08
entry.set_stake(stake.sub(actual)?);
if entry.status() == STATUS_ACTIVE {
model.try_update_total_active_stake(|s| s.sub(actual))?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2152d60. Configure here.

@adamkrellenstein adamkrellenstein changed the title staking economics: slashing + stake-weighted ordering rewards staking economics: slashing (storage + equivocation) + ordering rewards May 31, 2026
Comment thread native-contracts/staking/src/lib.rs
entry.set_stake(stake.sub(actual)?);
if entry.status() == STATUS_ACTIVE {
model.try_update_total_active_stake(|s| s.sub(actual))?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit afd8b2b. Configure here.

Comment thread native-contracts/staking/src/lib.rs
let share = if i + 1 == n {
amount.sub(distributed)?
} else {
let s = amount.mul(entry.stake())?.div(total)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit afd8b2b. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Fix All in Cursor

❌ 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))?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2e7e76e. Configure here.

} else {
let s = amount.mul(entry.stake())?.div(total)?;
distributed = distributed.add(s)?;
s

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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).
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)
@adamkrellenstein adamkrellenstein added enhancement New capability or improvement area: economics Token / staking / storage economics labels Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: economics Token / staking / storage economics enhancement New capability or improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant