IIP-59: cover block reward + snapshot-based voter list#73
Merged
envestcc merged 1 commit intoJul 10, 2026
Merged
Conversation
Amend IIP-59 so protocol-native distribution matches the two reward streams Hermes previously distributed (block + epoch), preserving voter net income at Hermes-era levels for the same commission rate. Foundation Bonus is explicitly out of scope (already zero on mainnet). Specification changes - Simple Summary / Abstract rewritten for dual stream. - New §3.1: block reward accumulates into a per-delegate pending pool during the epoch (O(1) per block, no per-voter work). - New §3.2: GrantEpochReward drains the pending pool and folds it into the single per-epoch voter distribution. - New §3.3: trailing drain for delegates that produced blocks but exited top-N before epoch end — bounded by candidate count (~100). - New §3.4: both commission rate and per-voter weight list are frozen at the previous epoch's PutPollResult; distribution reads from the frozen snapshot, not the live staking view. Gives voters a ~1.5-epoch reaction window on rate changes. Skeletons and supporting sections - §7.3 GrantBlockReward: route to pending pool when feature flag on and CommissionRate > 0; legacy credit path otherwise. - §7.4 GrantEpochReward: fold pending, call distributeToVoters reading the snapshot, then run orphan drain. - §7.5 Voter weight snapshot: per-candidate blob (21-byte key, sorted-by-voter proto), incremental write with byte-equality skip, DelState when voter list empties. Called from PutPollResult. - §7.6 GetActiveBucketsByCandidate marked as no longer needed — reward path reads only the snapshot. Rationale / Migration / Perf / Backwards Compatibility / Security / Test Cases updated for the dual-stream design: rate mapping from Hermes era is 1:1, per-block overhead is sub-millisecond, the snapshot determinism invariant (sorted encoding → byte-identical blobs) is called out, and three new test cases cover block-reward folding, non-top-N pending drain, and the snapshot reaction window. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced Jul 1, 2026
Closed
feat(rewarding,poll,staking): IIP-59 voter reward distribution (PR 4/5)
iotexproject/iotex-core#4864
Closed
envestcc
added a commit
to envestcc/iotex-core
that referenced
this pull request
Jul 2, 2026
… (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>
7 tasks
envestcc
added a commit
to envestcc/iotex-core
that referenced
this pull request
Jul 2, 2026
…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
added a commit
to envestcc/iotex-core
that referenced
this pull request
Jul 3, 2026
…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.
5 tasks
envestcc
added a commit
that referenced
this pull request
Jul 16, 2026
…igration Four amendments to the IIP-59 draft that override the earlier all-native design (pending PR #73): 1. Commission rate source. Drop native `SetCommissionRate` action. Read voter-take portions from the existing `DelegateProfile` contract (mainnet 0xfa7f5..0e258 / testnet 0xd19ff..7774) via `getEncodedProfile(delegate)` at `PutPollResult`, invert to commission (`10000 - portion*100`), and support separate `BlockCommissionRate` + `EpochCommissionRate`. Empty profile falls back to legacy full-amount-to-delegate. Reuses operator UX; requires contract-owner governance (multisig / burn / delta caps) as a hard prerequisite. 2. Native compound via existing auto-deposit contract. When a voter has an active bucket registered with the compound contract (mainnet io108ck..q95uat9 / testnet 0xB575D..0B9Cf), the on-chain epoch distribution calls `AddDeposit(bucketId, amount)`; otherwise the share accrues to `unclaimedBalance` for pull-claim. Preserves today's passive-user compound UX. LSD holders get pull-claim parity with today. 3. Batched per-delegate log. Replace per-voter `VOTER_REWARD` receipt events with a single `DelegateDistributed{epoch, delegate, voters[], amounts[], commissionAmount, snapshotHash, blockComponent, epochComponent}` per opted-in delegate per epoch. Cuts receipt log volume from 10k-50k/epoch to ~24-100/epoch and gives off-chain verifiers a single anchor to check the frozen snapshot against. 4. Per-delegate opt-in transition. New `Candidate.VoterRewardOnchainOptIn` bool + `SetVoterRewardOptIn(bool)` action. Default post-fork is opt-out: legacy Hermes continues to distribute the full amount from the delegate's `RewardAddress`. Delegates must send an explicit opt-in tx; effect is delayed one epoch via the poll snapshot. Bidirectional (delegates can opt back out). Hermes off-chain service must filter opted-in delegates from `distributeRewards` at the same snapshot boundary to prevent double-spend. Rationale, backwards compatibility, six new test cases (opt-in delay, compound routing, empty-profile fallback, batched log format, Hermes double-spend prevention, contract-rate snapshot invariance), expanded security considerations (contract-owner governance, semantic inversion guard, opt-in coexistence, compound reentrancy, log payload bound, contract-read determinism), and updated Protocol Changes Required table all included. Co-Authored-By: Chen Chen <envestcc@gmail.com>
Draft
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Amends IIP-59 so protocol-native distribution matches the two reward streams Hermes previously distributed — block reward + epoch reward — preserving voter net income at Hermes-era levels for the same commission rate. Foundation Bonus is explicitly out of scope (already zero on mainnet).
This is a spec-only change (touches
iip-59.md). No code in this PR.What changed
Specification (§3 split into 3.1–3.4)
GrantBlockRewardroutes to a per-delegate pending pool whenCommissionRate > 0. O(1) per block: one state read + one state write. No per-voter work at block time.GrantEpochRewarddrains each delegate's pending pool and folds it into the single per-epoch voter distribution call. Total voter income =(epochReward + Σ blockReward) × (1 − commission).GrantEpochRewarddrains pending pools for delegates that produced blocks but exited top-N mid-epoch. Bounded by candidate count (~100).PutPollResult; distribution reads the frozen snapshot, not the live staking view. Voters get a ~1.5-epoch reaction window on rate changes.Skeletons (§7)
GrantBlockReward— pending pool path + legacy path.GrantEpochReward— fold pending,distributeToVotersreading the snapshot, orphan drain.DelStatewhen a candidate's voter list empties.GetActiveBucketsByCandidatemarked as no longer needed — the reward path reads only the snapshot.Supporting sections
Test plan
🤖 Generated with Claude Code