IIP-59 PR 5.5a: populate CandidatePollSnapshot.Entries via incremental VoterWeightView - #4952
Closed
envestcc wants to merge 4 commits into
Closed
Conversation
Introduces IIP-59's incremental per-(candidate, voter) weight aggregate.
The view lives inside viewData alongside candCenter and bucketPool, and
its deterministic 32-byte digest is persisted under a new
_voterWeights state-key tag in StakingNamespace.
Type layer (voter_weight_view.go):
* VoterWeightView interface with three overlay implementations —
voterWeightBase (terminal, sorted-by-voter map),
voterWeightWrap (read-through overlay for viewData.Snapshot), and
voterWeightFork (commit-in-clone overlay for viewData.Fork).
* Apply(candID, voter, delta) — additive, negative-clamps-to-zero,
unknown-voter withdraw is silent no-op.
* VoterWeightsByCandidate returns entries pre-sorted by voter bytes
lexicographically so downstream freeze paths get a stable order for
free.
* Hash() is a deterministic digest over (candID, voter, weight) triples
sorted the same way — safe to compare across nodes to detect a
missed hook.
* voterWeightDigest is the on-chain persistence form: exactly 32 bytes;
Deserialize rejects any other length so corruption fails loudly.
* NewVoterWeightViewFromBuckets rebuilds the full in-memory state from
an iterator over native + contract-staking buckets. Called at
CreateBaseView; the rebuild-vs-persisted digest comparison detects
divergence between the incremental hooks and ground truth.
Integration (viewdata.go, candidate_statereader.go,
candidate_statemanager.go, protocol.go):
* viewData gains a voterWeights field routed through Fork/Snapshot/
Revert/Commit mirroring candCenter/bucketPool.
* CreateBaseView populates the view via NewVoterWeightViewFromBuckets
over both native and all three contract-staking indexers, then
reads the persisted digest and refuses to boot on mismatch (a
boot-time error, not a silent fork).
* DirtyView() carries voterWeights forward by pointer so applyVoter
WeightDelta (added in the following commit) mutates the same
object other handlers in the same workflow see. Stripping it to
nil here was the exact bug flagged in the abandoned pr2.5 review.
* protocol.go reserves _voterWeights as the next 1-byte namespace tag
after _candidatePollSnapshot.
Tests (voter_weight_view_test.go): 14 cases covering Apply
add/aggregate/decrease/clamp/unknown-voter, cross-candidate isolation,
sort invariant, deep-copy safety in VoterWeightsByCandidate, Fork
isolation, Wrap merge, hash determinism, and incremental-matches-
rebuild parity.
Part 1/4 of IIP-59 PR 5.5a. Alone this commit only builds up the
plumbing; the freezer still writes empty Entries until commit 3.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires every bucket-mutating handler (native + contract-staking) into
the VoterWeightView so the freezer at PutPollResult has an authoritative
per-(cand, voter) weight aggregate to read from.
Helper (voter_weight_hooks.go):
applyVoterWeightDelta(csm, candIdentifier, voter, delta) is the single
funnel every hook site calls. Nil-safe on all four arguments: nil csm,
nil candIdentifier, nil voter, nil delta, zero delta, and view-not-
installed all no-op silently. This shape is deliberate — the fork gate
is enforced by the *presence* of the view (installed in CreateBaseView
post-fork), not by callers, so hooks compile in as unconditional call
sites and are always safe to execute pre-fork.
Native handler hooks (11 sites across 7 files):
* handlers.go — CreateStake, Unstake, ChangeCandidate (pair),
TransferStake (pair), DepositToStake, Restake.
* add_deposit_compound.go — AddDepositForCompound (introduced after
the abandoned pr2.5 branch, so absent from the original hook list).
* handler_candidate_selfstake.go — Activate self-stake, both prev-
bucket transitions (self-stake↔normal) plus new-bucket seed.
* handler_candidate_endorsement.go — clearCandidateSelfStake now
takes csm so revoking an endorsement drops the self-stake bonus in
the view.
* handler_stake_migrate.go — native leg of stake migration removes
the bucket's contribution.
* candidate_statemanager.go — deactivate() records the (negative)
net delta when a self-stake bucket loses its bonus.
Contract-staking hooks (single funnel, 3 event types):
* nfteventhandler.go — PutBucket (+w), DeductBucket (−Δ),
DeleteBucket (−w). Each fires once per NFT event, dispatched to
all three V1/V2/V3 indexers by the shared handler.
Explicitly not hooked:
* vote_reviser.go — historical fork revisions predate IIP-59.
* handleWithdrawStake — the vote was already removed at Unstake.
* Self-stake bucket weights — excluded from voter distribution per
IIP-59 spec; the hook records only the (negative) delta when a
self-stake bonus is added/removed, not the self-stake principal.
Part 2/4 of PR 5.5a. After this commit the view state matches ground
truth per block but nothing reads from it yet — the freezer still
writes empty Entries. That flip happens in the next commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tView
FreezePollSnapshot at PutPollResult now walks each frozen candidate's
per-voter aggregate in the VoterWeightView and copies it into
snap.Entries. Downstream Phase B (splitDelegateEpochReward in the
rewarding protocol) reads that blob and distributes voter rewards.
Before this commit the freezer wrote empty Entries; downstream, the
"no voters known" degenerate branch routed 100% of every epoch's voter
allotment to delegate commission via fallback. This is the fix that
turns IIP-59 voter distribution on for the first time in production.
Per-item degradation for the fetch:
* View not installed (typically pre-fork or in unit tests that skip
the Protocol.Start bootstrap) — snap.Entries stays nil for every
candidate. Legacy rewarding path takes over cleanly.
* View installed but VoterWeightsByCandidate returns empty for a
given candidate — snap.Entries stays nil for that candidate only.
Other candidates still get their populated Entries.
* Weights are deep-copied via new(big.Int).Set(w.weight) so a later
handler that mutates the same *big.Int cannot retroactively rewrite
a persisted snapshot.
Order comes from VoterWeightsByCandidate, which returns entries pre-
sorted by voter bytes lexicographically — matches the ordering that
downstream determinism tests already rely on.
Part 3/4 of PR 5.5a.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three new test files pin the correctness properties that make PR 5.5a
safe to activate in production.
voter_weight_hooks_test.go — 10 cases pinning the no-op semantics of
applyVoterWeightDelta. Every upstream call site is allowed to hand in
nil csm / nil cand / nil voter / nil delta / zero delta / view-not-
installed without a pre-check; each of those must be a silent no-op.
Regressions here silently drop hook applications and only surface much
later at the view-hash verification on restart. Also covers positive/
negative flow, over-withdraw clamps to zero, and unknown-voter
withdraw is a no-op (not a phantom zero entry).
poll_snapshot_entries_test.go — 6 cases covering the freezer:
* NativeVoters — happy path with 3 voters on one candidate.
* MultipleCandidatesIsolated — a voter's weight on cand A does not
bleed into cand B's Entries.
* CandidateWithNoVoters — a candidate whose view slot is empty gets
nil Entries, other candidates still populate.
* ViewMissingDegrades — no view installed = every Entries nil, no
panic. Freeze itself succeeds; downstream falls to legacy path.
* DeterministicOrder — Entries come out sorted by voter bytes
lexicographically regardless of Apply insertion order. Guards the
consensus invariant Phase B (splitDelegateEpochReward) depends on.
* WeightCloneIsolation — mutating the view after freeze does not
retroactively rewrite the persisted snapshot's weights.
poll_snapshot_test.go — drop two now-stale r.Empty(snap.Entries)
assertions. Those tests don't seed voter buckets so their Entries stay
nil, but the assertion is misleading now that population is a first-
class concern; the new Entries suite covers the positive path.
bench_freeze_snapshot_test.go — Design B (bucket enumeration) bench
gated by //go:build iip59bench. Kept in-tree as regression protection
for the design decision recorded in the plan: if a future refactor
tries to move back to enumeration-at-freeze, running the bench at the
30k-bucket ceiling will show the ~7s cost that made Design A
mandatory.
Part 4/4 of PR 5.5a. After this commit CandidatePollSnapshot.Entries
is populated from an authoritative source and voter distribution
finally runs end-to-end.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
This was referenced Jul 20, 2026
Member
Author
envestcc
added a commit
that referenced
this pull request
Jul 20, 2026
TestFreezeIIP59PollSnapshot_EraBoundaryProceeds's strict mock rejected
the ReadView("staking") call that 5.5a's FreezePollSnapshot issues via
voterWeightsFromSM. Return ErrStateNotExist so the freezer degrades to
Entries=nil — matches the "view not installed" branch already tested
directly in staking/poll_snapshot_test.go.
Compat fix for the interaction between #4940 (era-boundary gate) and
#4952 / 5.5a (VoterWeightView-populated snapshot). Both cherry-picks
apply cleanly in isolation.
envestcc
added a commit
that referenced
this pull request
Jul 21, 2026
TestFreezeIIP59PollSnapshot_EraBoundaryProceeds's strict mock rejected
the ReadView("staking") call that 5.5a's FreezePollSnapshot issues via
voterWeightsFromSM. Return ErrStateNotExist so the freezer degrades to
Entries=nil — matches the "view not installed" branch already tested
directly in staking/poll_snapshot_test.go.
Compat fix for the interaction between #4940 (era-boundary gate) and
#4952 / 5.5a (VoterWeightView-populated snapshot). Both cherry-picks
apply cleanly in isolation.
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
Fixes the missing voter-reward producer path. Before this PR,
FreezePollSnapshotwrotesnap.Entries = nilfor every candidate,so downstream
splitDelegateEpochRewardhit the "no voters known"degenerate branch on every delegate, every epoch — 100% of every
epoch's voter allotment routed to delegate commission via fallback.
This is a hard blocker for PR 6 (mainnet activation).
Approach — incremental view, not enumeration at freeze
A per-(candidate, voter) weight aggregate lives inside
viewDataalongside
candCenterandbucketPool. Every bucket-mutating handlerapplies the same delta to the view via a single funnel
(
applyVoterWeightDelta). AtFreezePollSnapshot, the freezer copieseach candidate's per-voter map into
snap.Entriesin O(voters) — nobucket enumeration.
The alternative — enumerating buckets at freeze time — benched at
~7 seconds at the 30k-bucket design ceiling (~3× the 2.5s block
budget). The incremental view is amortized across the block, keeps
freeze O(voters), and the persisted digest catches any missed hook at
boot rather than as a silent consensus fork.
What's in each commit
feat(staking): add VoterWeightView + integrate into viewData—view type with base/wrap/fork overlays; digest persisted under a
new
_voterWeightsstate-key tag; rebuilt from all native +contract-staking buckets at
CreateBaseViewand compared againstthe persisted digest at boot.
feat(staking): applyVoterWeightDelta helper + hook 13 mutation sites— nil-safe funnel hooked into every bucket-mutatinghandler (native
handlers.go6 sites; self-stake activate /deactivate / clear-endorsement 3 sites; native leg of
stake-migrate 1 site;
add_deposit_compound.go1 site — introducedafter the abandoned pr2.5 branch; contract-staking Put / Deduct /
Delete 3 sites via
nfteventhandler.go).feat(staking): populate CandidatePollSnapshot.Entries from VoterWeightView— the actual fix. Freezer walks the view andcopies per-voter weights into
snap.Entrieswith deep-copied*big.Intvalues so later handler mutations don't retroactivelyrewrite persisted snapshots.
test(staking): hook-driven view consistency + freeze-populates-entries— 10 hook no-op cases, 6freezer-populates cases, 14 view-lifecycle cases. Also ships the
Design-B bench file gated by
//go:build iip59benchas regressionprotection against a future refactor moving back to enumeration.
Stacks on
PR 5 (#4951 — determinism & stress harness). Base branch:
iip-59/pr-5-determinism-stress-harness.Test plan
go build ./...go vet ./...go test ./action/protocol/staking/...— 30+ new tests plusfull existing suite passes in ~3.5s
go test ./action/protocol/rewarding/... ./action/protocol/poll/...ceilingtier to confirmthe ~7s cost that made incremental mandatory (
-tags iip59bench -bench BenchmarkFreezeSnapshot -benchtime=5x)fires when a hook is intentionally removed (see plan file)
🤖 Generated with Claude Code