Skip to content

feat(staking,poll): incremental voter weight view + snapshot at PutPollResult (IIP-59 PR 2/6)#4866

Open
envestcc wants to merge 2 commits into
iotexproject:masterfrom
envestcc:iip-59/pr2-snapshot-layer
Open

feat(staking,poll): incremental voter weight view + snapshot at PutPollResult (IIP-59 PR 2/6)#4866
envestcc wants to merge 2 commits into
iotexproject:masterfrom
envestcc:iip-59/pr2-snapshot-layer

Conversation

@envestcc

@envestcc envestcc commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

Second PR of the IIP-59 protocol-native voter reward distribution series (spec: iotexproject/iips#73). Stacked on #4865 — PR 1 introduces the commissionRate schema + SetCommissionRate action; this PR keeps a live per-voter weight view up to date and freezes both the commission rate and the per-voter weights per-epoch, materializing what PR 3's GrantEpochReward will consume.

  • No behavior change pre-flag. Gated on the same FeatureCtx.NoVoterRewardDistribution as PR 1. Reward distribution still runs the legacy path.
  • What's new:
    • a live in-memory VoterWeightView in viewData, kept incrementally consistent by native handlers and by the contract-staking receipt-driven event dispatch
    • at PutPollResult (mid-epoch snapshot of next-epoch active delegates), we now (a) copy staking.Candidate.CommissionRate → state.Candidate.CommissionRate — poll's existing NxtCandidateKey PutState persists it, no extra key — and (b) read the sorted per-voter slice out of the view and write a byte-deterministic per-candidate blob under the new _voterWeightSnap state-trie namespace
  • Reader for PR 3: VoterWeightsFromSnapshot(sr, candID) → []VoterWeight returns the frozen slice for one candidate.

Design change vs. the first draft on this branch

The prior version of this PR scanned all buckets at PutPollResult time to build the aggregate. That was O(all buckets) inside a hot state.db call and — worse — read from the contract staking indexer, a db independent of state.db, inside a consensus mutation path. Both problems are gone in this redesign:

  • Incremental live view. Native handlers (handleCreateStake, handleUnstake, handleChangeCandidate, handleTransferStake, handleDepositToStake, handleRestake, handleCandidateEndorsement, handleStakeMigrate) push (cand, voter, Δw) deltas into VoterWeightView as they run. Contract-staking events push deltas through a VoterDeltaSink plumbed via context (no ContractStakeView interface widening, no mock breakage). SnapshotForEpochReward at PutPollResult reads O(voters-per-cand) and does zero bucket iteration.
  • State.db-native consensus path. The consensus mutation path (Handle) never reads the contract staking indexer db. The view is fed through the same receipt-driven dispatch that already updates contractStakeView. The view itself is memory-only; on Start, CreateBaseView rebuilds it once from current buckets + each indexer's current per-voter aggregates. The durable half of the story is the frozen per-epoch snapshot blobs under _voterWeightSnap, not the live view.
  • Cheap Snapshot/Revert. The view is a base map + a change-overlay wrapper (Wrap() layers a change map on top of the receiver so Revert throws the overlay away; Fork() deep-clones for parallel working sets; Commit() folds change into base and prunes zeros).

Storage layout

  • New 1-byte namespace tag _voterWeightSnap = 5 in the staking namespace (protocol.go), following the _const / _bucket / _voterIndex / _candIndex / _endorsement pattern.
  • Per-candidate blob key = _voterWeightSnap || candIdentifier.Bytes() → 21 bytes total, mirroring _voterIndex / _candIndex layout.
  • Payload = stakingpb.VoterWeightSnapshot { repeated VoterWeightEntry (bytes voter, bytes weight) }. Entries pre-sorted by voter address bytes; marshalled output is byte-deterministic given identical logical state — a load-bearing invariant for consensus and for the byte-equality skip in the writer.

Correctness invariants (deliberate; PoC #4811 review issues folded in)

  • Self-stake exclusion uses the strict native check ContractAddress == "" && Index == cand.SelfStakeBucketIdx. Fixes PoC review issue Docker build fails #5 (all contract buckets with Index=0 erroneously got self-stake weighting). Applied uniformly: self-stake buckets never enter the view, so all handler hooks that touch them skip the delta.
  • Unstaked buckets skipped. handleUnstake emits the removal delta; buckets already unstaked at the time of handleTransferStake / handleChangeCandidate are skipped because they left the view earlier.
  • Byte-equality skip in writeVoterWeightSnapshot: encode → compare to existing stored blob bytes → skip PutState when identical. Cuts write amplification in the steady state.
  • Empty entries → DelState, not "write empty blob." VoterWeightsFromSnapshot returns (nil, nil) for the ErrStateNotExist path either way, but keeping state clean matches how other candidate-scoped namespaces behave.
  • State read errors other than ErrStateNotExist propagate (wrapped). Fixes PoC review issue Implement IoTeX blockchain explorer API MVP #4 (all errors were being treated as "no buckets").
  • Deterministic voter orderingWeights(cand) returns a slice sorted by voter address bytes. No map iteration reaches the consensus-visible output. Fixes PoC review issue Batch update 2019-04-27 6PM PDT #2 (map iteration would have caused block-hash divergence between nodes).

Poll integration

  • poll/util.go setCandidates: after the shortlist is built but before sm.PutState under NxtCandidateKey, call staking.FindProtocol(...).SnapshotForEpochReward(...). Must run first — the commission-rate copy mutates the candidate list in place, and the persisted state.Candidate needs the frozen value.
  • FindProtocol lookup means poll still doesn't hard-depend on staking's concrete Protocol type; the nil-check keeps legacy tests that skip staking registration working.

Tests

  • voter_weight_view_test.go (19 cases): base apply/aggregate/read, sorting stability, wrap overlay semantics, Snapshot/Revert parity against a Fork+Apply reference, Commit fold + zero-prune, nested Wrap, per-candidate isolation, sink context plumbing, deterministic incremental updates.
  • vote_view_handler_test.go (9 cases, systemcontractindex/stakingindex): PutBucket fresh / same-cand-same-owner / owner-change / candidate-change; DeleteBucket / missing-bucket / muted-bucket; nil-sink tolerance; sink integration that a fresh-then-delete sequence balances to zero after Commit.
  • voter_weight_snapshot_test.go: updated to construct a populated view directly rather than seeding buckets, then assert SnapshotForEpochReward writes the expected blob, the byte-equality skip triggers on unchanged inputs, and empty entries DelState the blob. End-to-end key layout, encoding determinism, reader roundtrip, and error propagation are all covered.

Test plan

  • go build ./...
  • go vet ./action/protocol/staking/... ./action/protocol/poll/... ./systemcontractindex/stakingindex/...
  • go test ./action/protocol/staking/... ./systemcontractindex/stakingindex/... -count=1 — 390 passing (2 unrelated pre-existing gomonkey flakes in TestProtocol_FetchBucketAndValidate/validate_owner, confirmed to reproduce without this PR)
  • go test ./action/... ./state/... -count=1 — smoke pass
  • New view + handler + snapshot tests × 10 iterations — no flakes
  • Behavior under IsToBeEnabled flipped mid-test — deferred to PR 5's e2e determinism harness
  • Cross-node block-hash equality under load — deferred to PR 5's stress harness

Series roadmap

🤖 Generated with Claude Code

… (IIP-59 PR 1/6)

First PR of the IIP-59 protocol-native voter reward distribution
series (spec: iotexproject/iips#73).

Introduces the on-chain delegate opt-in surface — a commissionRate
field on the candidate schema plus the SetCommissionRate action
delegates use to change it. No reward-distribution logic yet: that
lands in PR 3, gated on the same feature flag as this PR. Chain
behavior is unchanged pre-flag.

Schema
------

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

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).
- Named so the bool zero value (false) corresponds to the post-fork
  activated behavior, matching NoCandidateExitQueue /
  NotSlashUnproductiveDelegates.

Action (SetCommissionRate)
--------------------------

- action/set_commission_rate.go: SetCommissionRate{rate uint64} with
  IntrinsicGas (10000) and SanityCheck (rate in [0, 10000]).
- Full Proto / LoadProto / FillAction wiring for the iotex-proto
  oneof slot (setCommissionRate = 56) shipped in iotex-proto v0.6.7
  (iotexproject/iotex-proto#174).
- EthCompatibleAction + NewSetCommissionRateFromABIBinary so
  MetaMask / hardhat can submit via the same path as
  candidateActivate / candidateDeactivate.
- PackCommissionRateSetEvent helper producing keccak-anchored topic-0
  + indexed candidate address for the receipt log indexers subscribe
  to.
- action/native_staking_contract_interface.sol +
  native_staking_contract_abi.json: declare
  \`function setCommissionRate(uint64 rate)\` and
  \`event CommissionRateSet(address indexed candidate, uint64 newRate)\`
  so external tooling has an ABI to bind against.

Handler (staking)
-----------------

- action/protocol/staking/handler_set_commission_rate.go: resolves
  the caller to a registered candidate by owner, writes
  candidate.CommissionRate, emits CommissionRateSet on the receipt.
  No cooldown enforcement — voters already get a ~1.5 epoch reaction
  window from the PutPollResult snapshot (§3.4 of the IIP), so a
  separate per-rate-change gate is not required.
- validations.go: rejects SetCommissionRate at Validate when the
  feature flag is off, so pre-flag the action never leaves mempool.
- protocol.go: registers the action in the handler switch.

Tests
-----

- candidate_test.go / state/candidate_test.go: proto roundtrip +
  Equal / Clone coverage for the new field.
- set_commission_rate_test.go: SanityCheck bounds, IntrinsicGas, ABI
  encode/decode roundtrip.
- handler_set_commission_rate_test.go: owner check, rate cap,
  successful write path, pre-flag Validate rejection, receipt event
  encoding.

go.mod
------

- Bumps github.com/iotexproject/iotex-proto to v0.6.7 for the new
  SetCommissionRate action + Candidate.commissionRate fields.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@envestcc envestcc requested a review from a team as a code owner July 2, 2026 03:14
@envestcc

envestcc commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Holding review — reviewer flagged two architectural issues with computeCandidateVoterWeights:

  1. Perf risk: O(voters) trie reads per top-N delegate at PutPollResult → mint-timeout risk at mainnet scale (potentially 10K+ reads on a single mid-epoch block).
  2. Layering violation: ContractStakingIndexer is backed by an independent db.KVStore (blockindex/contractstaking/indexer.go:63), not state.db. Consensus paths must depend on state.db lineage only — reading the indexer here opens a divergence vector if any node's indexer is behind/inconsistent.

Both are the same root fix: move voter-weight aggregation to an incrementally-maintained viewData field, updated by (a) native staking handlers and (b) the existing contractStakeView.Handle(ctx, receipt) receipt-processing path (which is already state.db-native and per-block deterministic). PutPollResult then just reads the persisted per-candidate blob — O(top-N), no indexer.

Will redo PR 2 with that design on the same branch and force-push. Marking as draft in the meantime.

@envestcc envestcc marked this pull request as draft July 2, 2026 04:15
@envestcc envestcc force-pushed the iip-59/pr2-snapshot-layer branch from ddbc478 to 4e15806 Compare July 2, 2026 08:01
@envestcc envestcc changed the title feat(staking,poll): snapshot voter weights + commission rate @ PutPollResult (IIP-59 PR 2/6) feat(staking,poll): incremental voter weight view + snapshot at PutPollResult (IIP-59 PR 2/6) Jul 2, 2026
@envestcc envestcc marked this pull request as ready for review July 2, 2026 08:03
@envestcc

envestcc commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Force-pushed the redesigned PR 2. Summary of the delta vs. the first draft:

  • No more indexer reads on the consensus path. The first draft called contractStakingIndexer.BucketsByCandidate(...) inside SnapshotForEpochReward, which runs on the consensus mutation path. Since the indexer db is independent of state.db, that was a state-mismatch waiting to happen. The redesign feeds a live in-memory VoterWeightView through the same receipt-driven contractStakeView.Handle path that already updates contract staking state — no indexer reads reach Handle.
  • PutPollResult is now O(voters-per-cand), not O(all buckets). Native handlers push (cand, voter, Δw) deltas as they run; PutPollResult reads the sorted slice out of the view and writes.
  • Cheap Snapshot/Revert via a base + change-overlay wrapper (matches the shape of bucketPool / candidateCenter).
  • Contract sink through context (VoterDeltaSink). Avoids widening the ContractStakeView interface — no mock breakage in existing tests or third-party impls.
  • Same feature-flag guard, same _voterWeightSnap = 5 blob layout, same reader signature (VoterWeightsFromSnapshot) — PR 3 doesn't need to change based on this redesign.

Updated the PR body + title to reflect the new design. Ready for another look.

@guo

guo commented Jul 2, 2026

Copy link
Copy Markdown
Member

Review: incremental voter-weight view looks sound, but the self-stake state machine isn't hooked

Overall the design holds up well. A few things I specifically checked and they're correct:

  • No time-decay in the weight basis. CalculateVoteWeight reads only stored bucket fields (StakedDuration, AutoStake, StakedAmount) — a bucket's weight is constant between the actions that mutate it. That's the precondition that makes the delta-based view sound, so 👍.
  • Snapshot determinism (sorted-by-voter + big-endian weight.Bytes() + byte-equal skip + DelState on empty + non-ErrStateNotExist errors propagating) is consensus-safe.
  • Commission freeze ordering in poll/util.go setCandidates is right: SnapshotForEpochReward mutates state.Candidate.CommissionRate in place before the NxtCandidateKey PutState, so the frozen rate is persisted.
  • viewData Snapshot/Revert/Commit/Fork mirror the existing contractsStake wrapper semantics.
  • Contract-staking event deltas carry the correct signs (candidate/owner change → full move; same/same → net delta; muted/unstaked → zero, matching the native isUnstaked exclusion).

Blocking: three handlers that flip a bucket between voter-visible and self-stake-excluded don't emit a view delta

The PR adds ApplyVoterWeightDelta to the bucket-lifecycle handlers (create / unstake / change / transfer / deposit / restake) plus endorsement and migrate. But the self-stake designation state machine also moves buckets in and out of the view, and those handlers aren't touched by this PR:

Handler Transition Missing delta
handleCandidateActivate (handler_candidate_selfstake.go) previous self-stake bucket → vote bucket and new vote bucket → self-stake bucket +w(prev, false) and −w(new, false) (both legs)
handleCandidateDeactivate confirm op (handler_candidate_selfstake.go) self-stake bucket → vote bucket (via csm.deactivate) +w(bucket, false)
handleCandidateTransferOwnership (handler_candidate_transfer_ownership.go) self-stake bucket → vote bucket (the needClear path) +w(bucket, false)

The tell is that handleCandidateEndorsement already compensates right after csm.deactivate:

if err := csm.deactivate(cand, bucket, ...); err != nil { ... }
csm.ApplyVoterWeightDelta(cand.GetIdentifier(), bucket.Owner, p.calculateVoteWeight(bucket, false))

handleCandidateDeactivate's confirm op calls the same csm.deactivate (which does SubVote(w,true) + AddVote(w,false) + clears SelfStakeBucketIdx, i.e. converts the bucket back to a normal vote bucket) but does not apply the matching +w to the view. handleCandidateActivate and handleCandidateTransferOwnership have the analogous gap.

Why it matters — two layers:

  1. The live view drifts, so the frozen snapshot carries wrong per-voter weights → rewards are misallocated (an owner whose bucket became self-stake keeps earning voter reward on it; an owner whose bucket reverted to a vote bucket is under-credited).
  2. More seriously: buildVoterWeightBaseFromState rebuilds the base from current bucket state at startup, and it correctly excludes/includes these buckets. So rebuild-from-state ≠ the incrementally-maintained view after any of these three actions. A node that restarts between one of these actions and the next PutPollResult will freeze a different snapshot blob than a node that didn't restart → state-root divergence.

This is all gated behind NoVoterRewardDistribution, so there's no mainnet exposure before the PR 6 activation — but it needs to be closed before the PR 5 determinism harness, since this is exactly the class of drift that harness is meant to catch.

The fix is mechanical: add the ApplyVoterWeightDelta calls to those three handlers, mirroring the endorsement hook. Suggested invariant to enforce (and to cover in the PR 5 harness): every handler that writes SelfStakeBucketIdx must emit the corresponding view-membership delta, and the harness should assert rebuild-from-state == live view at each PutPollResult.

Non-blocking

  • Dead code: voterDeltaSinkHandler (+ newVoterDeltaSinkHandler, effectiveWeight) in vote_view_handler.go has no references anywhere — the sink is actually wired through the modified voteViewEventHandler (voteView.HandleVoterDeltaSinkFrom(ctx)). Go won't flag an unused package-level type, so it compiles, but it's ~60 lines of a second, parallel implementation of the same delta logic. Worth removing to avoid ambiguity about which path is authoritative.
  • Stale snapshot blobs for delegates that drop out of the top-N are never deleted (SnapshotForEpochReward only iterates the next-epoch active list). Not a correctness issue — GrantEpochReward only reads delegates it pays — but the _voterWeightSnap namespace grows unbounded over churn. A lazy cleanup could be folded into PR 3/4.

…llResult (IIP-59 PR 2/6)

Second PR of the IIP-59 protocol-native voter reward distribution
series (spec: iotexproject/iips#73, previous:
PR 1 — commissionRate schema + SetCommissionRate action).

Adds the per-epoch freeze that PR 3's GrantEpochReward will consume:
at PutPollResult (mid-epoch snapshot of the next-epoch active
delegate set), we now capture (a) the delegate's currently-set
commission rate and (b) the per-voter aggregated vote weight for
every delegate that will be paid next epoch. No reward-distribution
logic yet — GrantEpochReward still runs the pre-IIP-59 path; the
snapshot just sits in state until PR 3 reads it. Behavior is
unchanged pre-flag.

Design change vs. the earlier draft on this branch
--------------------------------------------------

The prior version of this PR scanned all buckets at PutPollResult
time to build the per-voter aggregate. That was O(all buckets)
inside a hot state.db call and, worse, required reading from the
contract staking indexer — a db independent of state.db — inside a
consensus mutation path. Both problems are gone in this redesign:

- A live in-memory VoterWeightView is maintained incrementally by
  the native handlers and by the contract-staking receipt-driven
  event dispatch. PutPollResult reads directly from the view (top-N
  candidates only, O(voters-per-cand)), sorts by voter address
  bytes, and writes a per-candidate blob.
- The consensus path (Handle → contractsStake.Handle) never
  touches the contract staking indexer db. The view is fed through
  the same receipt-driven dispatch that today updates
  contractStakeView, via a `VoterDeltaSink` plumbed through
  context (no widening of ContractStakeView interface, no
  breakage for existing mocks/impls).
- The view is memory-only. On protocol Start, `CreateBaseView`
  rebuilds it once by scanning current native buckets and asking
  each contract-staking indexer for its current per-voter
  aggregates. The durable half of the story is the frozen
  per-epoch snapshot blobs written under `_voterWeightSnap`, not
  the live view.

Storage layout
--------------

- New 1-byte namespace tag `_voterWeightSnap = 5` in the staking
  namespace (protocol.go), following the existing _const / _bucket
  / _voterIndex / _candIndex / _endorsement pattern.
- Per-candidate blob key = `_voterWeightSnap || candIdentifier.Bytes()`
  → 21 bytes, mirroring `_voterIndex` / `_candIndex` layout.
- Blob payload = `stakingpb.VoterWeightSnapshot { repeated
  VoterWeightEntry (bytes voter, bytes weight) }`. Entries are
  written pre-sorted by voter address bytes; the marshalled output
  is byte-deterministic given identical logical state — a load-
  bearing invariant for the byte-equality skip in the writer and
  for cross-node consensus.

Live view
---------

- `action/protocol/staking/voter_weight_view.go`: VoterWeightView
  interface + baseVoterWeightView (map[candID]map[voterID]weight)
  + wrapVoterWeightView (change overlay). Wrap layers a change map
  on top of the receiver so Snapshot returns cheaply and Revert
  discards the overlay. Fork deep-clones for parallel working
  sets. Commit folds change into base and prunes zero-weight
  entries.
- Deterministic ordering: `Weights(cand)` returns
  `[]VoterWeight` sorted by voter address bytes. No map iteration
  reaches the consensus-visible output.
- Nil / zero delta is a no-op; negative deltas model bucket exit
  (unstake, withdraw, ChangeCandidate off-ramp, TransferStake off-
  ramp).

Native handler hooks
--------------------

Every native handler that changes a voter bucket's contribution to
a candidate calls `csm.ApplyVoterWeightDelta(cand, voter, Δ)`:

- handleCreateStake: +weightedVote
- handleUnstake: -weightedVote (skipped for self-stake buckets)
- handleChangeCandidate: -w on prev, +w on new (skipped when the
  bucket was prev's self-stake bucket — it was never in the view)
- handleTransferStake: -w on old voter, +w on new voter (skipped
  when bucket is already unstaked — it exited the view earlier)
- handleDepositToStake / handleRestake: +Δw on the same
  (cand, voter) pair (skipped for self-stake buckets)
- handleCandidateEndorsement: no-op on the voter view (self-stake
  gate flips SelfStakeBucketIdx but does not add/remove voter
  buckets)
- handleStakeMigrate: -native_w on (cand, oldOwner); the new
  contract bucket enters the view via the contract-staking event
  sink below

Self-stake buckets are never in the view. The exclusion rule is
uniform: `ContractAddress == "" && Index == cand.SelfStakeBucketIdx`
— fixes review issue iotexproject#5 on the PoC PR (iotexproject#4811) where any Index=0
contract bucket was silently treated as self-stake.

Contract staking sink
---------------------

- `staking/protocol.go` wraps `contractsStake.Handle` with
  `WithVoterDeltaSink(ctx, viewData.voterWeights)`. All V1/V2/V3
  event processors get the sink through context.
- `systemcontractindex/stakingindex/vote_view_handler.go` pulls
  the sink out of context and emits deltas on PutBucket/DeleteBucket:
  fresh puts emit +w; owner or candidate changes emit split (-old,
  +new); deletes emit -w. Muted buckets emit no delta (matching
  the "not in view" invariant).
- All sink calls funnel through `VoterWeightView.Apply` which is
  the single source of truth for the aggregation.

Snapshot entry point (staking)
------------------------------

- `action/protocol/staking/voter_weight_snapshot.go`:
  `Protocol.SnapshotForEpochReward(ctx, sm, cands)` is the public
  API poll calls. For each candidate in the next-epoch active list:
  1. copies `staking.Candidate.CommissionRate` into
     `state.Candidate.CommissionRate`. poll then persists this via
     its existing `NxtCandidateKey` PutState — no extra state key
     needed, the `shiftCandidates` path already promotes next →
     current at the epoch boundary.
  2. reads `vd.voterWeights.Weights(candID)` (already sorted),
     encodes with `encodeVoterWeightSnapshot`, and writes an
     incremental per-candidate blob under `_voterWeightSnap` —
     with a byte-equality skip against the existing stored blob
     so steady-state (voter set unchanged epoch-over-epoch) does
     zero writes.
- Guarded on `FeatureCtx.NoVoterRewardDistribution` — a no-op
  pre-fork.
- Empty entries after aggregation → `DelState`, not "write empty
  blob." `VoterWeightsFromSnapshot` returns (nil, nil) for the
  ErrStateNotExist path, so PR 3 sees the same result either way,
  but keeping state clean matches how the other candidate-scoped
  namespaces behave when nothing is left to store.
- State read errors other than ErrStateNotExist propagate wrapped.
  Fixes review issue iotexproject#4 on the PoC — corruption / IO errors no
  longer silently drop voter entries.

Reader for PR 3
---------------

- `VoterWeightsFromSnapshot(sr, candID) → ([]VoterWeight, error)`:
  a single sm.State call, decoded from the persisted blob.
  (nil, nil) for a candidate with no snapshot (either not yet
  activated, or all voters left).

Tests
-----

- `voter_weight_view_test.go` (19 cases): base apply/aggregate/read,
  sorting stability, wrap overlay semantics, Snapshot/Revert parity
  against a Fork+Apply reference, Commit fold + zero-prune, nested
  Wrap, per-candidate isolation, sink context plumbing.
- `vote_view_handler_test.go` (9 cases, systemcontractindex/
  stakingindex): PutBucket fresh / same-cand-same-owner /
  owner-change / candidate-change; DeleteBucket / missing-bucket
  / muted-bucket; nil-sink tolerance; sink integration that a
  fresh-then-delete sequence balances to zero after Commit.
- `voter_weight_snapshot_test.go`: updated to construct a
  populated view rather than seeding buckets, then assert
  SnapshotForEpochReward writes the expected blob and
  byte-equality skip triggers on unchanged inputs.
@envestcc envestcc force-pushed the iip-59/pr2-snapshot-layer branch from 4e15806 to 36e06e4 Compare July 3, 2026 04:31
@sonarqubecloud

sonarqubecloud Bot commented Jul 3, 2026

Copy link
Copy Markdown

@envestcc

envestcc commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Thanks for the careful read — all three misses are real. Fixed in 36e06e4d:

handleCandidateActivate (self-stake ↔ vote conversions). Two deltas were missing:

  • After converting the previous self-stake bucket back to a vote bucket, emit +w(prev, false) so the view sees it rejoin.
  • Before overwriting SelfStakeBucketIdx with the newly self-staked bucket, emit -w(new, false). Order matters: downstream isSelfStake checks need to see the pre-transition membership.

handleCandidateDeactivate (confirm op). csm.deactivate clears SelfStakeBucketIdx and folds the self-stake bucket back into the vote set — same conversion as above. Now emits +w(bucket, false) after the successful deactivate.

handleCandidateTransferOwnership (settled-bucket clear path). Capture the settled self-stake bucket before mutating candidate, then on the needClear path emit +w(prev, false). Guarded by !isUnstaked() since unstaked buckets are excluded from the view.

Dead code. You're right — voterDeltaSinkHandler had no callers; the live path is voteViewEventHandler.emitVoterDelta, wired via context by voteView.Handle. Deleted the struct + factory + PutBucket/DeleteBucket/effectiveWeight (~65 lines).

The 27 self-stake / deactivate / transfer-ownership handler tests still pass, and the full staking package (354 tests) is green. Please take another look.

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.

2 participants