Skip to content

IIP-59: cover block reward + snapshot-based voter list#73

Merged
envestcc merged 1 commit into
iotexproject:masterfrom
envestcc:iip-59-block-epoch-amendment
Jul 10, 2026
Merged

IIP-59: cover block reward + snapshot-based voter list#73
envestcc merged 1 commit into
iotexproject:masterfrom
envestcc:iip-59-block-epoch-amendment

Conversation

@envestcc

@envestcc envestcc commented Jul 1, 2026

Copy link
Copy Markdown
Member

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)

  • §3.1 Block reward accumulation. GrantBlockReward routes to a per-delegate pending pool when CommissionRate > 0. O(1) per block: one state read + one state write. No per-voter work at block time.
  • §3.2 Epoch reward split. GrantEpochReward drains each delegate's pending pool and folds it into the single per-epoch voter distribution call. Total voter income = (epochReward + Σ blockReward) × (1 − commission).
  • §3.3 Orphan drain. A trailing pass at the end of GrantEpochReward drains pending pools for delegates that produced blocks but exited top-N mid-epoch. Bounded by candidate count (~100).
  • §3.4 Snapshot semantics. Both commission rate and per-voter weight list are frozen at the previous epoch's PutPollResult; distribution reads the frozen snapshot, not the live staking view. Voters get a ~1.5-epoch reaction window on rate changes.

Skeletons (§7)

  • §7.3 GrantBlockReward — pending pool path + legacy path.
  • §7.4 GrantEpochReward — fold pending, distributeToVoters reading the snapshot, 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 a candidate's voter list empties.
  • §7.6 GetActiveBucketsByCandidate marked as no longer needed — the reward path reads only the snapshot.

Supporting sections

  • Migration: Hermes-era rates map 1:1 (no upward adjustment needed to compensate for block reward).
  • Rationale: added "Why fold block reward instead of distributing per block" — O(1) per block, one deterministic epoch distribution, identical voter economics.
  • Performance: sub-ms per-block overhead; per-epoch overhead unchanged from the epoch-only design.
  • Backwards Compatibility: new pending pool + snapshot state, both persisted (restart-safe).
  • Security Considerations: pending pool drained exactly once per epoch; snapshot determinism invariant (sorted encoding → byte-identical blobs) called out.
  • Test Cases: three new cases (add iip-5 #9 block reward folding, iip 6: exclude delegate nodes with lower than expected version #10 non-top-N pending drain, add iip 7 #11 snapshot reaction window).

Test plan

  • Editorial review of §3.1–3.4 and §7.3–7.6 for internal consistency
  • Confirm the Foundation Bonus exclusion is acceptable to reviewers (proposal explicitly defers a future proposal for that stream)
  • Cross-check the 1:1 rate mapping claim in §5 against Hermes v1 fee schedule
  • Verify §3.3 orphan drain bound (~100 candidates) matches current mainnet candidate count

🤖 Generated with Claude Code

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>
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>
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.
@envestcc
envestcc merged commit dc8156e into iotexproject:master Jul 10, 2026
@envestcc
envestcc deleted the iip-59-block-epoch-amendment branch July 10, 2026 01:32
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>
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