Skip to content

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
iotexproject:masterfrom
envestcc:iip-59/pr4-block-reward-folding
Closed

feat(rewarding): fold block reward into IIP-59 pending pool + drain (IIP-59 PR 4/6)#4881
envestcc wants to merge 4 commits into
iotexproject:masterfrom
envestcc:iip-59/pr4-block-reward-folding

Conversation

@envestcc

@envestcc envestcc commented Jul 8, 2026

Copy link
Copy Markdown
Member

Summary

Fourth PR of the IIP-59 protocol-native voter reward distribution series (spec: iotexproject/iips#73). Stacked on #4880 — PR 3 built distributeVoterReward for 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 through distributeVoterReward at epoch close, mirroring the epoch-reward math. Priority tip (effectiveTip) still credits the producer directly — it's tx-inclusion incentive, not delegate compensation.

  • No behavior change pre-flag. Gated on the same FeatureCtx.NoVoterRewardDistribution predicate as PRs 1–3, with the same fallback triggers as distributeVoterReward. Legacy path — grantToAccount(rewardAddr, totalReward) + BLOCK_REWARD log — still runs when the flag is set, the candidate is missing/unresolvable, or commission rate is zero.
  • What's new:
    • Per-delegate pending pool in the rewarding namespace, keyed by candidate identity; and a sorted index for deterministic drain ordering.
    • GrantBlockReward routes eligible producers into the pool; GrantEpochReward drains it via distributeVoterReward after slashing and before the existing epoch-reward loop.
    • BLOCK_REWARD / PRIORITY_BONUS receipt logs unchanged — the routing decision is internal accounting. Per-voter distribution surfaces in the epoch receipt via PR 3's VOTER_REWARD / DELEGATE_COMMISSION logs.

Why a pool?

voter_weight_snapshot is only written at PutPollResult (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"):

Key Value Purpose
"pbr" || candIdentity.Bytes() pendingBlockReward { amount, rewardAddr, commissionRate } per-delegate pool entry
"pbi" pendingBlockRewardIndex { identities []address.Address } sorted index of delegates with balance

Fields captured at credit time. rewardAddr and commissionRate are 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 in bytes.Compare order. Avoids a prefix scan on the underlying KV store and pins receipt-log ordering.

Both structs get Serialize/Deserialize via new proto messages in the existing rewardingpb/rewarding.proto; no new .proto file. Regen with protoc-gen-go v1.26.0 from repo root:

protoc --go_out=. --go_opt=paths=source_relative action/protocol/rewarding/rewardingpb/rewarding.proto

GrantBlockReward — route based on eligibility

Prologue (assertNoRewardYet / calculateTotalRewardAndTip / updateAvailableBalance) is untouched — the fund invariant unclaimedBalance ≤ totalBalance and the per-height idempotency sentinel don't change. Only the "credit" step is routed:

if p.blockRewardEligibleForVoterSplit(fCtx, producerCand) {
    if !isZero(effectiveTip) {
        if err := p.grantToAccount(ctx, sm, rewardAddr, effectiveTip); err != nil {
            return nil, err
        }
    }
    if err := p.creditPendingBlockReward(ctx, sm, producerCand, blockReward); err != nil {
        return nil, err
    }
} else {
    // legacy — unchanged
    if err := p.grantToAccount(ctx, sm, rewardAddr, totalReward); err != nil {
        return nil, err
    }
}

Eligibility predicate mirrors PR 3's distributeVoterReward fallback triggers exactly, so "am I eligible" gives the same answer at block time and epoch time:

func (p *Protocol) blockRewardEligibleForVoterSplit(fCtx protocol.FeatureCtx, cand *state.Candidate) bool {
    return !fCtx.NoVoterRewardDistribution &&
        cand != nil &&
        cand.Identity != "" &&
        cand.CommissionRate > 0
}

GrantEpochReward — drain phase

New 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):

  1. Read pool entry {amount, rewardAddr, commissionRate}. amount == 0 → delete entry, continue (defensive).
  2. If identity is in the current-epoch top-N (map[identity]*state.Candidate built from the poll snapshot) → use the fresh *state.Candidate from the map. Picks up mid-epoch rate/rewardAddr changes captured at credit time and re-frozen by PutPollResult.
  3. Else (orphan) → construct a synthetic *state.Candidate from the pool entry's frozen rewardAddr + commissionRate.
  4. Call PR 3's distributeVoterReward(ctx, sm, cand, entry.rewardAddr, entry.amount, blkHeight, actionHash):
    • handled=true → append logs to output.
    • handled=false → belt-and-suspenders legacy fallback (grantToAccount + EPOCH_REWARD log). Unreachable under the current predicate (pre-flag never credited the entry) but retained in case the flag ever re-flips mid-epoch.
  5. Delete the pool entry.

Finally delete the pool index.

Fund accounting. unclaimedBalance was already debited at block-credit time via the original updateAvailableBalance(totalReward). The drain phase only moves money between subaccounts (pool → voter / delegate) via grantToAccount; no additional fund debit or credit. The existing epoch loop's actualTotalReward accumulator is NOT bumped by drained amounts — those were accounted for at block time. The final updateAvailableBalance(actualTotalReward) stays unchanged.

Idempotency. GrantEpochReward is already guarded by assertNoRewardYet(_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.

  1. D's identity is in the pool index (was inserted during E).
  2. Drain at close of E+1 finds D → not in top-N map → synthesize *state.Candidate from D's frozen pool-entry fields.
  3. distributeVoterReward calls staking.VoterWeightsFromSnapshot(sm, identity):
    • If D was in E's PutPollResult top-N (i.e. snapshot exists) → normal voter split with D's last-known voter set.
    • If not (D never made it into a snapshot) → PR 3's (nil, nil) branch: full amount to D as a single DELEGATE_COMMISSION log.
  4. Pool entry deleted.

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

  • Pool index sorted by identityAddr.Bytes(); drain iterates in that order.
  • distributeVoterReward (PR 3) iterates its voter list in sorted order.
  • All map accesses in the drain phase are lookups only; no for k := range m iteration reaches receipt-log or state-write order.
  • sort.Slice with bytes.Compare(a.Bytes(), b.Bytes()) — same comparator as PR 2's snapshot helper.

Tests

pending_block_reward_test.go — 14 functions / 19 subcases:

  • Struct roundtrip for both pendingBlockReward and pendingBlockRewardIndex (zero, small, large amount; nil rewardAddr; malformed amount rejected on decode).
  • Index insert semantics (sort order preserved, dedup on repeat).
  • creditPendingBlockReward create + accumulate; second credit refreshes commissionRate (mid-epoch rate change captured); multi-delegate case verifies index stays sorted; zero-amount is a no-op.
  • Eligibility predicate matrix (all fallback triggers × the pass-through case).
  • Drain:
    • Empty pool → no-op.
    • Top-N with snapshot: fresh 20% commission rate preferred over entry's frozen 10%; math asserted to the wei — commission=200_000, voterPool=800_000, per-voter 266_666, dust=2, delegate = 200_002; 4 logs in canonical order.
    • Orphan with snapshot: delegate not in current-epoch top-N but snapshot exists → normal split using frozen 10% rate from entry.
    • Orphan without snapshot: no snapshot → full amount to delegate as DELEGATE_COMMISSION.
    • Legacy fallback branch: fresh candidate has CommissionRate=0 → drain routes to belt-and-suspenders grantToAccount + EPOCH_REWARD path.
  • Index and pool entries verified deleted after each drain scenario.

Test plan

  • go build ./...
  • go vet ./action/protocol/rewarding/...
  • go test ./action/protocol/rewarding/... -count=1 — 55/55 green (existing 36 + new 19 subcases).
  • Verified pre-existing blockchain/blockdao failures (Test_blockDAO_Start, TestBlockIndexerChecker_CheckIndexer/.../FailedToGetAddress) reproduce on PR 3 baseline and on master. Unrelated to this PR.
  • Cross-flag matrix at real block heights — deferred to PR 5's e2e determinism harness.
  • Multi-epoch fund conservation under mixed producers — deferred to PR 5.

Series roadmap

🤖 Generated with Claude Code

envestcc and others added 4 commits July 2, 2026 09:33
… (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>
@envestcc
envestcc requested a review from a team as a code owner July 8, 2026 11:34
@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

@envestcc

Copy link
Copy Markdown
Member Author

Closing in favor of a redesigned stack based on the IIP-59 amendment: iotexproject/iips#74.

Four design pivots supersede this PR:

  1. Commission rate source — Read from the existing DelegateProfile contract at PutPollResult (via getEncodedProfile), not stored via a native SetCommissionRate action. Split into BlockCommissionRate + EpochCommissionRate; portion → commission inversion at the bridge.
  2. Voter compound — Reuse the existing AutoDeposit contract to route each voter's share to AddDeposit when the voter has a registered active bucket; else credit unclaimedBalance. Preserves today's passive-user compound UX.
  3. Batched receipt log — Single DelegateDistributed{epoch, delegate, voters[], amounts[], snapshotHash, ...} per opted-in delegate per epoch, replacing per-voter VOTER_REWARD logs (~24-100 logs/epoch vs 10k-50k).
  4. Per-delegate opt-in — New Candidate.VoterRewardOnchainOptIn + SetVoterRewardOptIn action. Default is opt-out (legacy Hermes continues). Effect delayed 1 epoch via PutPollResult snapshot. Bidirectional. Hermes off-chain service filters opted-in delegates to prevent double-spend.

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.

@envestcc envestcc closed this Jul 10, 2026
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
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