fix(gov): bound EndBlock vote tally work - #4000
Conversation
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4000 +/- ##
==========================================
- Coverage 61.29% 58.04% -3.25%
==========================================
Files 2163 2206 +43
Lines 188757 185304 -3453
==========================================
- Hits 115693 107556 -8137
- Misses 62338 67924 +5586
+ Partials 10726 9824 -902
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
PR SummaryHigh Risk Overview Incremental tallying ( Vote queries read pending plus archived votes via Reviewed by Cursor Bugbot for commit 10412a5. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7c3f7c7. Configure here.
There was a problem hiding this comment.
Spreading the gov tally across blocks is the right fix for the unbounded EndBlock work, but freezing the validator/bonded-token snapshot at tally start while still reading delegations live in later blocks makes the tally arithmetic unsound (deductions can exceed snapshot shares, yielding negative validator power). Several secondary gaps — archived votes invisible to vote queries, tally progress dropped on genesis export, and an unbounded (now heavier) Tally on the public query path — should also be addressed.
Findings: 1 blocking | 7 non-blocking | 5 posted inline
Blockers
- None at the file/PR level.
- 1 blocking issue(s) flagged inline on specific lines.
Non-blocking
- [suggestion] Cleanup only receives the budget left over after tallying (
CleanupTallyVotes(ctx, remainingVotes)inabci.go). While a large tally repeatedly consumes the full 1,000-record budget, archived rounds from earlier proposals are never reclaimed and the0x31archive grows. Consider reserving a slice of the budget for cleanup, or draining cleanup before tallying, so archive removal cannot be starved indefinitely. - [suggestion] Test gap: there is no multi-block regression test for the expedited→regular conversion path (an expedited tally that spans several blocks, converts, then re-tallies with both round archives live), nor for a delegation change occurring between two blocks of the same tally. Both are the cases the new persisted-snapshot design actually changes; the current tests only exercise single-round, single-block-delta scenarios.
- 4 suggestion(s)/nit(s) flagged inline on specific lines.
- 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.
Pre-existing issues
- [suggestion]
grpc_query.go:275/querier.go:177compute a provisional tally for a voting-period proposal by callingTally, which iterates every vote record. This is already an unbounded, publicly reachable query independent of this PR.
|
Addressed the remaining review-summary items in d67cd15:
Validation completed:
|
|
@seidroid review |
There was a problem hiding this comment.
Spreading the gov tally across blocks with a persisted accumulator, round-scoped vote archives, a merged read view, and a shared cleanup budget is a solid structure, and the previous round's findings on vote visibility, query mutation, genesis round-trip, cleanup starvation, and spec layout are genuinely addressed. The remaining blocker is the one Codex also flags: mixing a frozen validator snapshot with live delegation reads is still unsound — the greedy per-voter clamp bounds each validator's total but makes a legitimate voter's counted power depend on other delegators' post-deadline stake movements and on vote-key ordering, and can silently drop their vote entirely.
Findings: 1 blocking | 6 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
- 1 blocking issue(s) flagged inline on specific lines.
Non-blocking
- [suggestion]
tallyProgressis consensus-critical state but is serialized withencoding/json, while every other record inx/govgoes throughkeeper.cdc. The app hash now depends on Go's JSON encoder forsdk.Dec/sdk.Int/proto structs, and the simulation decoder can only hex-dump the blob. A proto message would keep the module consistent and give the store diff a readable decoder. - [suggestion] The whole progress blob is re-encoded and re-written to IAVL on every block a tally advances, including the immutable part of the snapshot (validator address, bonded tokens, snapshot shares — roughly 200 bytes per bonded validator). For a long spam-induced tally that is the full validator set written per block. Consider splitting the immutable snapshot from the mutable accumulator, or keying deductions per validator, so only what changed is rewritten.
- [suggestion]
if !complete { return true }inEndBlockerstopsIterateActiveProposalsQueueentirely, so a single vote-spammed proposal defers finalization of every other expired proposal until it drains at ~900 records/block. Work per block is correctly bounded, but head-of-line blocking across proposals is a new liveness property worth stating in the spec (or working around by continuing to the next proposal when the leader is out of budget). - [suggestion] While a tally is in progress
AddVotereturnsErrInactiveProposal, butQueryProposalstill reportsPROPOSAL_STATUS_VOTING_PERIOD, so a client cannot distinguish "voting closed, tallying" from a genuine status mismatch. Consider a distinct error or exposingIsTallyingon the proposal query. - 1 suggestion(s)/nit(s) flagged inline on specific lines.
- 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.
Pre-existing issues
- [suggestion]
grpc_query.go:274andquerier.go:177compute a provisional tally for a voting-period proposal by callingTally, which iterates every vote record with no bound. This is a publicly reachable unbounded query independent of this PR (and this PR makes it cheaper than the previous revision by removing the writes).
| panic(fmt.Sprintf("invalid tally cleanup key length %d", len(key))) | ||
| } | ||
| proposalID = types.GetProposalIDFromBytes(key[1:9]) | ||
| switch key[9] { |
There was a problem hiding this comment.
[suggestion] This switch is a second, independent copy of the round-byte encoding defined by types.tallyRound (types/keys.go:140), and it reads inverted (0 → expedited) relative to the boolean it decodes. Because tallyRound is unexported, the keeper cannot reuse it, so flipping the encoding in types would leave this decode silently wrong — cleanup would then delete the other round's archive. Put the decode next to the encode (e.g. an exported types.SplitTallyCleanupKey) so the mapping lives at one choke point.

Describe your changes and provide context
Governance proposal finalization previously iterated and deleted every stored vote in one
EndBlock, allowing a proposal with many zero-power voters to concentrate unbounded work in its expiry block.This change:
EndBlockProposal execution, deposit handling, hooks, and final events remain deferred until every vote has been processed.
Testing performed to validate your change
go test ./sei-cosmos/x/gov/... -count=1go test -race ./sei-cosmos/x/gov ./sei-cosmos/x/gov/keeper -count=1go vet ./sei-cosmos/x/gov/...make fmtcheckwith golangci-lint v2.8.0 rebuilt for Go 1.25./sei-cosmos/x/gov/...— 0 issues