Skip to content

feat(staking): IIP-59 VoterWeightView infrastructure (PR 2/5)#4860

Closed
envestcc wants to merge 5 commits into
iotexproject:masterfrom
envestcc:iip-59/pr2-voter-weight-view
Closed

feat(staking): IIP-59 VoterWeightView infrastructure (PR 2/5)#4860
envestcc wants to merge 5 commits into
iotexproject:masterfrom
envestcc:iip-59/pr2-voter-weight-view

Conversation

@envestcc

Copy link
Copy Markdown
Member

Summary

PR 2 of the IIP-59 series. Introduces the per-(candidate, voter) weighted-votes view data structure that the rest of the IIP-59 implementation hangs off of, integrates it into the staking protocol's standard view lifecycle (Fork/Snapshot/Revert/Commit), and persists its deterministic hash digest to anchor cross-node consensus on the view contents. No runtime behavior changes — the view is built when the flag activates but no callers consume it in this PR.

The actual handler hooks that keep the view in lock-step with each bucket lifecycle event are split into PR 2.5 to keep this PR scoped to the view's data structure and lifecycle wiring (low-risk; no production handler files touched).

Series

# PR Status
0 iotex-proto: SetCommissionRate + Candidate.commissionRate iotexproject/iotex-proto#174 (draft)
1 iotex-core: fields + feature flag #4859 (draft, depends on #174)
2 This PR — VoterWeightView infrastructure here, depends on #4859
2.5 wire handler hooks to keep view in sync TODO
3 SetCommissionRate action + handler TODO
4 distributeVoterReward + PutPollResult snapshot TODO (depends on 2.5)
5 multi-node stress test + e2e harness TODO

What's in this PR

Data structure (voter_weight_view.go):

  • VoterWeightView keys candidates by Hash160 identifier; each entry is a sorted slice of (voter, *big.Int) plus an index map for O(log n) insert/remove.
  • Apply(candID, voter, delta) is the single mutation entry point. Aggregates per-voter, removes the entry on drain-to-zero, removes the candidate when its last voter leaves, and is defensively no-op when a withdrawal targets an unknown (cand) or (voter).
  • VoterWeightsByCandidate(candID) returns a sorted copy of the slice — distribution iterates this slice, never the map, which makes receipt-log order and state-write order identical across nodes (this is the bug class flagged as Batch update 2019-04-27 6PM PDT #2 in the original PoC review at iotex-core#4811).
  • Hash() walks candidates in sorted Hash160 order and voters in their already-sorted slice order; insertion-order-independent (proven by tests).
  • Tests: 14 covering add, aggregate, decrease, drain-to-zero, over-withdraw, unknown-key no-op, sortedness, clone deep-copy, hash determinism across insertion orders, hash sensitivity to weight change, and realistic-event-stream incremental-vs-rebuild equivalence. Benchmark: Apply at 73.8 ns/op on M1 Pro.

viewData integration (viewdata.go):

  • voterWeights *VoterWeightView field plus matching field on Snapshot.
  • Fork / Snapshot deep-clone (Apply mutates in place); Revert restores the cloned snapshot; Commit persists the digest when IsDirty.

Persistence (voter_weight_view.go):

  • New 1-byte staking namespace tag _voterWeights = 5 (next free tag; the existing 0-4 are reserved in protocol.go).
  • voterWeightDigest serializes as exactly 32 bytes; corruption surfaces as a deserialization error rather than a silent zero.
  • viewData.commitVoterWeights writes the digest only when dirty, so pre-flag chains are byte-identical to today's behavior.

Initial population (protocol.go CreatePreStates):

  • When EnableVoterRewardDistribution is on and viewData.voterWeights is nil (first block after activation, or restart), ensureVoterWeightView scans all active native buckets, computes per-bucket weight via CalculateVoteWeight (with ContractAddress == \"\" && Index == cand.SelfStakeBucketIdx for the self-stake gate — fixes PoC review finding Docker build fails #5), and Apply()s each weight into a fresh view.
  • After the build, the rebuilt hash is checked against the persisted digest. Mismatch is fatal; ErrStateNotExist (no record yet — first activation) is normal and the view is marked dirty so the next Commit writes the initial digest.

Helper for PR 2.5 (voter_weight_view.go):

  • applyVoterWeightDelta(csm, candIdentifier, voter, delta) — the single entry point PR 2.5 will wire into every native + contract-staking handler that changes a bucket's weight contribution. No-op when the flag is off and when delta is zero, so callers can drop it next to any existing candidate.AddVote / candidate.SubVote without first checking the flag.

What's not in this PR

  • Handler hooks. PR 2.5 (task #7 in our internal tracker) will instrument:
    • handlers.go: handleCreateStake, handleUnstake, handleChangeCandidate, handleTransferStake (asymmetric: −W on old voter, +W on new voter), handleDepositToStake, handleRestake
    • handler_candidate_endorsement.go, handler_candidate_selfstake.go
    • handler_stake_migrate.go (−W native, +W contract)
    • nfteventhandler.go (V1/V2/V3 contract staking events)
    • vote_reviser.go, protocol.go
  • Contract-staking initial population — ensureVoterWeightView currently scans native buckets only. Contract buckets are read from each indexer via the existing ContractStakeView.CandidateStakeVotes boundary, which is per-candidate aggregated. To enumerate per-voter contract bucket contributions at startup, PR 2.5 (or its precursor) will extend the indexer interface with a Buckets() enumerator (or use the existing BucketsByCandidate over the candidate-center list).

Why split this way

The data structure and lifecycle are independently reviewable — they compile, they pass tests, and they cannot change runtime behavior because no caller reads from the view. Splitting handler instrumentation into its own PR contains the production-handler-touching change to a separately-scoped review window, where most of the work is mechanical (delta wiring next to existing AddVote/SubVote sites) but each site needs careful attention to corner cases (transfer-style asymmetric updates, self-stake bonus changes, native↔contract migration).

Test plan

  • go build ./... passes
  • go test ./action/... — 922 tests pass; no regression
  • New voter_weight_view_test.go — 14 unit tests + 1 benchmark covering data-structure correctness, determinism, and clone safety
  • make lint (run before un-drafting)

Behavior gating

  • Pre-flag: EnableVoterRewardDistribution = false. voterWeights stays nil. Apply paths are no-op. Commit is no-op. Persistence path is no-op. Byte-identical to today.
  • Post-flag, no callers yet (this PR): the view is built and verified, but no caller reads it. State-trie footprint: one 32-byte digest per chain, rewritten only on dirty commits.

🤖 Generated with Claude Code

envestcc and others added 2 commits June 25, 2026 11:42
Adds the proto-level field and feature gate that the rest of the IIP-59
protocol-native voter reward distribution PRs will hang behavior off of.
No runtime behavior changes in this PR — the field is populated as zero
on existing chain data, default behavior matches today exactly.

Schema:
- stakingpb.Candidate gains commissionRate=11; Go Candidate struct mirrors
  it. Equal/Clone/toProto/fromProto updated (the original PoC missed Equal
  — flagged in iotexproject#4811 review #2).
- state.Candidate gains CommissionRate, snapshotted per epoch from the
  staking candidate state by PutPollResult (added in PR 4). The latest
  user-set value lives on staking.Candidate; state.Candidate holds the
  per-epoch frozen value consumed by GrantEpochReward.
- iotextypes.Candidate.commissionRate is set/read in candidateToPb /
  pbToCandidate so the new field travels through poll snapshots and over
  the wire.

Feature flag:
- FeatureCtx.NoVoterRewardDistribution, bound to !g.IsToBeEnabled(height)
  per AGENTS.md convention for WIP features (the gate will be swapped for
  a real hardfork height at release time).
- Named so that the bool zero value (false) corresponds to the post-fork
  activated behavior, matching the existing NoCandidateExitQueue /
  NotSlashUnproductiveDelegates convention. A docstring records this rule
  next to the field for future readers.

Why no separate commissionRateLastEpoch field:
- IIP-59 doesn't prescribe a per-rate-change cooldown. Its protection
  against rapid manipulation is epoch-boundary activation, which our
  design already provides for free: a SetCommissionRate at any moment
  only affects rewards in the epoch *after* the next PutPollResult
  snapshot, giving voters ~1.5 epochs of reaction time.
- If the protocol later decides cooldown is needed, adding the field is
  an additive proto change with no migration cost.

Toolchain:
- stakingpb regenerated with protoc-gen-go v1.26.0 to match the version
  recorded in the existing staking.pb.go header.

Local dev:
- go.mod replace pointing at ../iotex-proto so the build resolves the new
  iotex-proto fields prior to the proto PR being tagged. Remove the
  replace once iotex-proto cuts a release containing the
  SetCommissionRate + Candidate.commissionRate additions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…IIP-59)

This commit introduces the data structure that IIP-59's reward-distribution
path will consume in PR 4. No callers yet — this is the underlying view
type, its mutation entry point, the deterministic hash digest, and the
clone/iteration helpers, with unit tests.

Design:
- Per-candidate sorted slice of (voter, weight) tuples (sorted by voter
  address). distributeVoterReward iterates the slice directly — never the
  map — so receipt log order is deterministic across nodes (PoC iotexproject#4811
  review finding #2 was exactly this bug, fixed here at the data-structure
  level rather than at every caller).
- Per-candidate index map for O(log n) insert/remove on hot paths.
- Multiple buckets from the same voter to the same delegate are aggregated
  per voter, not per bucket, which avoids per-bucket rounding loss at
  distribution time.
- Hash() walks candidates in sorted hash160 order and voters in
  already-sorted slice order; two views with the same logical state
  produce the same digest, independent of Apply() insertion order. PR 2's
  next commit persists this digest to a new staking namespace tag
  (_voterWeights = 5) so a restarted node can rebuild from buckets and
  verify against the last-committed hash.

Apply() semantics:
- Adds a (cand, voter, delta) tuple. Aggregates with any existing weight.
- Withdrawals that drive the per-voter total to zero remove the voter
  entry; the candidate entry is removed too when its last voter leaves.
- Withdrawals against an unknown (cand) or (voter) are silently no-op
  (rationale: the staking handlers never overdraw; if they ever do, the
  view-hash check at restart catches the divergence loudly).

Coverage:
- Tests for add, aggregate, decrease, drive-to-zero, over-withdraw,
  unknown-key no-op, sortedness, clone deep-copy, hash determinism
  across insertion orders, hash sensitivity to weight change,
  realistic-event-stream incremental-vs-rebuild equivalence, and a
  benchmark for the Apply hot path (73.8 ns/op on M1 Pro).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread action/protocol/staking/protocol.go Outdated
// per-action handlers. The build is idempotent — subsequent calls
// see a non-nil voterWeights and short-circuit.
if featureCtx.EnableVoterRewardDistribution {
if err := p.ensureVoterWeightView(ctx, sm); err != nil {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't think it's necessary to load VoterWeightView every time CreatePreState is called; it only needs to be loaded once at startup. There should be no scenario where the VoterWeightView is missing here.

Comment thread action/protocol/staking/viewdata.go Outdated
amount: new(big.Int).Set(v.snapshots[i].amount),
count: v.snapshots[i].count,
contractsStake: v.snapshots[i].contractsStake,
voterWeights: v.snapshots[i].voterWeights.Clone(),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Cloning voterWeights during viewData.Fork is an inefficient implementation that will cause serious performance issues. You can refer to contractStakeView as a reference.

Comment thread action/protocol/staking/viewdata.go Outdated
}
}
fork.contractsStake = v.contractsStake.Fork()
fork.voterWeights = v.voterWeights.Clone()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

also inefficient

envestcc and others added 2 commits June 25, 2026 12:07
… (IIP-59)

Wires the IIP-59 voter-weight view into the staking protocol's standard
view lifecycle so it follows the same Fork/Snapshot/Revert/Commit rules as
bucketPool and contractsStake. No callers consume the view yet — that
arrives in PR 4 with distributeVoterReward.

viewData:
- New voterWeights *VoterWeightView field plus parallel field on Snapshot
  for deep capture/restore. Fork deep-clones; Snapshot deep-clones (Apply
  is the single mutation path and mutates in place, so a shallow snapshot
  would not survive any later change); Revert restores the snapshotted
  clone; Commit persists the view digest under the new _voterWeights
  namespace tag when IsDirty.
- voterWeightDigest is the on-disk format: a single 32-byte Hash256
  rewritten only on dirty commits. Other nodes' digests at the same block
  height must match byte-for-byte; a mismatch surfaces via the state
  digest path that already feeds into deltaStateDigest, so any divergence
  fails the block.

CreatePreStates branch:
- When EnableVoterRewardDistribution is on and viewData.voterWeights is
  nil (first block after flag activation, or restart), ensureVoterWeightView
  scans all active native buckets, computes per-bucket weight via
  CalculateVoteWeight (using ContractAddress=="" gate to apply the
  self-stake bonus only to native self-stake buckets — fixes PoC iotexproject#4811
  review finding iotexproject#5), and Apply()s each weight into a fresh view.
- After the build, the rebuilt hash is checked against the persisted
  digest. Mismatch is fatal: the staking view has diverged from the
  on-chain bucket state and continuing would produce invalid receipts.
  ErrStateNotExist (no record yet — first activation) is the normal path:
  the view is marked dirty so the next Commit writes the initial digest.

Contract-staking buckets are not yet enumerated here; PR 2's next commit
adds the indexer-driven enumeration. Until then, the view is built from
native buckets only; the persisted hash still anchors determinism.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…P-59)

Defines the single entry point that subsequent handler hooks (PR 2.5) will
use to keep the VoterWeightView in sync with on-chain bucket changes.
No callers yet — the helper is no-op when the feature flag is off and
when delta is zero, so PR 2.5 can wire it next to existing
candidate.AddVote / candidate.SubVote sites without first checking
the flag.

The helper sits next to ensureVoterWeightView so all IIP-59 view-mutation
concerns are co-located.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread action/protocol/staking/viewdata.go Outdated
if err := v.contractsStake.Commit(ctx, sm); err != nil {
return err
}
if err := v.commitVoterWeights(sm); err != nil {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

can define a Commit method of the VoterWeightView, and call it instead

Comment thread action/protocol/staking/viewdata.go Outdated
amount: new(big.Int).Set(v.bucketPool.total.amount),
count: v.bucketPool.total.count,
contractsStake: v.contractsStake,
voterWeights: v.voterWeights.Clone(),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

also inenfficient

@envestcc
envestcc force-pushed the iip-59/pr2-voter-weight-view branch from 3c5e0d7 to 0cc2d30 Compare June 25, 2026 04:12
//
// The hash is persisted at the _voterWeights namespace tag on every block
// commit and re-checked at restart against a rebuilt-from-buckets view.
func (v *VoterWeightView) Hash() hash.Hash256 {

@envestcc envestcc Jun 25, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We should run a benchmark for Hash(). Currently, there are approximately 100 candidates and 100,000 staking buckets on mainnet.

envestcc added a commit to envestcc/iotex-core that referenced this pull request Jun 25, 2026
…eview)

Responds to envestcc's review feedback on PR iotexproject#4860. Replaces the eager
deep-clone Snapshot/Fork model with the same lazy-overlay pattern that
ContractStakeView already uses (voteView / candidateVotesWraper in
systemcontractindex/stakingindex), and moves the IIP-59 init out of the
per-block CreatePreStates hot path.

VoterWeightView is now an interface with three implementations:

- voterWeightBase  — the terminal layer holding the actual sorted map.
- voterWeightWrap  — a thin overlay used by viewData.Snapshot. Wraps a
                     parent (base or another wrap) and accumulates Apply
                     deltas locally; Commit replays them into the parent's
                     base directly. No data copy at Snapshot time.
- voterWeightFork  — commit-in-clone overlay used by viewData.Fork. The
                     parent base is shared until Commit actually flushes,
                     then cloned so the parent stays intact. Forks that
                     never mutate the view pay nothing.

Per-snapshot cost drops from O(N) (full map clone of ~100k voters) to
O(k) where k is the number of (cand, voter) tuples Apply touched between
snapshot and revert — typically a handful per action.

Commit and persistence moved onto the interface:
- voterWeights.Commit(sm) flattens the overlay AND persists the digest if
  dirty. viewData.Commit just calls it and installs the returned view.
- viewData.commitVoterWeights / VoterWeightView.MarkClean removed.

Initial scan moved from CreatePreStates → Protocol.Start:
- Protocol.Start now calls loadVoterWeightView once at node startup
  after both candCenter and contractsStake have loaded. The previous
  ensureVoterWeightView hook ran inside CreatePreStates (which fires on
  every block) and only short-circuited on the nil check — wasteful.
- Persisted digest is still verified against the rebuilt view at startup;
  mismatch is fatal. ErrStateNotExist (first activation, no digest yet)
  marks the view dirty so the next block commit writes the initial hash.

New test coverage:
- TestVoterWeightView_ForkIsolation        — fork mutates without leaking
                                              to parent before commit;
                                              after commit, fork has the
                                              delta and parent unchanged.
- TestVoterWeightView_WrapMergesIntoParent — wrap commit lands changes in
                                              the shared parent base.
- BenchmarkVoterWeightView_Hash            — 100 candidates × 100k voters
                                              (mainnet-scale): Hash() runs
                                              in ~13.2ms on M1 Pro. Block
                                              time is 5s, so this is well
                                              within the budget; called at
                                              most once per block via the
                                              commit path.

Existing tests retargeted to the interface API (Hash() == ZeroHash256
instead of removed IsEmpty(); Fork/Wrap instead of removed Clone()).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@envestcc

Copy link
Copy Markdown
Member Author

All six comments addressed in 2678a6eb6 (force-pushed). Refactor follows the voteView / candidateVotesWraper pattern from systemcontractindex/stakingindex.

Comment 1 — protocol.go:593 (don't load every CreatePreState)

Removed the CreatePreStates branch. Protocol.Start now calls a new loadVoterWeightView(view, sr) once at node startup, after both candCenter and contractsStake have loaded. The startup-time digest verification is preserved; ErrStateNotExist (first activation, no digest yet) marks the view dirty so the next block commit writes the initial hash.

Comments 2, 3, 5 — Fork/Snapshot deep-clone is inefficient

VoterWeightView is now an interface with three implementations:

voterWeightBase  — terminal layer, the sorted map.
voterWeightWrap  — Snapshot overlay. Wraps a parent and accumulates Apply
                   deltas locally; Commit replays into the parent's base
                   (no data copy at Snapshot time).
voterWeightFork  — commit-in-clone overlay for viewData.Fork. Base shared
                   until Commit actually flushes; then cloned so the
                   parent stays intact. Forks that never mutate the view
                   pay nothing.

Per-snapshot cost drops from O(N) (full map clone of ~100k voters) to O(k) where k is the number of (cand, voter) tuples touched between snapshot and revert — typically a handful per action. viewData.Snapshot now installs a Wrap() overlay; Revert restores the saved pointer; Fork() installs a Fork() overlay.

Comment 4 — Commit method on VoterWeightView

Moved both flatten and persist logic onto the interface: Commit(sm protocol.StateManager) (VoterWeightView, error). viewData.Commit is now just v.voterWeights = v.voterWeights.Commit(sm). The old free function commitVoterWeights and MarkClean helper are gone.

Comment 6 — Hash() benchmark at mainnet scale

Added BenchmarkVoterWeightView_Hash with 100 candidates and 100,000 voters distributed via a skewed normal across candidates (popular delegates with more voters):

goos: darwin
goarch: arm64
pkg: github.com/iotexproject/iotex-core/v2/action/protocol/staking
cpu: Apple M1 Pro
BenchmarkVoterWeightView_Hash-10    272    13177876 ns/op

~13.2ms per call at full mainnet scale, called at most once per block via the viewData commit path. Well within a 5-second block budget.

Test sweep

  • New: TestVoterWeightView_ForkIsolation and TestVoterWeightView_WrapMergesIntoParent exercise the wrap/fork overlay semantics directly (changes don't leak from fork to parent before commit; wrap merges into parent on commit).
  • Existing tests retargeted to the interface API: Hash() == ZeroHash256 instead of removed IsEmpty(); Fork()/Wrap() instead of removed Clone().
  • All 15 TestVoterWeightView_* tests pass; full ./action/protocol/staking/ package passes (modulo the pre-existing flaky TestProtocol_FetchBucketAndValidate bug: TestProtocol_FetchBucketAndValidate flaky in staking test suite #4813).

Comment thread action/protocol/staking/protocol.go Outdated
// bucket state and view state cannot silently pass into block
// production. This is a one-shot full scan — subsequent blocks rely on
// the incremental Apply hooks in the per-action handlers.
if !protocol.MustGetFeatureCtx(ctx).NoVoterRewardDistribution {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

should load voterWeightView even if NoVoterRewardDistribution

Comment thread action/protocol/staking/protocol.go Outdated
// the enumeration once its handler hooks are wired. Until then, the view is
// built from native buckets only — the persisted hash still anchors
// determinism within that scope.
func (p *Protocol) loadVoterWeightView(c *viewData, sr protocol.StateReader) error {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

In addition to native buckets, those contract staking buckets should also be counted.

…eview)

Responds to envestcc's review feedback on PR iotexproject#4860. Replaces the eager
deep-clone Snapshot/Fork model with the same lazy-overlay pattern that
ContractStakeView already uses (voteView / candidateVotesWraper in
systemcontractindex/stakingindex), and moves the IIP-59 init out of the
per-block CreatePreStates hot path.

VoterWeightView is now an interface with three implementations:

- voterWeightBase  — the terminal layer holding the actual sorted map.
- voterWeightWrap  — a thin overlay used by viewData.Snapshot. Wraps a
                     parent (base or another wrap) and accumulates Apply
                     deltas locally; Commit replays them into the parent's
                     base directly. No data copy at Snapshot time.
- voterWeightFork  — commit-in-clone overlay used by viewData.Fork. The
                     parent base is shared until Commit actually flushes,
                     then cloned so the parent stays intact. Forks that
                     never mutate the view pay nothing.

Per-snapshot cost drops from O(N) (full map clone of ~100k voters) to
O(k) where k is the number of (cand, voter) tuples Apply touched between
snapshot and revert — typically a handful per action.

Commit and persistence moved onto the interface:
- voterWeights.Commit(sm) flattens the overlay AND persists the digest if
  dirty. viewData.Commit just calls it and installs the returned view.
- viewData.commitVoterWeights / VoterWeightView.MarkClean removed.

Initial scan moved from CreatePreStates → Protocol.Start:
- Protocol.Start now calls loadVoterWeightView once at node startup
  after both candCenter and contractsStake have loaded. The previous
  ensureVoterWeightView hook ran inside CreatePreStates (which fires on
  every block) and only short-circuited on the nil check — wasteful.
- Persisted digest is still verified against the rebuilt view at startup;
  mismatch is fatal. ErrStateNotExist (first activation, no digest yet)
  marks the view dirty so the next block commit writes the initial hash.

New test coverage:
- TestVoterWeightView_ForkIsolation        — fork mutates without leaking
                                              to parent before commit;
                                              after commit, fork has the
                                              delta and parent unchanged.
- TestVoterWeightView_WrapMergesIntoParent — wrap commit lands changes in
                                              the shared parent base.
- BenchmarkVoterWeightView_Hash            — 100 candidates × 100k voters
                                              (mainnet-scale): Hash() runs
                                              in ~13.2ms on M1 Pro. Block
                                              time is 5s, so this is well
                                              within the budget; called at
                                              most once per block via the
                                              commit path.

Existing tests retargeted to the interface API (Hash() == ZeroHash256
instead of removed IsEmpty(); Fork/Wrap instead of removed Clone()).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@envestcc
envestcc force-pushed the iip-59/pr2-voter-weight-view branch from 2678a6e to a55e194 Compare June 25, 2026 07:02
@envestcc

Copy link
Copy Markdown
Member Author

Both addressed in a55e19465 (force-pushed onto the refactor commit).

Comment 1 — protocol.go:390: load even when NoVoterRewardDistribution

Removed the flag check at the call site. Protocol.Start now invokes loadVoterWeightView unconditionally, so the view is built and digest-verified on every startup regardless of activation status. The pair to this change is in viewData.Commit:

var persistSM protocol.StateManager
if !protocol.MustGetFeatureCtx(ctx).NoVoterRewardDistribution {
    persistSM = sm
}
updated, err := v.voterWeights.Commit(persistSM)

Pre-fork chains pass sm=nil into VoterWeightView.Commit, which flattens overlays and clears the dirty flag without touching the state trie — byte-identical to today's behavior. Post-fork, the real sm flows through and the digest is written whenever the view is dirty. This way the view is always in sync with the bucket state from the moment the node boots, but persistence stays gated on the flag.

loadVoterWeightView also got an updated dirty-on-empty-digest rule: it now only sets dirty=true on first-activation when !NoVoterRewardDistribution. Pre-fork startups with no persisted digest leave dirty=false so the next commit skips the write.

Comment 2 — protocol.go:406: include contract staking buckets

Extended the enumeration to walk all three contract staking indexers (V1/V2/V3) in addition to native buckets:

for _, indexer := range []ContractStakingIndexer{
    p.contractStakingIndexer,
    p.contractStakingIndexerV2,
    p.contractStakingIndexerV3,
} {
    if indexer == nil || indexer.StartHeight() > height {
        continue
    }
    contractBuckets, err := indexer.Buckets(height)
    if err != nil {
        return errors.Wrapf(err, "failed to read contract staking buckets at %s", indexer.ContractAddress())
    }
    allBuckets = append(allBuckets, contractBuckets...)
}

The same ContractAddress == "" && Index == cand.SelfStakeBucketIdx gate inside buildVoterWeightView ensures self-stake bonus only applies to native self-stake buckets, not contract buckets with Index == 0 (the PoC review-finding #5 bug from #4811).

Test-mock updates

Two existing tests had hardcoded Times(N) expectations on sr.Height() / MockContractStakingIndexer.StartHeight() that broke because Start now does the extra work. Updated:

  • TestVoteRevisersm.Height() count 4 → 5 (one extra sr.Height from loadVoterWeightView)
  • TestCreatePreStatesMigration — switched StartHeight / ContractAddress to AnyTimes() and added a Buckets(any) expectation, so future load-path additions don't ping this test again

Full sweep: 923/923 tests pass in action/protocol/....

@sonarqubecloud

Copy link
Copy Markdown

@envestcc

envestcc commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Closing as part of IIP-59 PR reorganization. The proposal was amended (iotexproject/iips#73) to cover both block reward + epoch reward and to base voter distribution on a per-epoch snapshot. The new PR split is:

The incremental voter weight view (old #4860 + #4863) is dropped in favor of a per-PutPollResult full scan; ~40k buckets sorted+encoded once per epoch fits well inside the mint budget and eliminates the 13-handler-hook consensus-safety surface. Old #4864 (voter reward distribution) is replaced by new PR 3 which reads the snapshot instead of the live view.

Superseded by new PRs — will link once opened.

@envestcc envestcc closed this Jul 1, 2026
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