Skip to content

[Bug] Zero stake_amount when a supplier or gateway finishes unbonding - #90

Merged
jorgecuesta merged 3 commits into
mainfrom
fix/zero-stake-on-unstaked
Aug 3, 2026
Merged

[Bug] Zero stake_amount when a supplier or gateway finishes unbonding#90
jorgecuesta merged 3 commits into
mainfrom
fix/zero-stake-on-unstaked

Conversation

@jorgecuesta

@jorgecuesta jorgecuesta commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

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 three update_block_unstaked_* reports so they keep working once the amount is gone.

Issue

handleSupplierUnbondingEndEvent and both gateway unstake handlers set stakeStatus = Unstaked without touching stakeAmount. On pnf mainnet:

suppliers   Unstaked: 6949 rows, 358,753,838 POKT   vs  245,126,942 actually staked
gateways    Unstaked:    2 rows,      10,100 POKT

The phantom total is larger than the real one, so anyone summing stakeAmount without filtering on stakeStatus gets 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 summing stake_amount of rows whose unstaking_end_block_id is that block. generateReports runs after indexStake in 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 protects unstaked_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

  • Bug fix

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:

entity blocks checked result
suppliers 862640, 861240, 853200, 848820, 847140 5/5 exact match
applications 798600, 797940, 797880, 797820, 797760 5/5 exact match
gateways 98030, 118660, 650160 3/3 exact match

Cases the sample covers on purpose:

  • Rows already zeroed — the applications above had their live rows zeroed by a manual patch, so they reproduce the post-fix scenario: the new query recovers the correct amount from the previous version where the old query would return 0.
  • Two stake/unstake cycles on one entity — gateway pokt1mvz6gf... unstaked at 98030, restaked, unstaked again at 118660. Each block reports its own 5100, no cross-contamination.

yarn run lint: 0 errors. Full tsc not run — subql codegen does not emit src/types/proto-interfaces/ in this environment, pre-existing and identical on main; the real build runs in CI.

Third commit: the reports were keyed on the wrong column

unstaking_end_block_id carries the chain-announced unbonding_end_height from the event attribute (suppliers.ts:378-380, gateways.ts:214-216), not the block the event was indexed in. Applications differ: applications.ts:755 uses getBlockId(event.block). On pnf mainnet the two values diverge for thousands of supplier rows:

block range Unstaked rows field == write block differs
< 100,000 41 0 41
100,000-200,000 2,731 418 2,313
200,000-300,000 289 231 58
300,000-500,000 677 677 0
500,000-700,000 1,196 1,133 63
700,000-900,000 2,124 2,121 3

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:

old key new key
blocks carrying a supplier unstake 199 320
entities counted (vs 7058 actual Unstaked rows) -- 7058
total returned stake reported 356,402,199 POKT 365,145,191 POKT

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 setting unstaking_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_range is 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 already Unstaked has 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 for Unstaked rows with no earlier version before recomputing elsewhere.

Known issue, not introduced here

7 applications on mainnet are Unstaked with no unstaking_end_block_id (2 of them after block 801233). They were closed out by reconcileApplications without a preceding EventApplicationUnbondingEnd, 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 set unstakingEndBlockId in the close-out or accept that reconcile-detected exits are not reported.

Sanity Checklist

  • I have tested my changes using the available tooling
  • I have commented my code
  • I have performed a self-review of my own code; both comments & source code

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.
@jorgecuesta
jorgecuesta requested a review from oten91 August 3, 2026 19:24
@jorgecuesta jorgecuesta self-assigned this Aug 3, 2026
@jorgecuesta jorgecuesta added the bug Something isn't working label Aug 3, 2026
@jorgecuesta
jorgecuesta merged commit ccd14b3 into main Aug 3, 2026
3 checks passed
@jorgecuesta
jorgecuesta deleted the fix/zero-stake-on-unstaked branch August 3, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants