feat(rewarding): fold block reward into IIP-59 pending pool + drain (IIP-59 PR 4/6)#4881
Closed
envestcc wants to merge 4 commits into
Closed
feat(rewarding): fold block reward into IIP-59 pending pool + drain (IIP-59 PR 4/6)#4881envestcc wants to merge 4 commits into
envestcc wants to merge 4 commits into
Conversation
… (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>
…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.
…eward (IIP-59 PR 3/6) Consumes PR 2's per-candidate voter-weight snapshot to split each delegate's epoch reward according to its frozen commissionRate: the commission (rate/10000 of totalReward) goes to the delegate's reward address; the remaining pool is prorated across voters using the frozen weights; integer-division dust folds back to the delegate. Total reward is conserved to the wei. - rewardingpb: extend RewardLog.RewardType with VOTER_REWARD=5 and DELEGATE_COMMISSION=6. Regenerated .pb.go with protoc-gen-go 1.26.0. - rewarding: new voter_reward.go implements distributeVoterReward with a (logs, handled, err) contract: handled=false triggers the legacy grantToAccount + EPOCH_REWARD fallback in the caller. Legacy paths: NoVoterRewardDistribution flag on, CommissionRate == 0, or empty Identity. Hard errors: rate > 10000, unparseable Identity, nil rewardAddr. - reward.go: widen splitEpochReward to return the filtered []*state.Candidate aligned with addrs/amounts so GrantEpochReward can pass the full state.Candidate (with frozen CommissionRate + Identity) into distributeVoterReward. This fixes the PoC's silent-fallback bug where an operator address was fed to CandidateByIdentifier. - staking: WriteVoterWeightSnapshotForTest exports a seed helper so rewarding tests can populate the snapshot namespace directly without spinning up viewData + a bucket universe. Sort inline via sort.Slice. Tests: 16 table-driven cases in voter_reward_test.go covering the legacy-fallback matrix, invalid-input errors, no-voter degenerate paths, 100% commission, even split (conservation asserted), dust folding (exact per-voter shares + delegate dust), and zero-weight voter skipped inside a mixed snapshot. Gated behind featureCtx.NoVoterRewardDistribution (bound to !IsToBeEnabled). Pre-fork behavior is byte-identical to today. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…IIP-59 PR 4/6)
Route block-reward credit through a per-delegate pending pool when the
producer is IIP-59-eligible (feature flag on, non-zero commission,
parseable identity). The pool accumulates over the epoch and drains at
epoch close via distributeVoterReward, mirroring PR 3's epoch-reward
split. Priority tip continues to credit the producer directly.
Pool state:
- "pbr" || candIdentity.Bytes(): {amount, rewardAddr, commissionRate};
rewardAddr + commissionRate refreshed on each credit so mid-epoch
changes are captured and orphan drains have everything they need.
- "pbi": sorted index of identities with balance; drives deterministic
drain order and avoids a prefix scan.
Wiring:
- GrantBlockReward: eligibility predicate routes between legacy
grantToAccount(totalReward) and creditPendingBlockReward(blockReward)
+ grantToAccount(effectiveTip). Fund debit + BLOCK_REWARD /
PRIORITY_BONUS logs unchanged.
- GrantEpochReward: drainPendingBlockRewards runs after slashing and
before the existing epoch-reward loop. Top-N identities use the fresh
*state.Candidate from the current poll snapshot; orphans use a
synthetic candidate built from the pool entry. No additional fund
debit (money moves between subaccounts only).
Tests (pending_block_reward_test.go): struct roundtrip, index insert
semantics, credit create/accumulate/refresh, multi-delegate sort order,
eligibility predicate matrix, drain top-N (fresh rate preferred over
frozen), drain orphan-with-snapshot, drain orphan-without-snapshot
(full amount to delegate), and drain legacy fallback belt-and-suspenders.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Member
Author
|
Closing in favor of a redesigned stack based on the IIP-59 amendment: iotexproject/iips#74. Four design pivots supersede this PR:
Rationale: original design broke Hermes UX in four places (push→pull, compound gap, service fee removal, log volume) and had no per-delegate migration ramp. Contract-backed rate + native compound restore feature parity; opt-in makes rollout per-delegate rather than a fork-wide flip. New draft stack will be opened as Draft PRs until iotexproject/iips#74 lands. Branch preserved locally for reference. |
5 tasks
envestcc
added a commit
that referenced
this pull request
Jul 13, 2026
Adds a persistent per-delegate opt-in flag on staking.Candidate that gates protocol-native voter reward distribution. Default false — post-fork the legacy path (full block/epoch reward to RewardAddress, off-chain Hermes service continues) still runs unless the delegate explicitly opts in. Scope of this change is intentionally narrow: - stakingpb.Candidate gains field 11 (voterRewardOnchainOptIn bool). - staking.Candidate Go struct gains VoterRewardOnchainOptIn; Clone, Equal, toProto, fromProto all thread it. Default zero-value keeps existing candidates opting out on decode. - TestSerWithVoterRewardOnchainOptIn covers false/true round-trip through proto, Equal's flag sensitivity, and Clone independence. Deferred to follow-up PRs (per amended IIP-59, iotexproject/iips#74): - SetVoterRewardOptIn native action + handler that mutates the flag with a one-epoch delay via the PutPollResult snapshot (bidirectional flip). - BlockCommissionRate / EpochCommissionRate — these are per-epoch snapshot values populated from the DelegateProfile contract at PutPollResult; they live on the poll snapshot (PR 2') rather than the persistent Candidate. Superseded PRs: #4865 / #4866 / #4880 / #4881 (all closed 2026-07-10). Refs iotexproject/iips#74
envestcc
added a commit
that referenced
this pull request
Jul 16, 2026
Adds a persistent per-delegate opt-in flag on staking.Candidate that gates protocol-native voter reward distribution. Default false — post-fork the legacy path (full block/epoch reward to RewardAddress, off-chain Hermes service continues) still runs unless the delegate explicitly opts in. Scope of this change is intentionally narrow: - stakingpb.Candidate gains field 11 (voterRewardOnchainOptIn bool). - staking.Candidate Go struct gains VoterRewardOnchainOptIn; Clone, Equal, toProto, fromProto all thread it. Default zero-value keeps existing candidates opting out on decode. - TestSerWithVoterRewardOnchainOptIn covers false/true round-trip through proto, Equal's flag sensitivity, and Clone independence. Deferred to follow-up PRs (per amended IIP-59, iotexproject/iips#74): - SetVoterRewardOptIn native action + handler that mutates the flag with a one-epoch delay via the PutPollResult snapshot (bidirectional flip). - BlockCommissionRate / EpochCommissionRate — these are per-epoch snapshot values populated from the DelegateProfile contract at PutPollResult; they live on the poll snapshot (PR 2') rather than the persistent Candidate. Superseded PRs: #4865 / #4866 / #4880 / #4881 (all closed 2026-07-10). Refs iotexproject/iips#74
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
Fourth PR of the IIP-59 protocol-native voter reward distribution series (spec: iotexproject/iips#73). Stacked on #4880 — PR 3 built
distributeVoterRewardfor the epoch stream; this PR folds the block reward into the same split. The block reward accrues to a per-delegate pending pool during the epoch and drains throughdistributeVoterRewardat epoch close, mirroring the epoch-reward math. Priority tip (effectiveTip) still credits the producer directly — it's tx-inclusion incentive, not delegate compensation.FeatureCtx.NoVoterRewardDistributionpredicate as PRs 1–3, with the same fallback triggers asdistributeVoterReward. Legacy path —grantToAccount(rewardAddr, totalReward)+BLOCK_REWARDlog — still runs when the flag is set, the candidate is missing/unresolvable, or commission rate is zero.GrantBlockRewardroutes eligible producers into the pool;GrantEpochRewarddrains it viadistributeVoterRewardafter slashing and before the existing epoch-reward loop.BLOCK_REWARD/PRIORITY_BONUSreceipt logs unchanged — the routing decision is internal accounting. Per-voter distribution surfaces in the epoch receipt via PR 3'sVOTER_REWARD/DELEGATE_COMMISSIONlogs.Why a pool?
voter_weight_snapshotis only written atPutPollResult(epoch boundary). Between snapshots the on-chain state has no authoritative per-voter share, so block-time splitting isn't well-defined. Accumulating block rewards per-delegate and settling them at the next snapshot boundary is the cleanest fix: same voters, same weights, same math as the epoch stream.Storage layout
Two new state entries in
state.RewardingNamespace, matching the existing 3-byte prefix convention ("adm"/"fnd"/"acc"/"xpt"):"pbr" || candIdentity.Bytes()pendingBlockReward { amount, rewardAddr, commissionRate }"pbi"pendingBlockRewardIndex { identities []address.Address }Fields captured at credit time.
rewardAddrandcommissionRateare refreshed on every credit so mid-epoch commission-rate changes are captured, and orphan drains (delegate churned out of top-N) still have everything needed to split — no dependency on a stale poll snapshot at drain time.Sorted index for determinism. Insertion is binary-search
insert-if-absent; drain iterates inbytes.Compareorder. Avoids a prefix scan on the underlying KV store and pins receipt-log ordering.Both structs get
Serialize/Deserializevia new proto messages in the existingrewardingpb/rewarding.proto; no new .proto file. Regen with protoc-gen-go v1.26.0 from repo root:GrantBlockReward— route based on eligibilityPrologue (
assertNoRewardYet/calculateTotalRewardAndTip/updateAvailableBalance) is untouched — the fund invariantunclaimedBalance ≤ totalBalanceand the per-height idempotency sentinel don't change. Only the "credit" step is routed:Eligibility predicate mirrors PR 3's
distributeVoterRewardfallback triggers exactly, so "am I eligible" gives the same answer at block time and epoch time:GrantEpochReward— drain phaseNew
drainPendingBlockRewards(ctx, sm, candidates, blkHeight, actionHash) ([]*action.Log, error)runs after slashing and before the existing epoch-reward split loop — slashing still operates on the current-epoch active list without pool interference.For each identity in the pool index (sorted):
{amount, rewardAddr, commissionRate}.amount == 0→ delete entry, continue (defensive).map[identity]*state.Candidatebuilt from the poll snapshot) → use the fresh*state.Candidatefrom the map. Picks up mid-epoch rate/rewardAddr changes captured at credit time and re-frozen by PutPollResult.*state.Candidatefrom the pool entry's frozenrewardAddr+commissionRate.distributeVoterReward(ctx, sm, cand, entry.rewardAddr, entry.amount, blkHeight, actionHash):handled=true→ appendlogsto output.handled=false→ belt-and-suspenders legacy fallback (grantToAccount+EPOCH_REWARDlog). Unreachable under the current predicate (pre-flag never credited the entry) but retained in case the flag ever re-flips mid-epoch.Finally delete the pool index.
Fund accounting.
unclaimedBalancewas already debited at block-credit time via the originalupdateAvailableBalance(totalReward). The drain phase only moves money between subaccounts (pool → voter / delegate) viagrantToAccount; no additional fund debit or credit. The existing epoch loop'sactualTotalRewardaccumulator is NOT bumped by drained amounts — those were accounted for at block time. The finalupdateAvailableBalance(actualTotalReward)stays unchanged.Idempotency.
GrantEpochRewardis already guarded byassertNoRewardYet(_epochRewardHistoryKeyPrefix, epochNum). Re-execution is blocked upstream, so the drain phase is naturally one-shot per epoch.Orphan handling (concrete)
Scenario: delegate D produces N blocks in epoch E, rotates out for E+1.
*state.Candidatefrom D's frozen pool-entry fields.distributeVoterRewardcallsstaking.VoterWeightsFromSnapshot(sm, identity):(nil, nil)branch: full amount to D as a singleDELEGATE_COMMISSIONlog.Voters are never stranded — accrued block rewards flow to whoever the last snapshot deemed them owed to, or fall back to the delegate.
Determinism guardrails
identityAddr.Bytes(); drain iterates in that order.distributeVoterReward(PR 3) iterates its voter list in sorted order.mapaccesses in the drain phase are lookups only; nofor k := range miteration reaches receipt-log or state-write order.sort.Slicewithbytes.Compare(a.Bytes(), b.Bytes())— same comparator as PR 2's snapshot helper.Tests
pending_block_reward_test.go— 14 functions / 19 subcases:pendingBlockRewardandpendingBlockRewardIndex(zero, small, large amount; nil rewardAddr; malformed amount rejected on decode).creditPendingBlockRewardcreate + accumulate; second credit refreshescommissionRate(mid-epoch rate change captured); multi-delegate case verifies index stays sorted; zero-amount is a no-op.commission=200_000,voterPool=800_000, per-voter266_666,dust=2, delegate= 200_002; 4 logs in canonical order.DELEGATE_COMMISSION.CommissionRate=0→ drain routes to belt-and-suspendersgrantToAccount + EPOCH_REWARDpath.Test plan
go build ./...go vet ./action/protocol/rewarding/...go test ./action/protocol/rewarding/... -count=1— 55/55 green (existing 36 + new 19 subcases).blockchain/blockdaofailures (Test_blockDAO_Start,TestBlockIndexerChecker_CheckIndexer/.../FailedToGetAddress) reproduce on PR 3 baseline and onmaster. Unrelated to this PR.Series roadmap
SetCommissionRateactionPutPollResultNoVoterRewardDistributiongate)🤖 Generated with Claude Code