Skip to content

IIP-59 PR 5.5a: populate CandidatePollSnapshot.Entries via incremental VoterWeightView - #4952

Closed
envestcc wants to merge 4 commits into
iip-59/pr-5-determinism-stress-harnessfrom
iip-59/pr-5.5a-voter-weight-view
Closed

IIP-59 PR 5.5a: populate CandidatePollSnapshot.Entries via incremental VoterWeightView#4952
envestcc wants to merge 4 commits into
iip-59/pr-5-determinism-stress-harnessfrom
iip-59/pr-5.5a-voter-weight-view

Conversation

@envestcc

Copy link
Copy Markdown
Member

Summary

Fixes the missing voter-reward producer path. Before this PR,
FreezePollSnapshot wrote snap.Entries = nil for every candidate,
so downstream splitDelegateEpochReward hit 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 viewData
alongside candCenter and bucketPool. Every bucket-mutating handler
applies the same delta to the view via a single funnel
(applyVoterWeightDelta). At FreezePollSnapshot, the freezer copies
each candidate's per-voter map into snap.Entries in O(voters) — no
bucket 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

  1. feat(staking): add VoterWeightView + integrate into viewData
    view type with base/wrap/fork overlays; digest persisted under a
    new _voterWeights state-key tag; rebuilt from all native +
    contract-staking buckets at CreateBaseView and compared against
    the persisted digest at boot.

  2. feat(staking): applyVoterWeightDelta helper + hook 13 mutation sites — nil-safe funnel hooked into every bucket-mutating
    handler (native handlers.go 6 sites; self-stake activate /
    deactivate / clear-endorsement 3 sites; native leg of
    stake-migrate 1 site; add_deposit_compound.go 1 site — introduced
    after the abandoned pr2.5 branch; contract-staking Put / Deduct /
    Delete 3 sites via nfteventhandler.go).

  3. feat(staking): populate CandidatePollSnapshot.Entries from VoterWeightView — the actual fix. Freezer walks the view and
    copies per-voter weights into snap.Entries with deep-copied
    *big.Int values so later handler mutations don't retroactively
    rewrite persisted snapshots.

  4. test(staking): hook-driven view consistency + freeze-populates-entries — 10 hook no-op cases, 6
    freezer-populates cases, 14 view-lifecycle cases. Also ships the
    Design-B bench file gated by //go:build iip59bench as regression
    protection 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 plus
    full existing suite passes in ~3.5s
  • go test ./action/protocol/rewarding/... ./action/protocol/poll/...
  • Reviewer runs the Design-B bench at ceiling tier to confirm
    the ~7s cost that made incremental mandatory (-tags iip59bench -bench BenchmarkFreezeSnapshot -benchtime=5x)
  • Reviewer manually asserts a boot-time digest-mismatch error
    fires when a hook is intentionally removed (see plan file)

🤖 Generated with Claude Code

envestcc and others added 4 commits July 20, 2026 20:04
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>
@envestcc
envestcc requested a review from a team as a code owner July 20, 2026 12:31
@sonarqubecloud

Copy link
Copy Markdown

@envestcc

Copy link
Copy Markdown
Member Author

Superseded by #4953 — this PR's 4 commits are included in the consolidated PR, alongside PR 5 (#4951) and the new PR 5.5b. Closing to move review to a single branch.

@envestcc envestcc closed this Jul 20, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant