Repository navigation
fix(scanner): decrement Staked by the full balance before zeroing it - #2140
nullPointerEnjoyer wants to merge 1 commit into
Conversation
Stake-pool spends zeroed the stored pool record before reading the pledge for the statistic decrement, so the Staked statistic was never decremented when a pool was closed. Capture the pool's full stake balance (pledge plus accumulated staker rewards) before decommissioning and decrement with that, matching what creation and block rewards add. Both spend paths are covered: pool outputs spent as regular transaction inputs and pool outputs spent as block-reward outputs. The same fix is mirrored in the sync simulation's statistics model. Regression test covers decommissioning all three spend shapes with the statistic checked after every block, including a delegation that outlives its pool.
|
🔍 OpenCodeReview found 7 issue(s) in this PR.
📄
|
| decrease_statistic_amount( | ||
| db_tx, | ||
| CoinOrTokenStatistic::Staked, | ||
| &staker_balance, | ||
| CoinOrTokenId::Coin, | ||
| block_height, | ||
| ) | ||
| .await; | ||
| Ok(pool_data) |
There was a problem hiding this comment.
decrease_statistic_amount internally panics via expect("Balance should not underflow"). This change increases the subtracted amount from pledge-only to the full staker_balance (pledge + staker_rewards), so underflow is now more reachable if the Staked statistic is inconsistent with pool state (e.g., after a partial reorg, an interrupted statistic update, or a pre-existing drift). In that case this will panic mid-scan instead of surfacing a recoverable error, unlike the rest of this helper which propagates Result. Consider propagating the subtraction as a typed error (or at least logging the statistic/pool IDs before the panic) so a corrupted statistic is diagnosable rather than a bare arithmetic panic.
Suggestion:
| decrease_statistic_amount( | |
| db_tx, | |
| CoinOrTokenStatistic::Staked, | |
| &staker_balance, | |
| CoinOrTokenId::Coin, | |
| block_height, | |
| ) | |
| .await; | |
| Ok(pool_data) | |
| decrease_statistic_amount( | |
| db_tx, | |
| CoinOrTokenStatistic::Staked, | |
| &staker_balance, | |
| CoinOrTokenId::Coin, | |
| block_height, | |
| ) | |
| .await | |
| .map_err(|error| { | |
| ApiServerStorageError::LowLevelStorageError(format!( | |
| "staked statistic underflow decommissioning pool {pool_id}: {error}", | |
| )) | |
| })?; | |
| Ok(pool_data) |
| let staked = statistics | ||
| .entry(CoinOrTokenStatistic::Staked) | ||
| .or_default() | ||
| .get_mut(&CoinOrTokenId::Coin) | ||
| .unwrap(); | ||
| *staked = (*staked - pool_balance).unwrap(); |
There was a problem hiding this comment.
The outputs side of this helper only adds the pool pledge to Staked (line ~600), but the input side subtracts the pool's full staker_balance(), which includes staker rewards accrued after pool creation. If a pool has accumulated rewards when its output is spent, the subtraction can exceed the tracked Staked amount, so (*staked - pool_balance).unwrap() panics with an underflow (and get_mut(&CoinOrTokenId::Coin).unwrap() would also panic if the Staked entry was never created). Since this is a statistics-simulation invariant check, it would be safer to handle these cases explicitly (e.g. entry().or_insert(Amount::ZERO) and a saturating/checked subtraction with a descriptive panic message) so a mismatch produces a clear failure instead of an opaque Amount-underflow panic.
Suggestion:
| let staked = statistics | |
| .entry(CoinOrTokenStatistic::Staked) | |
| .or_default() | |
| .get_mut(&CoinOrTokenId::Coin) | |
| .unwrap(); | |
| *staked = (*staked - pool_balance).unwrap(); | |
| let staked = statistics | |
| .entry(CoinOrTokenStatistic::Staked) | |
| .or_default() | |
| .entry(CoinOrTokenId::Coin) | |
| .or_insert(Amount::ZERO); | |
| *staked = (*staked - pool_balance).unwrap_or_else(|_| { | |
| panic!( | |
| "Staked statistic underflow spending pool {pool_id:?} \ | |
| (balance {pool_balance})" | |
| ) | |
| }); |
Root cause
Both stake-pool spend handlers in
api-server/scanner-lib/src/blockchain_state/mod.rszeroed the stored pool record before reading the pledge for the statistic decrement, so the decrement always subtracted zero:PoolData::decommission_pool()zeroespledge_amountandstaker_rewards, sopledge_amount()read after the call is alwaysAmount::ZERO.Impact
Every pool closure leaves its full stake-at-death in the public
stakedstatistic forever. On mainnet the statistic currently exceeds the real staked supply by ~33M ML (~17%), consistent with ~170 dead pools × ~179k ML.Fix
A shared helper
decommission_pool_and_decrement_stakednow captures the pool'sstaker_balance()(pledge plus accumulated staker rewards — matching what creation and block rewards add to the statistic) before decommissioning, then decrementsStakedwith it:OutPointSourceId::Transactionbranch: pool outputs spent as regular transaction inputs.OutPointSourceId::BlockRewardbranch: pool outputs spent as block-reward outputs (keeps the existing address-transactions bookkeeping via the returned record's decommission destination).The stored record is still zeroed exactly as before (
decommission_poolsemantics unchanged); only the decrement source is corrected. The sync simulation's statistics model was updated to mirror the same behavior (it previously never decremented on pool spends at all).Test evidence
decommission_and_delegation_spends_update_staked_statistic(api-server/scanner-lib/src/sync/tests/mod.rs): 9-block scenario covering pool-creation pledges, pool spends of both shapes (Transaction-sourced andBlockReward-sourced), the stake-spend maturity lock, a delegation that outlives its pool, and a delegation spend after the pool is gone — with theStakedstatistic asserted after every block and decommissioned records checked for zeroed pledge/balance.simulation.rsmodel updated to decrement on pool spends; the whole sync simulation suite passes against the scanner.cargo test -p pos-accounting -p api-blockchain-scanner-lib -p api-server-backend-test-suite: 46 passed, 0 failed.cargo clippy --all-targetsclean;./do_checks.shclean.Known follow-up (out of scope)
Databases that already synced with the previous code keep the permanently overstated statistic — no storage-version bump in this PR, so existing deployments are not forced into a full reindex. Repairing the historical mainnet value needs a reindex/migration decision (or a targeted statistic rebuild) and should be tracked separately.
Delegation accounting is unaffected: the delegation-spend decrement is separate and already correct.