feat(rewarding,poll,staking): IIP-59 voter reward distribution (PR 4/5)#4864
Closed
envestcc wants to merge 9 commits into
Closed
feat(rewarding,poll,staking): IIP-59 voter reward distribution (PR 4/5)#4864envestcc wants to merge 9 commits into
envestcc wants to merge 9 commits into
Conversation
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>
|
This was referenced Jul 1, 2026
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. |
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
The flip-the-switch PR.
GrantEpochRewardnow 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 returnsnil/ no-op untilNoVoterRewardDistributionis false.This PR connects everything PRs #4859 / #4860 / #4862 / PR 2.5 (#4863) already shipped:
state.Candidate.CommissionRatefieldVoterWeightViewinfrastructureSetCommissionRateactionPutPollResultsnapshot +distributeVoterRewardSeries
What's new
action/protocol/rewarding/voter_reward.go(new):cand.CommissionRatefrom the frozenstate.Candidatesnapshot (not livestaking.Candidate) so a mid-epochSetCommissionRatedoesn't retroactively change the in-flight epoch's payout. This is the mechanism that gives voters ~1.5 epochs of reaction time.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).COMMISSION_REWARDlog.commission + sum(shares) + dust == totalRewardexactly.action/protocol/rewarding/reward.go:splitEpochRewardgains a 4th return: thefilteredCandidatesslice parallel toaddrs/amounts. HandsdistributeVoterRewardthe whole*state.Candidateso it hasIdentity(for the view lookup) ANDCommissionRate(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 expectedIdentity).GrantEpochReward's per-candidate loop callsdistributeVoterRewardfirst;(nil, nil)drops through to the legacyEPOCH_REWARDgrant path.action/protocol/rewarding/rewardingpb/rewarding.proto+ regen:action/protocol/poll/util.go:snapshotCommissionRates: atPutPollResulttime (mid-epoch), copies each delegate's lateststaking.Candidate.CommissionRateonto the next-epochstate.Candidatesnapshot. The frozen value travels throughshiftCandidatesat the epoch boundary and is whatGrantEpochRewardreads.nilso legacy callers see no behavior change.action/protocol/staking/voter_weight_view.go:VoterWeighttype +Protocol.VoterWeightsByCandidatefor cross-package consumption. The internalvoterWeightstays unexported.Test plan
go build ./...passesvoter_reward_test.go(5):computeCommissionboundaries and floor rounding (random fuzz, 200 inputs), exact-conservation invariant (commission + distributed + dust == totalReward) at 2800-voter mainnet scale.poll/util_test.go(1):snapshotCommissionRatestolerates missing / bogus / unknown Identity entries without erroring or panicking../action/...sweep: 943 / 945 pass (the 2 remaining failures are the pre-existing flakyTestProtocol_FetchBucketAndValidatebug: TestProtocol_FetchBucketAndValidate flaky in staking test suite #4813 unrelated to this PR).Behavior gating
NoVoterRewardDistribution = true.distributeVoterRewardearly-returns(nil, nil); legacyEPOCH_REWARDpath runs.snapshotCommissionRatesis gated on the same flag. Byte-identical to today.cand.CommissionRate = 0: same legacy path. Delegates that don't callSetCommissionRatekeep exactly today's behavior — opt-in only.cand.CommissionRate > 0: split intoCOMMISSION_REWARD(delegate) + per-voterVOTER_REWARDlogs; voter accounts credited; indexer subscribes to the two new RewardLog types.🤖 Generated with Claude Code