[Bug] Zero stake_amount when a supplier or gateway finishes unbonding - #90
Merged
Conversation
Both handlers set stakeStatus to Unstaked but left the last known stake on the row, so the entity reads as unstaked and holding stake at the same time and any consumer summing stakeAmount without filtering on stakeStatus counts it as live: on pnf mainnet that is 358,753,838 POKT across 6949 unstaked suppliers, against 245,126,942 actually staked. The reconcile close-outs for applications and validators already zero the amount; this brings the two event handlers that never went through a reconcile in line with them.
Zeroing stake_amount on the Unstaked row breaks the three update_block_unstaked_* reports, which answer "how many tokens were returned in this block" by summing stake_amount of rows whose unstaking_end_block_id is that block: generateReports runs after indexStake in the same block, so the aggregate would read the zero it just wrote and report 0 forever. They now read the amount from the row version immediately before the entity turned Unstaked, which historical indexing already keeps. Verified against 13 blocks on pnf mainnet (5 suppliers, 5 applications, 3 gateways, including a gateway with two stake/unstake cycles and applications whose live rows are already zeroed): the generated SQL reproduces every stored report value exactly.
unstaking_end_block_id carries the chain-announced unbonding_end_height from the event attribute, not the block the event was indexed in, and on pnf mainnet the two differ for thousands of supplier rows. Keying the report on it meant the lookup found nothing at the announced height (the row did not exist yet) and no longer matched at the write block, so those entities were counted in no block at all, while a supplier with several row versions sharing the value was counted once per version. Anchoring on lower(_block_range) also picks up applications closed out by reconcileApplications, which never sets unstaking_end_block_id. Measured on pnf mainnet: 320 blocks carry a supplier unstake against the 199 the old key saw, and the report now counts 7058 entities against 7058 actual Unstaked rows -- one per transition, no double counting -- recovering 8.7M POKT of returned stake that no block reported.
oten91
approved these changes
Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Suppliers and gateways that finish unbonding kept their last stake on the row while being marked
Unstaked, so the entity claims to be unstaked and holding stake at the same time. This zeroes the amount, and fixes the threeupdate_block_unstaked_*reports so they keep working once the amount is gone.Issue
handleSupplierUnbondingEndEventand both gateway unstake handlers setstakeStatus = Unstakedwithout touchingstakeAmount. On pnf mainnet:The phantom total is larger than the real one, so anyone summing
stakeAmountwithout filtering onstakeStatusgets 146% of the true staked supply. The reconcile close-outs for applications and validators already zero the amount; this brings the two handlers that never went through a reconcile in line.Second commit — why the reports had to change too.
update_block_unstaked_{suppliers,apps,gateways}answer "how many tokens were returned in this block" by summingstake_amountof rows whoseunstaking_end_block_idis that block.generateReportsruns afterindexStakein the same block, so once the row is zeroed the aggregate reads the zero it just wrote and reports 0 forever. They now read the amount from the row version immediately before the transition, which historical indexing already stores. This also protectsunstaked_apps_tokens, which would have hit the same wall the next time an application finishes unbonding (the reconcile close-out already zeroes on the same write).The DB functions are recreated by
createDbFunctions()on process start, so the deploy applies the new SQL — no manual migration.Type of change
Testing
Verified against the live pnf mainnet DB, running the SQL generated by the code with the parameter substituted, and comparing to the values already stored in
blocks:Cases the sample covers on purpose:
pokt1mvz6gf...unstaked at 98030, restaked, unstaked again at 118660. Each block reports its own 5100, no cross-contamination.yarn run lint: 0 errors. Fulltscnot run —subql codegendoes not emitsrc/types/proto-interfaces/in this environment, pre-existing and identical onmain; the real build runs in CI.Third commit: the reports were keyed on the wrong column
unstaking_end_block_idcarries the chain-announcedunbonding_end_heightfrom the event attribute (suppliers.ts:378-380,gateways.ts:214-216), not the block the event was indexed in. Applications differ:applications.ts:755usesgetBlockId(event.block). On pnf mainnet the two values diverge for thousands of supplier rows:When they differ the entity is counted in no block at all: at the announced height the row does not exist yet, and at the write block the key no longer matches. The same column also double-counted a supplier when several row versions shared it (
pokt1un6kjf...counted 4x at block 133050 -- 240,040 POKT reported for one 60,010 POKT supplier).The reports now anchor on
lower(_block_range). Measured on mainnet:Every transition is counted exactly once, and 8.7M POKT of returned stake that no block reported is recovered. This also fixes applications closed out by
reconcileApplications, which marks them Unstaked without ever settingunstaking_end_block_id-- those were invisible to the report before.Note this reattributes historical peaks to the block the row was written in rather than the announced height, so a recompute will move some values between blocks.
Recomputing historical reports
On a DB whose entities have their full history, recomputing with
update_block_reports_rangeis safe and strictly improves the data. On a DB missing early history it would zero those blocks: a supplier whose first ever row version is alreadyUnstakedhas nothing earlier to read. On pnf mainnet that gap was closed by reconstructing the missing predecessor version for 1780 suppliers (one block wide,Unstaking, amount taken from the row the indexer itself wrote); check forUnstakedrows with no earlier version before recomputing elsewhere.Known issue, not introduced here
7 applications on mainnet are
Unstakedwith nounstaking_end_block_id(2 of them after block 801233). They were closed out byreconcileApplicationswithout a precedingEventApplicationUnbondingEnd, and the close-out does not set the field itself. Those rows appear in no unstaked report, before or after this PR. Worth a follow-up: either setunstakingEndBlockIdin the close-out or accept that reconcile-detected exits are not reported.Sanity Checklist