Skip to content

PLT-980: Index delegations by validator at the v6.8 upgrade - #4518

Open
amir-deris wants to merge 24 commits into
mainfrom
amir/plt-980-validator-delegations-query-second-index
Open

amir-deris wants to merge 24 commits into
mainfrom
amir/plt-980-validator-delegations-query-second-index

Conversation

@amir-deris

@amir-deris amir-deris commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

The staking precompile's validatorDelegations reverts for most validators on atlantic-2, including bonded ones with thousands of delegators (PLT-1447). Delegations are stored by delegator, so listing one validator's delegations scans the whole delegation store. The precompile path keeps the v6.6 paginator for consensus safety, and that paginator fails once it has scanned 10,000 entries without collecting a full page of 100. Any validator whose delegations are sparse in that keyspace can never return a page, and the caller gets a bare revert with no cursor.

This adds a delegation-by-validator index under prefix 0x37 (validator | delegator, empty value), a ready marker at 0x38, and a backfill cursor at 0x39. Once the marker is present, Querier.ValidatorDelegations pages over a single validator's prefix, so each page costs about one read per result; before that it keeps the full scan. The marker is versioned state, so replaying or tracing heights before the index is ready still takes the old path and reproduces the old result, and the frozen v6.6/v6.7 precompiles behave as before. The query serves the staking precompile and REST/gRPC alike, so REST/gRPC callers also get the index once it is ready.

Populating the index. The v6.8 upgrade handler does not build the index; it only writes the cursor, which starts a backfill. From then on SetDelegation and RemoveDelegation maintain the index (their marker reads are gasless, so pre-upgrade delegation gas is unchanged), and the staking EndBlocker indexes the next 1,000 delegations after the cursor each block. When the cursor reaches the end it is deleted and the ready marker is set. Building it in one pass inside the handler would make the upgrade block's work and memory grow with the delegation count, which anyone can inflate ahead of a known upgrade height. Pending writes also slow every staking iterator created in that block, since the cache store rescans them per iterator. The batch runs in EndBlock rather than BeginBlock so that no transaction iterates the staking store while a batch is pending. InitGenesis still builds the index in one pass with MigrateDelegationByValIndex, so fresh chains start with it ready.

Pagination across the switch. The index path returns next_key as a length-prefixed delegator rather than a full delegation key. A forward cursor obtained from the scan before the index was ready resumes at the delegation it names, so a client paging across that height neither skips nor repeats a delegator. A reverse page cannot resume from such a cursor and is rejected with InvalidArgument.

This changes consensus at v6.8: from the upgrade height, delegation writes also write index entries, each block runs a backfill batch until the index is ready, and precompile results change once it is. Coverage: staking keeper and genesis tests for the index, the batched backfill (including delegations written and removed on either side of the cursor) and scan-cursor resumption; upgrade_v68 in-process tests that the handler only starts the backfill and the EndBlocker completes it; and make upgrade-test-offline FROM_REF=release/v6.7 TO_REF=<this branch>, whose v6.7 source phase now seeds delegations and whose target phase requires exactly one index entry per delegation after the upgrade block.

The existing size of the index seems to be around 80k, which the backfill covers in about 80 blocks:
image

amir-deris and others added 18 commits August 21, 2026 14:59
Dual-write a validator→delegator secondary index on delegation mutations and
expose a dev CLI to benchmark one-shot backfill before choosing migration strategy.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use Commit(true) so storev2 does not panic, reject --write with --height,
and close the app so SeiDB flushes before exit.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep SetDelegation and RemoveDelegation app-hash neutral before the
upgrade by writing the validator index only once ClosestUpgradeName
reaches v6.7.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the subcommand PersistentPreRunE that shadowed the root seid hook
and collapsed duplicate app.New paths into one call with optional
LoadHeight.

Co-authored-by: Cursor <cursoragent@cursor.com>
Place 0x37 after the other delegation prefixes and smoke-test dry-run,
--write commit, and incompatible flag handling for the backfill tool.

Co-authored-by: Cursor <cursoragent@cursor.com>
Dual-write on DeliverTx and gate pre-v6.7 behavior only for tracing
replay via ClosestUpgradeName, matching the distribution keeper pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ress.

Clarify that the CLI is for analysis only, require confirmation before running, and emit stderr heartbeats while scanning delegations so long mainnet runs are observable.

Co-authored-by: Cursor <cursoragent@cursor.com>
Backfill the secondary index in the upgrade handler so operators never need a separate migration step, gate dual-writes on a store marker, and remove the CLI backfill command that could mutate live state.

Co-authored-by: Cursor <cursoragent@cursor.com>
CI Go/Lint and lint jobs failed the gofmt -s -l check because the
x/upgrade import was out of alphabetical order relative to
x/staking/types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ond-index

Resolve the app/upgrades.go conflict by keeping main's v6.7 handler and moving
the delegation-by-validator index migration into the v6.8 handler, since v6.7
has already shipped. The simulation decoder was deleted on main, so its index
cases go with it. The legacy v6.7 staking precompile is restored to main's
frozen copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
InitGenesis now populates the index and sets its ready marker, so chains
started from genesis read validator delegations from the index rather than
falling back to the full scan for their whole life.

The migration no longer probes for an existing entry before each write: no
entry can exist while the index is not ready. ValidatorDelegationsIndexed
builds each lookup key in a fresh slice and wraps pagination errors as
ValidatorDelegations does.

The v6.8 upgrade tests cover the handler populating one index entry per
delegation, and the offline target phase accepts the index keys as part of
the upgrade block's specified writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v6.7 fixture held no delegations, so the target phase's index check had
nothing to compare. The source phase now stores delegations to two validators
through the v6.7 keeper. The staking store is not among the snapshotted
retained stores, so the touched-key list does not need an index entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedOct 13, 2026, 6:25 PM

@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.52542% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.33%. Comparing base (2b12a2e) to head (ed8034f).

Files with missing lines Patch % Lines
sei-cosmos/x/staking/keeper/delegation_index.go 92.59% 4 Missing ⚠️
sei-cosmos/x/staking/keeper/grpc_query.go 88.88% 4 Missing ⚠️
sei-cosmos/x/staking/abci.go 83.33% 1 Missing ⚠️
sei-cosmos/x/staking/genesis.go 50.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4518      +/-   ##
==========================================
+ Coverage   57.29%   57.33%   +0.03%     
==========================================
  Files        2140     2141       +1     
  Lines      167395   167502     +107     
==========================================
+ Hits        95914    96030     +116     
+ Misses      71476    71467       -9     
  Partials        5        5              
Flag Coverage Δ
sei-chain 55.50% <91.52%> (+0.03%) ⬆️
sei-db 75.17% <ø> (ø)
sei-db-state-db 79.04% <ø> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
app/upgrades.go 44.24% <100.00%> (+0.49%) ⬆️
sei-cosmos/x/staking/keeper/delegation.go 85.43% <100.00%> (+0.19%) ⬆️
sei-cosmos/x/staking/types/keys.go 52.20% <100.00%> (+5.38%) ⬆️
sei-cosmos/x/staking/abci.go 92.30% <83.33%> (-7.70%) ⬇️
sei-cosmos/x/staking/genesis.go 67.32% <50.00%> (+5.71%) ⬆️
sei-cosmos/x/staking/keeper/delegation_index.go 92.59% <92.59%> (ø)
sei-cosmos/x/staking/keeper/grpc_query.go 81.67% <88.88%> (+1.13%) ⬆️

... and 28 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The index readiness check on every delegation write was a metered read, and
genesis marks the index ready, so EVM regression replays of v6.0.x
transactions that touch delegations reported extra gas. Read the marker
without charging gas, and clear it in the regression setup for versions
before v6.8, alongside the incremental-tally marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@amir-deris
amir-deris marked this pull request as ready for review October 9, 2026 21:25
@cursor

cursor Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

PR Summary

High Risk
Consensus- and state-affecting staking changes at v6.8: paced index backfill in EndBlocker, dual-writes on delegation updates, and different ValidatorDelegations query/precompile behavior once the index is ready.

Overview
Adds a delegation-by-validator secondary index in staking so ValidatorDelegations (gRPC/REST and the staking precompile) no longer has to scan the full delegation store—a path that reverts once the v6.6 paginator hits MaxScanLimit without filling a page.

New store prefixes (0x37 index, 0x38 ready, 0x39 backfill cursor) are introduced. SetDelegation / RemoveDelegation dual-write the index once the index is ready or a backfill has started (marker reads are gasless so pre-upgrade delegation tx gas is unchanged). Genesis builds the index in one pass via MigrateDelegationByValIndex. At v6.8, the upgrade handler only calls StartDelegationByValIndexBackfill; EndBlocker indexes up to 1000 delegations per block until the ready marker is set. Querier.ValidatorDelegations pages the per-validator prefix when ready and otherwise keeps the legacy full-store scan for historical replay.

Tests cover keeper/genesis backfill edge cases, in-process v6.8 upgrade + EndBlocker completion, and offline upgrade fixtures that seed pre-index delegations and assert a 1:1 index after migration. EVM regression setup clears the ready marker for simulated heights before v6.8.

Reviewed by Cursor Bugbot for commit ed8034f. Bugbot is set up for automated code reviews on this repo. Configure here.

seidroid[bot]
seidroid Bot previously requested changes Oct 9, 2026

@seidroid seidroid 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.

Adds a delegation-by-validator index to staking. The v6.8 upgrade handler and InitGenesis build it, and the staking precompile's validatorDelegations now pages through it, which fixes the scan-limit reverts. One blocker: the frozen v6.8 precompile snapshot under legacy/v68 still calls the unindexed query, so it no longer matches what v6.8 runs. Codex's single finding is kept as a nit: the byte overflow is real, but the address verifier only allows 20- or 32-byte addresses, so it cannot be reached on Sei.

Blocking

1 finding on the changed lines, as inline comments.

1 nit, not posted on the code
  • sei-cosmos/x/staking/types/keys.go:210 — (from codex) valAddrLen is a byte, so 1+valAddrLen wraps to 0 at a 255-byte length and valAddrLen+2 wraps at 254. Sei's address verifier only accepts 20- or 32-byte addresses, so this can't happen on chain, and the code copies the existing UBD/RED index helpers. Converting the length to int before slicing would make it robust.

seidroid review · decision request_changes · session be088953e8794fc89a0309b055de7471 · turn resp_claude_b25d91efca8b363bd86a9631676e2fe9 · item 13d743706fc659838f58d96385b5bc9e

Findings: 1 blocking | 0 non-blocking | 1 posted inline

Comment thread precompiles/staking/staking.go Outdated

@seidroid seidroid 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.

The only change since my last review is that the frozen v6.8 snapshot (precompiles/staking/legacy/v68/staking.go) now also calls ValidatorDelegationsIndexed, so v6.8 heights keep the indexed behaviour after the next version bump; that resolves my one blocker, and nothing else blocks. Codex's finding is still kept as a nit: the byte overflow is real, but the address verifier only allows 20- or 32-byte addresses, so it cannot be reached on Sei.

1 nit, not posted on the code
  • sei-cosmos/x/staking/types/keys.go:210 — (from codex) valAddrLen is a byte, so 1+valAddrLen wraps to 0 at a 255-byte length and valAddrLen+2 wraps at 254. Sei's address verifier only accepts 20- or 32-byte addresses, so this can't happen on chain, and the code copies the existing UBD/RED index helpers. Converting the length to int before slicing would make it robust.

seidroid review · decision approve · session be088953e8794fc89a0309b055de7471 · turn resp_claude_b6dc99eeb08693602e805b9a989d017a · item da76bc00a60e51fd942017b35d43ae39

Findings: 0 blocking | 0 non-blocking | 0 posted inline

@seidroid
seidroid Bot dismissed their stale review October 9, 2026 22:14

Superseded: the latest review found nothing blocking in this change.

@amir-deris
amir-deris requested a review from masih October 12, 2026 19:02
Comment thread sei-cosmos/x/staking/keeper/grpc_query.go Outdated
Fold the indexed read into ValidatorDelegations behind
DelegationByValIndexReady instead of a separate ValidatorDelegationsIndexed
query, so the precompiles, REST and gRPC share one path. Heights before the
index is ready still take the scan and reproduce its results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@amir-deris

Copy link
Copy Markdown
Contributor Author

@seidroid review

Comment thread sei-cosmos/x/staking/keeper/grpc_query.go

@seidroid seidroid 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.

The PR now has Querier.ValidatorDelegations itself switch to the index once the ready marker exists, so the v6.6, v6.7, v6.8 and current precompiles, and REST/gRPC, all take the scan below v6.8 and the index from v6.8. That routing also settles my earlier legacy/v68 blocker, nothing blocks, and I verified both of codex's findings and kept them: the cursor finding as a suggestion and the keys.go overflow as a nit, since only 20- and 32-byte addresses pass the address check.

Non-blocking

1 finding on the changed lines, as inline comments.

1 nit, not posted on the code
  • sei-cosmos/x/staking/types/keys.go:796 — (from codex) valAddrLen is a byte, so 1+valAddrLen wraps to 0 at a 255-byte length and valAddrLen+2 wraps at 254. Sei's address verifier only accepts 20- or 32-byte addresses, so this can't happen on chain, and the code copies the existing UBD/RED index helpers. Converting the length to int before slicing would make it robust.

seidroid review · decision approve · session be088953e8794fc89a0309b055de7471 · turn resp_claude_a9026a53ec5ce8c8bfb8128b65962b31 · item d05f98094900531e88bb10e52a0b248e

Findings: 0 blocking | 1 non-blocking | 1 posted inline

amir-deris and others added 2 commits October 13, 2026 11:18
Populating the delegation-by-validator index in the v6.8 handler made the
upgrade block's work and memory grow with the delegation count, which
anyone can inflate ahead of a known upgrade height. The handler now only
starts a backfill: a cursor under 0x39 marks it in progress, delegation
writes maintain the index from then on, and the staking EndBlocker
indexes 1000 delegations per block after the cursor until it reaches the
end and marks the index ready. Running the batch in EndBlock keeps its
pending writes out of the staking cache while transactions iterate it.

ValidatorDelegations keeps the scan until the index is ready. Genesis
still indexes in one pass. A non-positive backfill limit is rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@amir-deris

Copy link
Copy Markdown
Contributor Author

@seidroid review

@seidroid seidroid 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.

The v6.8 handler now only starts the index build, and the staking EndBlocker indexes 1,000 delegations per block behind a cursor at 0x39; delegation writes keep the index current once the build starts, so changes on either side of the cursor still leave it exact, and nothing blocks. I kept both of codex's findings after checking them: the stale scan cursor still silently skips one delegation instead of failing as the description says (suggestion), and the keys.go byte overflow can't happen with Sei's 20- or 32-byte addresses (nit).

Non-blocking

1 finding on the changed lines, as inline comments.

1 nit, not posted on the code
  • sei-cosmos/x/staking/types/keys.go:211 — (from codex) valAddrLen is a byte, so 1+valAddrLen wraps to 0 at a 255-byte length and valAddrLen+2 wraps at 254. Sei's address verifier only accepts 20- or 32-byte addresses, so this can't happen on chain, and the code copies the existing UBD/RED index helpers. Converting the length to int before slicing would make it robust.

seidroid review · decision approve · session be088953e8794fc89a0309b055de7471 · turn resp_claude_8dda07b79368f4c794a5ab3ee43d914e · item 3686865114ac52d08155a8a7383c28bf

Findings: 0 blocking | 1 non-blocking | 1 posted inline

valPrefix := types.GetDelegationsByValIndexKey(valAddr)
indexStore := prefix.NewStore(store, valPrefix)

pageRes, err := query.Paginate(ctx, indexStore, req.Pagination, func(key []byte, _ []byte) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion — (from codex) The description says a scan cursor from before the switch "will not resume", but it does resume, one entry too late. The scan cursor is len|del|len|val, and the index key len|del is a prefix of it, so it sorts first and the cursor's own delegator is dropped from the page with no error. Any client holding a cursor when the index takes over loses one delegator. Fix it by trimming a cursor longer than the delegator segment back to len|del, or by rejecting it.

@alexander-sei
alexander-sei self-requested a review October 13, 2026 20:51

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants