Skip to content

feat(rewarding,poll,staking): IIP-59 voter reward distribution (PR 4/5)#4864

Closed
envestcc wants to merge 9 commits into
iotexproject:masterfrom
envestcc:iip-59/pr4-voter-reward
Closed

feat(rewarding,poll,staking): IIP-59 voter reward distribution (PR 4/5)#4864
envestcc wants to merge 9 commits into
iotexproject:masterfrom
envestcc:iip-59/pr4-voter-reward

Conversation

@envestcc

Copy link
Copy Markdown
Member

Summary

The flip-the-switch PR. GrantEpochReward now splits each delegate's epoch reward between a commission cut to the delegate and a proportional distribution to its voters, gated on the IIP-59 feature flag. Pre-flag chains keep exactly today's payout behavior — every new code path returns nil / no-op until NoVoterRewardDistribution is false.

This PR connects everything PRs #4859 / #4860 / #4862 / PR 2.5 (#4863) already shipped:

Layer Source PR Role here
state.Candidate.CommissionRate field #4859 snapshot target
VoterWeightView infrastructure #4860 read source for shares
Handler hooks keep view in sync #4863 guarantees correctness
SetCommissionRate action #4862 delegate opt-in
PutPollResult snapshot + distributeVoterReward this PR actually flips the switch

Series

# PR Status
0 iotex-proto fields iotexproject/iotex-proto#174 (draft)
1 iotex-core: fields + flag #4859 (draft)
2 VoterWeightView infrastructure #4860 (draft)
2.5 handler hooks #4863 (draft)
3 SetCommissionRate action #4862 (draft)
4 This PR here, depends on 1+2+2.5+3
5 multi-node stress test TODO

What's new

action/protocol/rewarding/voter_reward.go (new):

func (p *Protocol) distributeVoterReward(
    ctx context.Context,
    sm protocol.StateManager,
    cand *state.Candidate,             // frozen poll snapshot
    rewardAddr address.Address,
    totalReward *big.Int,
    blkHeight uint64,
    actionHash hash.Hash256,
) ([]*action.Log, error)
  • Reads cand.CommissionRate from the frozen state.Candidate snapshot (not live staking.Candidate) so a mid-epoch SetCommissionRate doesn't retroactively change the in-flight epoch's payout. This is the mechanism that gives voters ~1.5 epochs of reaction time.
  • Iterates the voter slice returned by staking.Protocol.VoterWeightsByCandidate — already sorted by voter address. No map iteration anywhere on this path (the direct fix for PoC feat(iip-59): protocol-native voter reward distribution #4811 review finding Batch update 2019-04-27 6PM PDT #2).
  • 100% commission AND no-voters branches both flow the entire reward to the delegate, emitting a single COMMISSION_REWARD log.
  • Rounding dust (integer-division remainder, always < numVoters Rau) goes to the delegate so commission + sum(shares) + dust == totalReward exactly.

action/protocol/rewarding/reward.go:

  • splitEpochReward gains a 4th return: the filteredCandidates slice parallel to addrs/amounts. Hands distributeVoterReward the whole *state.Candidate so it has Identity (for the view lookup) AND CommissionRate (for the split decision) in one place — closes PoC feat(iip-59): protocol-native voter reward distribution #4811 review finding The syscall.SYS_EXIT constant is not defined on Windows #1 (the PoC passed .Address / operator where the staking view expected Identity).
  • GrantEpochReward's per-candidate loop calls distributeVoterReward first; (nil, nil) drops through to the legacy EPOCH_REWARD grant path.

action/protocol/rewarding/rewardingpb/rewarding.proto + regen:

enum RewardType {
    BLOCK_REWARD = 0;
    EPOCH_REWARD = 1;
    FOUNDATION_BONUS= 2;
    PRIORITY_BONUS = 3;
    UNPRODUCTIVE_SLASH = 4;
    VOTER_REWARD = 5;          // IIP-59
    COMMISSION_REWARD = 6;     // IIP-59
}

action/protocol/poll/util.go:

  • snapshotCommissionRates: at PutPollResult time (mid-epoch), copies each delegate's latest staking.Candidate.CommissionRate onto the next-epoch state.Candidate snapshot. The frozen value travels through shiftCandidates at the epoch boundary and is what GrantEpochReward reads.
  • Tolerant to missing staking view (e.g., pre-flag test fixtures) — returns nil so legacy callers see no behavior change.

action/protocol/staking/voter_weight_view.go:

  • New public VoterWeight type + Protocol.VoterWeightsByCandidate for cross-package consumption. The internal voterWeight stays unexported.

Test plan

  • go build ./... passes
  • voter_reward_test.go (5): computeCommission boundaries and floor rounding (random fuzz, 200 inputs), exact-conservation invariant (commission + distributed + dust == totalReward) at 2800-voter mainnet scale.
  • poll/util_test.go (1): snapshotCommissionRates tolerates missing / bogus / unknown Identity entries without erroring or panicking.
  • Full ./action/... sweep: 943 / 945 pass (the 2 remaining failures are the pre-existing flaky TestProtocol_FetchBucketAndValidate bug: TestProtocol_FetchBucketAndValidate flaky in staking test suite #4813 unrelated to this PR).

Behavior gating

  • Pre-flag (default): NoVoterRewardDistribution = true. distributeVoterReward early-returns (nil, nil); legacy EPOCH_REWARD path runs. snapshotCommissionRates is gated on the same flag. Byte-identical to today.
  • Post-flag, cand.CommissionRate = 0: same legacy path. Delegates that don't call SetCommissionRate keep exactly today's behavior — opt-in only.
  • Post-flag, cand.CommissionRate > 0: split into COMMISSION_REWARD (delegate) + per-voter VOTER_REWARD logs; voter accounts credited; indexer subscribes to the two new RewardLog types.

🤖 Generated with Claude Code

envestcc and others added 9 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>
… (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>
…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>
…ndlers (IIP-59)

PR 2.5 in the IIP-59 series. PR 2 added the per-(candidate, voter) view
infrastructure but no handler called into it; this commit instruments
every native + contract staking handler that mutates a bucket's weight
contribution so the view stays in lock-step with on-chain bucket state.
No runtime behavior change pre-flag — applyVoterWeightDelta is a no-op
when the view is nil.

Handler hooks (one applyVoterWeightDelta call per AddVote/SubVote pair):

action/protocol/staking/handlers.go
- handleCreateStake:        +W on (cand, staker)
- handleUnstake:            -W on (cand, bucket.Owner)
- handleChangeCandidate:    -W on (prevCand, voter) and +W on (newCand, voter)
- handleTransferStake:      asymmetric — same cand+weight, voter changes
                            from oldOwner to newOwner. The handler doesn't
                            call AddVote/SubVote (candidate total stays
                            the same), so the hook is two explicit Apply
                            calls instead of mirroring an existing site.
- handleDepositToStake:     Δ on (cand, voter), since the bucket amount
                            changed and the per-voter weight too
- handleRestake:            same Δ pattern

action/protocol/staking/handler_candidate_endorsement.go
- clearCandidateSelfStake:  same bucket drops the self-stake bonus —
                            (newWeight − prevWeight) Δ. Signature gained
                            a csm argument so the hook has a view handle.

action/protocol/staking/handler_candidate_selfstake.go
- handleCandidateActivate:  two transitions in one handler — prev
                            self-stake bucket loses the bonus, new
                            self-stake bucket gains it. Two Δ Applies.

action/protocol/staking/handler_stake_migrate.go
- handleStakeMigrate:       -W on (cand, bucket.Owner) for the burned
                            native bucket. The matching +W on the freshly
                            minted contract bucket flows through the
                            nfteventhandler hook below.

action/protocol/staking/nfteventhandler.go (contract V1/V2/V3 path)
- PutBucket:                +W on (cand, contractBucket.Owner)
- DeleteBucket / DeductBucket: -W on (cand, contractBucket.Owner)
  These three are the single funnel every contract staking event takes
  (via ContractStakeView.Handle), so instrumenting them once covers all
  three indexer impls automatically.

action/protocol/staking/protocol.go
- slashCandidate's self-stake-bucket-shrink path: Δ on (cand, bucket.Owner).

action/protocol/staking/candidate_statemanager.go
- candSM.deactivate: deactivation drops the self-stake bonus on the
  bucket — Δ on (cand, bucket.Owner).
- candSM.DirtyView: **bug fix discovered while writing the hook tests** —
  the returned DirtyView was stripping voterWeights to nil, so every
  Apply through csm.DirtyView().voterWeights would have been a silent
  no-op. Now propagates the pointer from the base view.

vote_reviser.go was intentionally NOT instrumented: it only fires at
historical fork heights (Greenland, Hawaii) that are long past on
mainnet, before IIP-59 activates. By the time IIP-59 is on, vote_reviser
is a permanent no-op via its NeedRevise/cache short-circuit.

Side fix in viewdata.go:
- viewData.Commit's feature-flag lookup now uses GetFeatureCtx (returns
  ok bool) instead of MustGetFeatureCtx — some unit tests commit with
  context.Background() (TestProtocol_HandleCandidateEndorsement_*).
  Missing FeatureCtx is treated as pre-fork: persistSM stays nil and the
  view's dirty flag is cleared without touching the state trie.

Test:
- voter_weight_hooks_test.go (3): smoke test for applyVoterWeightDelta;
  load-time view consistency (per-cand per-voter keying with the same
  voter on two candidates); defensive over-withdraw is no-op.
- Full action/protocol/staking package passes (252/254 — the two
  remaining failures are the pre-existing flaky
  TestProtocol_FetchBucketAndValidate iotexproject#4813 unrelated to this PR).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the on-chain entry point delegates use to opt into IIP-59
voter reward distribution. No reward-distribution logic yet — that arrives
in PR 4. This PR is independently reviewable: the action is gated at
Validate, so until the IIP-59 feature flag activates the protocol rejects
every SetCommissionRate at mempool admission and chain behavior is
unchanged.

action/set_commission_rate.go
- SetCommissionRate{ rate uint64 } with IntrinsicGas (10000) and
  SanityCheck (rate must be in [0, 10000]).
- Full Proto / LoadProto / FillAction wiring for the iotex-proto field 56
  oneof slot that ships with the parallel PR
  (iotexproject/iotex-proto#174).
- EthCompatibleAction implementation + NewSetCommissionRateFromABIBinary
  decoder so MetaMask / hardhat tooling can submit the action via the same
  path as candidateActivate / candidateDeactivate.
- PackCommissionRateSetEvent helper that produces the keccak-anchored
  topic-0 + indexed candidate address for the receipt log indexers will
  subscribe to.

action/native_staking_contract_interface.sol +
action/native_staking_contract_abi.json
- Declare `function setCommissionRate(uint64 rate)` and
  `event CommissionRateSet(address indexed candidate, uint64 newRate)`,
  matching the existing native_staking ABI shape. The JSON entries are
  hand-added (the repo doesn't auto-regen from the .sol).

action/envelope.go
- Route `ActionCore.setCommissionRate` (field 56) into the existing
  unmarshal dispatch.

action/protocol/staking/handler_set_commission_rate.go
- handleSetCommissionRate: owner-check via csm.GetByOwner; write the new
  rate to staking.Candidate.CommissionRate via Upsert; emit the
  CommissionRateSet event through receiptLog.AddEvent (the events-path,
  per CLAUDE.md — never via legacy r.topics / r.data).
- Non-owner caller returns errCandNotExist (the existing handleError),
  so the receipt is marked failed but block production continues.
- No cooldown check: IIP-59 doesn't prescribe one, and our design's
  ~1.5-epoch reaction window comes for free via the PutPollResult
  snapshot mechanism (see PR 1 commit message).

action/protocol/staking/validations.go
- validateSetCommissionRate: hard-reject when NoVoterRewardDistribution
  is true (pre-fork mempool admission gate); otherwise delegate to
  act.SanityCheck for the rate-range check. Reuses action.ErrInvalidAct
  to match the surrounding validateMigrateStake / validateCandidate*
  conventions.

action/protocol/staking/protocol.go
- Register the new action in both the handle and Validate switches.

Tests:
- action/set_commission_rate_test.go (7): SanityCheck boundaries, gas,
  proto roundtrip, ABI roundtrip, FromABIBinary garbage-rejection, event
  packing.
- action/protocol/staking/handler_set_commission_rate_test.go (7):
  validate pre-fork reject, post-fork accept, rate-over-max reject;
  handler non-owner errCandNotExist, owner persists, owner re-update;
  full p.Handle envelope success; pre-fork p.Validate rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The flip-the-switch PR: GrantEpochReward now splits each delegate's
epoch reward between a commission cut to the delegate and a
proportional distribution to its voters, gated on the IIP-59 feature
flag. Pre-flag chains keep exactly today's payout behavior — every
new code path returns nil / no-op until NoVoterRewardDistribution is
false.

Connects all the pieces PRs 1-3 + 2.5 already shipped:
  • PR 1: state.Candidate.CommissionRate field (snapshot target)
  • PR 2: VoterWeightView infrastructure (read source for shares)
  • PR 2.5: handler hooks keeping the view in sync per block
  • PR 3: SetCommissionRate action (delegate opt-in)
  • This PR: PutPollResult snapshot + distributeVoterReward

action/protocol/rewarding/voter_reward.go (new)
- distributeVoterReward(ctx, sm, cand, rewardAddr, totalReward, ...)
  splits the per-delegate epoch reward between commission and voters.
  Reads cand.CommissionRate from the *frozen* state.Candidate snapshot
  (not live staking state) so a mid-epoch SetCommissionRate doesn't
  retroactively change the in-flight epoch's payout. Returns (nil, nil)
  for pre-flag and rate=0, letting the caller fall back to today's
  single-grant path.
- Iterates the voter slice returned by
  staking.Protocol.VoterWeightsByCandidate — already sorted by voter
  address. No map iteration anywhere on this path (the direct fix for
  the PoC iotexproject#4811 review finding #2 receipt-drift bug).
- 100% commission AND no-voters branches both flow the entire reward
  to the delegate, emitting a single COMMISSION_REWARD log.
- Rounding dust (integer-division remainder, always < numVoters Rau)
  goes to the delegate so commission + sum(shares) + dust ==
  totalReward exactly.

action/protocol/rewarding/reward.go
- splitEpochReward gains a 4th return: the filteredCandidates slice
  parallel to addrs/amounts. Hands distributeVoterReward the whole
  *state.Candidate so it has Identity (for the view lookup) AND
  CommissionRate (for the split decision) in one place — closes
  the PoC iotexproject#4811 review finding #1 (the PoC passed .Address /
  operator where the staking view expected Identity).
- GrantEpochReward's per-candidate loop calls distributeVoterReward
  first; nil/nil drops through to the legacy EPOCH_REWARD path.

action/protocol/rewarding/rewardingpb/rewarding.proto + regen
- Two new RewardLog types: VOTER_REWARD = 5, COMMISSION_REWARD = 6,
  for indexers to distinguish IIP-59 distributions from legacy
  epoch rewards.

action/protocol/poll/util.go
- snapshotCommissionRates: at PutPollResult time (mid-epoch), copies
  each delegate's latest staking.Candidate.CommissionRate onto the
  next-epoch state.Candidate snapshot. The frozen value travels
  through shiftCandidates at the epoch boundary and is what
  GrantEpochReward reads — this is the mechanism that gives voters
  ~1.5 epochs of reaction time before a new rate takes effect.
- Tolerant to missing staking view (e.g., pre-flag test fixtures) —
  returns nil so legacy callers see no behavior change.

action/protocol/staking/voter_weight_view.go
- New public API VoterWeight + VoterWeightsByCandidate on the
  Protocol. The internal voterWeight stays unexported; this is the
  package-boundary type rewarding consumes.

Tests:
- voter_reward_test.go (5): computeCommission boundaries and floor
  rounding (random fuzz), exact-conservation invariant (commission +
  distributed + dust == totalReward) at 2800-voter mainnet scale.
- poll/util_test.go (1): snapshotCommissionRates tolerates missing /
  bogus / unknown Identity entries without erroring or panicking.
- Full action/... sweep: 943 / 945 pass (the 2 remaining failures are
  the pre-existing flaky TestProtocol_FetchBucketAndValidate iotexproject#4813
  unrelated to this PR).

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

Replace the live VoterWeightView read in distributeVoterReward with a
frozen per-candidate snapshot written at PutPollResult time. This pairs
the voter set with the same epoch-boundary CommissionRate frozen on the
poll snapshot, removing the live-vs-frozen asymmetry where rate was
frozen but voter weights drifted with mid-epoch stake activity.

Storage: per-candidate blob under _voterWeightSnap || candID (21-byte
key) in _stakingNameSpace. Writer iterates candCenter.All(), compares
the newly-encoded blob to the stored blob via raw bytes equality, and
only writes when changed (DelState when a candidate's voter list goes
empty). Determinism comes from VoterWeightView's sorted-by-voter-address
invariant — same logical state always encodes to the same bytes, so the
skip is correct.

Drops the now-unused public VoterWeightsByCandidate(sm, candAddr) API.
The internal VoterWeightView.VoterWeightsByCandidate(hash) method is
retained — it's how the snapshot writer pulls live state at
PutPollResult time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@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