Skip to content

feat(rewarding,staking): IIP-59 on-chain voter reward distribution - #4953

Open
envestcc wants to merge 98 commits into
masterfrom
iip-59/consolidated-pr-5-through-5.5b
Open

feat(rewarding,staking): IIP-59 on-chain voter reward distribution#4953
envestcc wants to merge 98 commits into
masterfrom
iip-59/consolidated-pr-5-through-5.5b

Conversation

@envestcc

@envestcc envestcc commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

Protocol-native, on-chain implementation of IIP-59 voter reward
distribution. Delegate commission is paid at block time; per-delegate
voter payouts run once per era (24 epochs, ≈ 1 day) and are chunked
across multiple blocks so a single 30k-bucket delegate cannot exceed
the 2.5s Dardanelles block budget.

Fully gated behind NoVoterRewardDistribution (bound to
!g.IsToBeEnabled(height)) — until the fork height is set in a
follow-up PR, every path in this change is a no-op and the chain
stays on the legacy Hermes distribution.

Motivation

Voter rewards are currently distributed off-chain via Hermes. This
change moves the accounting on-chain so:

  • delegate/voter splits are enforced by the protocol, not by a trusted
    operator;
  • per-voter payouts are deterministic and independently verifiable
    from block state;
  • the fund cannot silently drift from the ledger.

The design fits four constraints from the IIP-59 proposal
(iip_proposals/iip-59.md): (1) rates come from an on-chain
DelegateProfile contract, (2) compound routing comes from an
on-chain AutoDeposit contract, (3) opt-in is per-delegate, (4)
distribution work must fit inside the 2.5s block budget at 30k
buckets.

High-level design

Dual-stream commission, era-based voter payout

  • Block reward — commission portion paid to the delegate at block
    time via GrantBlockReward; voter portion credited to
    PendingBlockRewardPool[candidateID].
  • Epoch reward — Phase A pays epoch commission + credits epoch
    voter portion to the same per-delegate pool. Runs every epoch.
  • Voter payout — at the era boundary
    (epochNum % EpochsPerRewardEra == 0), FreezePollSnapshot
    writes per-voter weights into CandidatePollSnapshot.Entries; a
    cursor kicks off, and subsequent blocks drain the accumulated pool
    across all opted-in delegates until the era's frozen amounts are
    fully paid.

Chunked drain with two caps

runVoterDistributionChunk iterates delegates in a deterministic order
and is bounded by two independent genesis-configured caps:

  • CompoundBatchSize — max delegates processed per block. Prevents
    a chain with many small delegates from spending its whole block on
    per-delegate overhead.
  • VoterBudgetPerBlock — max voters paid per block. Lets one large
    delegate stop mid-list and resume in a later block.

Either cap can terminate a chunk; both zero = single-block full drain
(pre-fork behavior).

EpochDrainCursor — mid-delegate resumable

Persisted per era. Two fields: DelegateIndex (next delegate) and
VoterIndex (next voter within DelegateIndex). Deterministic
allocation over the full frozen snap.Entries is recomputed every
chunk; only the payout window [VoterIndex, VoterIndex+budget)
changes. Splitting one delegate across K blocks produces
byte-identical per-voter amounts to the un-split single-block run.

VoterWeightView — per-voter weight source

Reward distribution needs each voter's total weight against a
delegate. Rather than enumerate all buckets at freeze time (benched
~7 s at the 30k ceiling — ≈ 3× the 2.5 s block budget), a
VoterWeightView is maintained incrementally: hooked into 13
mutation sites in the staking handlers, it applies deltas per block
in O(1) amortized. FreezePollSnapshot reads a materialized
snap.Entries slice from it in O(voters).

The view is rebuilt from all buckets at CreateBaseView; its digest
is persisted and validated at boot so any hook drift fails loudly.

Contract bridges (read-only)

  • rewarding/delegateprofile/ — reads
    (commissionRateBlock, commissionRateEpoch, optIn) at
    PutPollResult; frozen into the poll snapshot. Per-delegate read
    failures degrade the delegate back to the legacy path rather than
    halting the block (per-item fallback, not deterministic halt).
  • rewarding/autodeposit/ — queried at voter payout to route
    compound-eligible amounts. Valid registered bucket ⇒
    AddDepositForCompound (adds to the existing bucket).
    Invalid/unregistered/error ⇒ direct credit to the voter.
    A direct-slot reader path (SlotBucketReader) avoids reentrant EVM
    calls in the reward handler.

Batched DelegateDistributed log

One log per delegate per chunk, encoding Voters[], Amounts[],
Routings[], TotalVoterPool, SnapshotHash. SnapshotHash covers
the full frozen entries and is identical across a delegate's chunks.

Off-chain semantic to be aware of: TotalVoterPool equals the sum
of Amounts[] in this chunk, not the era-wide frozen amount. Any
off-chain consumer that reassembles distributions must aggregate
partial logs by (SnapshotHash, delegate, epoch). Hermes-patch and
the off-chain verifier are being updated separately for this.

Genesis knobs

All new; safe defaults for the pre-fork branch.

Field Default Purpose
EpochsPerRewardEra 24 Era length in epochs (≈ 1 day)
EpochDrainChunkSize 50 CompoundBatchSize: delegates/block
VoterBudgetPerBlock 2000 Voters/block cap
AutoDepositContractAddress "" Filled with mainnet address in the fork-activation PR
DelegateProfileContractAddress "" Filled with mainnet address in the fork-activation PR

Feature-flag semantics

Bound to NoVoterRewardDistribution in protocol.FeatureCtx:

  • Pre-fork (NoVoterRewardDistribution == true): PutPollResult
    does not freeze the snapshot; runVoterDistributionChunk is not
    invoked; voterBudgetPerBlock(ctx) and epochDrainChunkSize(ctx)
    both return 0. Every code path added here is inert.
  • Post-fork (NoVoterRewardDistribution == false): the snapshot
    freezes at every PutPollResult; era boundaries kick off chunked
    drains; both caps read from genesis.

No new named fork; the existing ToBeEnabled gate is reused because
IIP-59 activation is planned as a scheduled upgrade rather than a
long-lived hard fork.

Test plan

  • go build ./...
  • go vet ./...
  • go test ./action/protocol/rewarding/... ./action/protocol/staking/...
  • go test -tags e2e -run 'TestIIP59ChunkedDrainStress_' ./e2etest/
    (SmallTier, MultiEra, and SingleDelegateLargeVoter all pass)

Coverage highlights:

  • Determinism: chunked drain at chunkSize ∈ {1, 2, all} produces
    byte-identical per-voter amounts and fund invariants.
  • Cross-era continuation: pool amounts accrued after the drain
    starts land in the next era, not the current one.
  • Mid-delegate resume: cursor pauses with
    DelegateIndex=k, VoterIndex>0, resumes on the next block, ends
    identically to a single-block run.
  • VoterWeightView consistency: hook-driven mutation across all 13
    handler sites keeps the view in sync with the bucket store;
    digest-mismatch on boot is an error.
  • AutoDeposit slot reader: sanity test deploys the real mainnet
    runtime bytecode and asserts direct-slot lookups match what the
    contract itself wrote.

Reviewer sanity checks

  • Run the Design-B bench for the VoterWeightView cost
    justification: go test -tags iip59bench -bench BenchmarkFreezeSnapshot -benchtime=5x ./action/protocol/staking/.
  • Confirm a boot-time digest-mismatch error fires when a
    VoterWeightView hook is intentionally removed.
  • Skim runVoterDistributionChunk for the two break paths
    (delegate-cap and voter-cap) and confirm the cursor persists in
    both.
  • Confirm NoVoterRewardDistribution == true short-circuits every
    new call site (this is the pre-fork guarantee).

Follow-ups (separate PRs)

🤖 Generated with Claude Code

envestcc and others added 25 commits July 21, 2026 08:34
…ound routing

Introduce a read-only bridge to the on-chain AutoDeposit contract so PR 3'
(distributeVoterReward rework) can decide per-voter compound-vs-credit
routing at epoch reward distribution time per IIP-59 §3.6.

- New action/protocol/rewarding/autodeposit package:
  * Bridge: stateless wrapper around the pinned AutoDeposit contract,
    exposing LookupBucket(ctx, reader, voter) → (bucketID, present, err).
  * ContractReader: package-local view-call primitive so unit tests can
    stand in a fake without pulling the EVM simulator into scope.
  * Route / Decision types with wire format shared with PR 4.7's
    DelegateDistributed.routings[] encoding.
  * IsBucketEligibleForCompound helper applies §3.6 preconditions 2-4
    (bucket exists, native bucket, AutoStake=true, active, Owner==voter).
  * Structurally mirrors PR 4.5's delegateprofile.Bridge so both bridges
    present a uniform surface to callers.

- Per-item fallback semantic (IIP-59 §3.6): malformed on-chain data
  (negative int256, oversized-for-uint64 value) silent-fallbacks to
  RouteCredit rather than erroring the block. Only wiring bugs (nil
  reader, nil voter, ABI drift, RPC error) hard-error.

- New genesis field Blockchain.AutoDepositContractAddress with empty
  default (compound routing inactive → every voter share → unclaimedBalance).

- New exported staking.VoteBucket.IsUnstaked() and IsNative() wrappers so
  the autodeposit package can gate eligibility without duplicating the
  staking-internal invariants.

AutoDeposit is read live at drain time, not frozen at PutPollResult
(unlike DelegateProfile) — see IIP-59 §3.6.

Ships independently of the earlier stack (PR 4.5 / PR 2') since it shares
no code with them.
…PR 4.7)

Introduces the encoder primitives for IIP-59 §3.2's per-delegate batched
receipt log. distributeToVoters (PR 3', upcoming) calls Pack once per
delegate at epoch close and wraps the returned (topics, data) into an
*action.Log; the batching keeps total epoch-last-block logs bounded by
|topDelegates| + |orphanDrain| (~200) regardless of voter count, avoiding
receipt-trie bloat under load (top 100 delegates × ~2k voters each).

New package action/protocol/rewarding/distributedlog:

- Pack(EventArgs) → (action.Topics, []byte, error) encodes the event in
  the exact wire layout an EVM-emitted DelegateDistributed would produce,
  so eth_getLogs clients (and PR #45's off-chain verifier) can decode with
  stock ethers.js / web3.py against the same ABI.

- EventArgs mirrors §3.2's Solidity signature field-for-field:
  Epoch(indexed) + Delegate(indexed) + RewardAddr + TotalCommission +
  TotalVoterPool + SnapshotHash + Voters[] + Amounts[] + Routings[].

- Routings[] reuses autodeposit.Route verbatim — PR 4.6's enum is the
  single source of truth for the credit/compound wire encoding.

- SnapshotHash(voters, weights) computes the bytes32 digest of a
  delegate's frozen voter list, domain-separated
  ("iip59.delegatedistributed.snapshot.v1") so verifiers can pin the
  exact bytes and the value cannot collide with same-layout hashes from
  other contexts. Empty-list case is well-defined and asserted.

- Hard-errors on caller-side wiring bugs (nil address, nil *big.Int,
  parallel-array length mismatch) — the encoder is not a consensus path
  so there is no per-item to degrade; loud failure surfaces PR 3' bugs.
  Empty voter list is NOT an error: a delegate with zero voters still
  emits a log for observability, per §3.2's "one log per delegate".

- Structurally mirrors PR 4.5's delegateprofile.Bridge and PR 4.6's
  autodeposit.Bridge minimal-ABI package shape. Deliberately does NOT
  construct *action.Log; block context (height, action hash, protocol
  address) belongs to PR 3', keeping this package unit-testable in
  isolation.

15 tests cover: happy path, zero voters, both parallel-length mismatches,
nil-field variants (delegate/reward/commission/pool/per-voter),
selector-pin (golden 32 bytes for verifier stability), full byte-level
round-trip via the same ABI, and SnapshotHash determinism / empty-list
constant / truncation semantics.
Introduce a read-only bridge over the existing DelegateProfile contract
(mainnet io1lfl4ppn2c3wcft04f0rk0jy9lyn4pcjcm7638u) so that PutPollResult
can snapshot per-delegate commission rates from the amended IIP-59 design
(iotexproject/iips#74).

The bridge invokes getProfileByField twice per delegate (blockRewardPortion
and epochRewardPortion), inverts the on-chain voter-take portion into
commission basis points, and returns a per-delegate map. Empty bytes for
either field flag the delegate as unregistered so the caller falls back to
the legacy Hermes path; a partial profile is deliberately treated as
unregistered to preserve the "either fully opted-in or fully legacy"
invariant. Explicit zero voter-take remains distinguishable (single 0x00
byte) and yields a registered 100% commission split. Values exceeding
uint64 or 10000 basis points are rejected rather than silently truncated.

The package has no dependency on protocol.StateManager and is exercised by
14 unit tests using an in-process ABI-round-tripping fake reader.

Refs iotexproject/iips#74. Follow-up PR (2') will call Snapshot at
PutPollResult and freeze the returned rates into the poll snapshot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…legacy path

Snapshot originally propagated any per-delegate read error up, which at the
IIP-59 consensus entry point (PutPollResult) would deterministically halt
block production at every subsequent epoch boundary if one delegate's
DelegateProfile field was malformed or a view call transiently failed.

Same on-chain state produces the same error on every validator, so the
fork stays safe — but wedging the chain is strictly worse than routing the
one bad delegate through the well-defined legacy Hermes path. Absorb the
per-delegate error, emit Registered=false for that entry, and log for
observability. Nil-reader and nil-address stay hard errors: those are
wiring bugs, not on-chain data issues, and must surface loudly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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
… opt-in at PutPollResult (IIP-59)

Introduces the per-candidate poll snapshot IIP-59 needs at each epoch boundary.
Downstream rewarding (PR 3', follow-up) reads this snapshot instead of the
live DelegateProfile contract / live staking.Candidate so a mid-epoch
mutation cannot retroactively re-split rewards that have already begun
accruing.

Wiring:

- stakingpb.CandidatePollSnapshot: block/epoch commission basis points,
  registered flag, opt-in flag, per-voter entries (empty in this PR).
  stakingpb.VoterWeightEntry per-voter tuple.
- staking._candidatePollSnapshot = 5 tag byte; full key
  {tag}||candID.Bytes() under _stakingNameSpace.
- staking.FreezePollSnapshot: writer called from poll/util.setCandidates.
  Nil bridge (contract not configured) → skip rate freeze but still
  capture opt-in from live Candidate; Registered=false forces legacy
  fallback downstream. Any per-delegate error aborts the whole snapshot
  write (no partial map).
- staking.PollSnapshotFor: reader; returns ErrStateNotExist pre-fork /
  pre-write.
- staking.readLiveOptIn: degrades to false when the poll list names a
  candidate that has no staking record, rather than wedging the chain.
- poll.freezeIIP59PollSnapshot: fork + config gate. Guarded by
  fCtx.NoVoterRewardDistribution (pre-fork no-op) and
  Blockchain.DelegateProfileContractAddress (empty ⇒ nil bridge).
- poll.delegateProfileContractReader: view-call plumbing mirrors
  consortium.getContractReaderForGenesisStates verbatim
  (address.ZeroAddress caller, evm.SimulateExecution).
- genesis.Blockchain.DelegateProfileContractAddress: per-network config;
  default empty.
- protocol.FeatureCtx.NoVoterRewardDistribution: fork gate bound to
  !g.IsToBeEnabled(height); zero-value = active post-fork.

Intentionally out of scope for this PR:

- Voter-weight source. Entries is written empty; PR 3' has a degenerate
  branch (empty voter list ⇒ full amount as commission). Follow-up PR
  fills in the actual weight computation.
- Rewarding consumption of the snapshot (PR 3').
- SetVoterRewardOptIn action to mutate the field this snapshot freezes
  (separate PR).

Stacks on iip-59/pr1-candidate-schema-optin (#4911) and
iip-59/pr4.5-delegateprofile-bridge (#4912).

Refs iotexproject/iips#74

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Follows PR 4.5's change to have delegateprofile.Bridge.Snapshot degrade
per-delegate read failures to Registered=false instead of erroring out.

FreezePollSnapshot's docstring now says "on bridge error the snapshot is
still written with Registered=false; opt-in remains captured from the
live Candidate" so downstream (PR 3') has a single degradation contract
to reason about.

Test rename: FreezePollSnapshot_BridgeErrorPropagates →
FreezePollSnapshot_BridgeErrorDegradesToLegacy — asserts the snapshot
IS written and opt-in is preserved on bridge error.

Refs iotexproject/iips#74

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wire the three read-side bridges landed upstream — PR 2' (frozen poll
snapshot), PR 4.6 (autodeposit compound bridge), PR 4.7 (batched
DelegateDistributed log) — into GrantEpochReward. For each opted-in
delegate, IIP-59 §3.2 now: splits the epoch pool by the frozen commission
basis points, allocates the voter pool proportionally by frozen voter
weight in canonical order, routes each per-voter share to compound
(native AddDeposit) or credit (rewarding unclaimedBalance), credits the
delegate's commission to its reward address, and emits exactly one
batched DelegateDistributed log per delegate.

Feature-flag matrix stays honoured:

  NoVoterRewardDistribution=true (pre-fork) → return (nil, false, nil);
    caller runs legacy grantToAccount unchanged.
  VoterRewardOnchainOptIn=false               → same fallback (opt-out).
  no poll snapshot yet                        → same fallback (first
    epoch after registration).
  Registered=false (bridge degraded)          → same fallback.
  autoDepositBridge nil                       → split runs; every voter
    routes to credit (compound routing inactive).

Per-item consensus fallback per feedback-consensus-fallback-vs-halt:
malformed on-chain data (bridge RPC error, bucket read error, ineligible
bucket) downgrades the affected voter to credit rather than halting the
block. Wiring errors (nil staking protocol, log-encoder failure) still
hard-fail.

Cross-protocol seam: staking.AddDepositForCompound is a package-exported
entry point for the rewarding-side compound path. Skips the
handleDepositToStake action-plumbing checks that PR 3' has already
enforced upstream (positive bucketID from AutoDeposit.bucket(voter);
IsBucketEligibleForCompound confirmed native/active/AutoStake/Owner).
Does NOT emit a staking receipt log — the batched DelegateDistributed
log is the single source of truth per delegate.

New:
- action/protocol/rewarding/voter_reward.go — distributeVoterReward
  + splitCommission (basis-points helper) + resolveAutoDepositReader.
- action/protocol/rewarding/voter_reward_test.go — 14 tests covering
  splitCommission edge cases, fork gate, nil-input guards, missing-
  snapshot fallback, bridge-nil default, and Option wiring.
- action/protocol/staking/add_deposit_compound.go — cross-protocol seam.

Modified:
- action/protocol/rewarding/reward.go — one call site + one branch
  inside GrantEpochReward's per-delegate loop. splitEpochReward now
  returns the filtered candidate list so callers see the same index
  domain as addrs/amounts.
- action/protocol/rewarding/protocol.go — Option type +
  WithAutoDepositBridge / WithAutoDepositReader; NewProtocol accepts
  opts ...Option so 11 existing call sites remain unchanged.
- chainservice/builder.go — construct autodeposit.Bridge from
  Blockchain.AutoDepositContractAddress at registerRewardingProtocol;
  empty string ⇒ nil bridge (compound routing inactive).

Stacks on:
- iip-59/pr4.7-delegatedistributed-log (#4923) — merge base.
- iip-59/pr2-snapshot-writer (#4915) — merged in via d7fe741.
- iip-59/pr4.6-autodeposit-bridge (#4922) — via #4923.
- iip-59/pr4.5-delegateprofile-bridge (#4912) — via #4915.

Deliberately out of scope: block-reward folding + orphan drain
(PR 4'); voter-weight source population (Entries currently empty
per PR 2' skeleton — downstream degenerate branch pays full amount as
commission until the weight-source follow-up lands).

Refs iotexproject/iips#74

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…PR 4')

Under IIP-59 §3.2 an opted-in delegate's block reward can no longer be
credited directly to its RewardAddress at block time — it must join the
epoch stream and be split by the same frozen voter snapshot. This commit
introduces the pending block-reward pool, dual-rate distribution, and the
epoch-close orphan drain.

State layout: a new `_pendingBlockRewardPoolKeyPrefix` ("pbrp") holds one
`PendingBlockRewardPool` entry per delegate (proto added, pb.go regen), plus
a sorted `pbrpx` index so end-of-epoch enumeration is canonical without a
namespace scan.

GrantBlockReward now branches on the block producer's frozen
PollSnapshot (via staking.PollSnapshotFor) — if VoterRewardOnchainOptIn is
set and the fork gate is on, the base reward is debited from
unclaimedBalance and credited into the pool instead of the delegate's
account; the priority tip stays with the producer directly since it is fee
income, not voter-splittable. Legacy BLOCK_REWARD log is suppressed on the
opt-in path.

GrantEpochReward folds both streams via a new `distributeCombinedReward`
built on the extracted `allocateAndRouteVoters` helper: block portion
splits by BlockCommissionBasisPoints, epoch portion by
EpochCommissionBasisPoints, both voter pools sum into a single per-voter
allocation, and one batched DelegateDistributed log is emitted per
delegate. When the combined path declines (opt-out mid-epoch) but a pool
balance exists, the pool balance is legacy-granted to the delegate so
nothing is stranded.

After the per-candidate loop, `drainPendingBlockRewardOrphans` sweeps any
pool entries not visited this epoch — these are delegates that dropped
out of the poll list entirely. Destinations, in order: live candidate's
current Reward address (via staking.ConstructBaseView.GetCandidateByOwner)
or, if the candidate is unregistered, refund to fund.unclaimedBalance.
Never burn — the fund invariant unclaimedBalance + Σ(pool) ≤ totalBalance
stays intact.

Test suite covers pool credit accumulation, sorted-index invariant,
idempotent delete, refund fund arithmetic, drain no-op on empty index,
orphan refund when candidate is gone, and visited-set filtering.
newVoterRewardCtx now seeds the staking base view so tests exercising the
drain path can resolve candidate lookups.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces the state schema + Go helpers for a multi-block era-boundary
drain of PendingBlockRewardPool balances into voter accounts, without
wiring it into GrantEpochReward yet. The wiring lands in the next
change; this PR isolates the additive schema/helper piece so the diff
stays readable.

Additions:

  proto rewardingpb.EpochDrainCursor {target_era, delegate_index,
    repeated EpochDrainDelegateWork} — cursor payload persisted at
    state.EpochDrainCursorKey (singleton in RewardingNamespace).
    Absence = no drain in progress. Presence carries the frozen work
    list captured at Phase A so chunks in later blocks read stable
    inputs even if the live pool keeps accruing behind the drain.

  proto rewardingpb.EpochDrainDelegateWork {candidate_identifier,
    pool_amount_frozen} — one frozen per-delegate work item.

  state.EpochDrainCursorKey — singleton key prefix "edc" in
    RewardingNamespace, mirroring the sentinel patterns already in
    state/tables.go for block/epoch reward history.

  action/protocol/rewarding/epoch_drain_cursor.go — cursor Go struct
    + Serialize / Deserialize + readEpochDrainCursor(returns nil,nil
    when absent) / writeEpochDrainCursor(overwrites) /
    deleteEpochDrainCursor (idempotent via existing deleteState
    ErrStateNotExist swallow).

Test coverage:

  round-trip (target_era, delegate_index, delegate list, big.Int pool
    amounts including zero); empty delegate list; missing key returns
    (nil, nil); write→read→delete lifecycle; second delete no-ops;
    write overwrites rather than merges.

Explicitly deferred (follow-up PRs):
  - GrantEpochReward refactor into chunked Phase A/B/C
  - CreatePostSystemActions continuation dispatch on cursor presence
  - IsEraBoundary gate (arrives with the PR A era genesis params)
  - EpochDrainChunkSize genesis field / CompoundBatchSize wiring

Depends on: IIP-59 PR A (#4939) for the IsEraBoundary helper the
follow-up chunked-drain PR will use.
Additive scaffolding for the era-based voter reward distribution
described in the design doc (IIP-59 §8):

- blockchain/genesis: three new Rewarding fields
  - EpochsPerRewardEra (default 24): epochs per voter-reward era
  - VoterBudgetPerBlock (default 2000): max voters credited per block
    during the era-boundary chunked credit path
  - CompoundBatchSize (default 500): max voters swept per block by the
    background compound sweep
  Not part of the genesis Hash proto — no consensus impact until a
  subsequent PR wires them into a fork-gated code path.

- action/protocol: IsEraBoundary(epochNum, epochsPerEra) helper.
  Epoch 0 is never a boundary; epochsPerEra=0 disables the cadence
  entirely so existing tests are undisturbed.

Follow-up PRs (B/C/D) consume these; no behavioral change here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Refactor GrantEpochReward into Phase A (prelude: assert + slash + freeze
delegate work list), Phase B (per-chunk credit loop bounded by
CompoundBatchSize), Phase C (coda: orphan drain, foundation bonus,
sentinel, cursor delete). An epochDrainCursor persists the resume
position between blocks so continuation grants can pick up where the
prior block left off. Frozen PoolAmountFrozen decouples the drain payout
from concurrent GrantBlockReward credits into the same pool.

CreatePostSystemActions emits an EpochReward action on any non-epoch-
boundary block when an active cursor is present, driving the
continuation dispatch.

Cursor operations are gated by !NoVoterRewardDistribution — pre-fork
blocks and CompoundBatchSize==0 configs behave as the legacy single-
block loop, no cursor read/write/delete.

Rationale: at 27,020 mainnet voters, single-block drain measured at
~1.15s in-memory (~3.4s trie-backed), breaching the 2.5s block interval.
Chunking is required for the fork to activate safely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four focused unit tests for the IIP-59 chunked drain refactor:

- TestEpochDrainChunkSize: fork gate short-circuits chunk size to 0 pre-fork
  regardless of CompoundBatchSize, and honors it post-fork.
- TestBuildEpochDrainCursor_FreezesPoolBalances: Phase A captures the pool
  balance at freeze time; post-freeze credits into the same delegate's
  pool do not inflate the cursor value.
- TestGrantEpochReward_RejectsStaleCursor: overrun guard raises a hard
  error when a cursor from a prior era is still resident on Phase A
  entry for a later epoch.
- TestGrantEpochReward_FeatureOffIgnoresCursor: pre-fork legacy path
  neither reads nor deletes the cursor; a cursor persisted before the
  fork opened survives a legacy epoch grant untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… fix (#4945)

* fix(rewarding): allow chunked drain to span epoch boundaries

Chunked epoch-drain, as merged in e2eb830, could not complete across
more than one block: Phase A always runs on the last block of an epoch,
so any continuation block belongs to epoch N+1 — and the continuation
guard rejected any cursor whose TargetEra != current epoch. The result
was a permanent stall at the second chunk on any multi-block drain.

Two fixes:
  1. Relax the guard to reject only FUTURE-epoch cursors (real corrupt
     state — Phase A can only pin to the current epoch). Prior-epoch
     cursors are the legitimate multi-block continuation case; carry
     them through Phase B/C.
  2. Write Phase C's sentinel history under cursor.TargetEra rather
     than the current epoch, so multi-block drains mark the epoch that
     actually triggered the drain as complete. In single-block drains
     the two are equal and behaviour is unchanged.

Update TestGrantEpochReward_RejectsStaleCursor to match the new
semantics — it now covers the "future epoch" corruption case rather
than the removed "prior epoch = misconfigured chunk size" case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(e2etest): add IIP-59 chunked-drain integration bench

Adds TestIIP59EpochGrantPerf, a tier-parameterised e2e bench that spins
up a real itx.Server (chainservice + disk-backed factory + rewarding
protocol) and mints blocks across an era boundary to prove the chunked
epoch-drain actually completes end-to-end. Reports per-block
wall-clock, per-chunk cursor advancement, and drain-total wall-clock.

Default tier is small (3 delegates, 100 voters, era = 2 epochs,
batch = 2) — CI-friendly at <1s. Larger tiers gate on IIP59_PERF_TIER:
  small    3 delegates,    100 voters, era=2  epochs, batch=2
  medium  10 delegates,  1 000 voters, era=4  epochs, batch=4
  mainnet 24 delegates, 27 020 voters, era=24 epochs, batch=4
Mainnet skips under -short.

Because DelegateProfile / AutoDeposit contract bytecode is not in the
repo, the bench does not deploy real contracts. Instead it wires three
test-only injection seams, each a package-level var reset by t.Cleanup:

  chainservice.TestOnlyRewardingOptions
      Appends rewarding.Options to rewarding.NewProtocol so tests can
      swap the AutoDeposit ContractReader.

  poll.TestOnlyDelegateProfileReaderFactory
      Overrides the evm-backed DelegateProfile reader constructed in
      freezeIIP59PollSnapshot with a canned reader that returns a fixed
      basis-points payload (1000 = 10 %).

  staking.TestOnlyGenesisStateSeeder
      Runs after BootstrapCandidates in CreateGenesisStates and before
      the final Commit, so tests can plant N delegates + M voter
      buckets in the same genesis transaction — bypassing the action
      pool, which would be the throughput bottleneck at 27 k voters.

The reader mocks route every voter share through the credit path
(bucket=0 = "unregistered") so the drain exercises per-voter unclaimed-
balance credits — the state-machine cost the C2 chunking targets. The
contract-read cost path is covered separately by task #67 benches.

Also adds:
  action/protocol/staking/perf_seeder.go — reusable helper that plants
      the N delegates + M voter buckets under a deterministic address
      space. Shared with the tagged micro-bench below.
  action/protocol/staking/add_deposit_compound_bench_test.go —
      BenchmarkAddDepositForCompound (build tag: iip59bench) that sizes
      the compound path per mainnet-scale voter count.
  action/protocol/rewarding/epoch_drain_cursor.go —
      TestOnlyEpochDrainSnapshot accessor exposing cursor.DelegateIndex
      / total delegates / target era, so the bench can watch chunks
      advance without touching internals.

Verified: small tier passes in <1 s (drain spans 2 blocks, wall
~30 ms/block, total ~60 ms). Rewarding suite (113) + staking + poll
suites (437) still green. go build + go vet clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(rewarding): dedicated GrantVoterRewardChunk system action for IIP-59 drain (#4946)

* refactor(rewarding): dedicated GrantVoterRewardChunk system action for IIP-59 drain

C2 (#4944) landed the era-boundary chunked drain by extending
GrantEpochReward with cursor-demux continuation: on every non-epoch
block a live cursor caused CreatePostSystemActions to emit an extra
GrantReward{EpochReward} that skipped Phase A and jumped straight into
the drain loop. Semantically that means "an action literally named
epoch reward" ran on non-epoch blocks — a mismatch that misled readers
and directly produced the C2 continuation-guard bug (!= vs > on the
epoch guard) fixed in 65a368d.

Promote the drain continuation to its own system action. Post-refactor:

  BlockReward       every block                   fold into pool
  EpochReward       epoch-last block              Phase A + first chunk
  VoterRewardChunk  non-epoch-last, cursor live   next chunk (+coda on last)

- action.GrantReward: add VoterRewardChunk = 2 (envelope reused; wire
  format & receipt handling unchanged beyond one enum value).
- rewarding.Protocol: split monolithic GrantEpochReward into
    * GrantEpochReward — Phase A only; ANY cursor at entry is now
      corrupt state and fails loud
    * GrantVoterRewardChunk — reads cursor, loads epoch-scoped state
      from cursor.TargetEra (not block's epoch), delegates to the
      shared runner
    * loadEpochDistributionInputs — pure helper for the deterministic
      re-load both entry points need
    * runVoterDistributionChunk — Phase B loop + Phase C coda; used by
      both entry points
- CreatePostSystemActions emits VoterRewardChunk on continuation.
- Validate rejects VoterRewardChunk when the fork gate is closed.
- Handle dispatches VoterRewardChunk with the same settle path as
  EpochReward.
- iotex-proto bumped to v0.6.8 for the enum extension.

Test migration mirrors the semantic shift:
- TestGrantEpochReward_RejectsStaleCursor → RejectsAnyLiveCursor
  (Phase A now rejects any live cursor, not just future-epoch ones).
- New voter_reward_chunk_test.go covers HappyPath, LastChunkRunsCoda,
  CrossEraContinuation (the C2 > guard scenario is now naturally
  handled), MissingCursorErrors (dispatcher invariant), and
  PreForkRejects (Validate + handler defense-in-depth).

Compound routing stays inside runVoterDistributionChunk — splitting
it into a third action would force either a redundant O(voter) walk
or a second cursor state machine, per prior design discussion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(rewarding): finish C2.1 split — GrantEpochReward runs Phase A only

The initial C2.1 refactor kept a first-chunk consumption inline in
GrantEpochReward (via a shared runVoterDistributionChunk call) to save
one block of drain latency. That undermined the whole point of the
new action type: an EpochReward system action would still, under the
hood, do voter distribution work. Reviewers rightfully pushed back
that "distribution belongs in VoterRewardChunk".

Finish the split:

- Post-fork GrantEpochReward now runs slashing + freeze + persist
  cursor, then returns. The receipt carries only Phase A logs.
- All voter distribution (Phase B chunks + Phase C coda: orphan
  drain, foundation bonus, sentinel, cursor delete) is deferred
  entirely to GrantVoterRewardChunk on subsequent non-boundary
  blocks. The first chunk fires on the block after the epoch
  boundary, not on the boundary block itself.
- Pre-fork behavior is unchanged: NoVoterRewardDistribution=true
  takes the legacy single-block path via runVoterDistributionChunk
  with chunkSize=0.

Trade-off: +1 block of drain wall-clock. Mainnet drain grows from
6 to 7 blocks (small tier confirms: 2 → 3 drain_blocks). At 2.5s /
block against an ~8,640-block era window, negligible.

Add TestGrantEpochReward_PostForkOnlyPhaseA to lock the invariant:
post-fork Phase A must persist a cursor and must NOT write the
sentinel — those responsibilities now live entirely in the chunk
handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(rewarding): route pre-fork legacy path around chunk machinery

GrantEpochReward previously called runVoterDistributionChunk(chunkSize=0)
on pre-fork blocks, relying on distributeCombinedReward's internal fork
gate to fall through to legacy grantToAccount. Correct semantically but
misleading — a function named "chunk" has no business running for chains
that never see IIP-59.

Split the pre-fork legacy flow into a dedicated grantLegacyEpochReward
helper that never touches the cursor, pending pool, or compound bridge.
runVoterDistributionChunk is now post-fork only; drop its cursorEnabled
guard and the fork-off branch of the chunkSize comment.

Behavior unchanged on either fork side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* docs(iip-59): perf report update — 2.5s budget + chunked-drain e2e numbers (#4947)

Update the perf report to reflect what's actually shipping:

- Update block budget from 5s to 2.5s (Dardanelles interval on mainnet)
  and recompute extrapolation percentages accordingly. The pre-C2
  single-block SimExec baseline is now 285% over budget at 107k voters,
  even ReadContractStorage per-call is 126% over.
- Add ## Chunked drain (era-based v2) end-to-end section with
  TestIIP59EpochGrantPerf numbers for all three tiers (small / medium /
  mainnet). Mainnet: 6 continuation blocks, p95 28.0ms of drain
  machinery, total 155.6ms.
- Rewrite Verdict to reflect that both levers landed — direct-slot
  AutoDeposit reader (PR C0/C1, ~26x per-voter) and era-based chunked
  drain (PR C2, spreads work across ~6 blocks). Combined mainnet
  estimate ~73ms per continuation block against a 2500ms budget.
- Reframe the "spread drain across sub-epoch blocks" alternative from
  fallback to landed defense-in-depth. Historical alternatives (batch
  wrapper contract, ReadContractStorage) preserved but marked
  superseded.

E2E bench (`e2etest/iip59_perf_test.go`) stubs contract dispatch via
test-only injection seams, so drain-block numbers isolate drain
machinery cost from AutoDeposit reader cost. The two combine
additively; the doc calls this out explicitly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…hunk voter-only

Cleans up the C2.1 phase split so semantics match action names.

Before: GrantEpochReward (EpochReward action) only wrote the cursor;
        GrantVoterRewardChunk did commission + voter + fallback pay
        for every delegate in the chunk. The action named
        "voter reward chunk" was really "everything for these delegates".

After:  GrantEpochReward (Phase A) grants delegate commission to each
        reward address and handles fallback delegates (opt-out, no
        snapshot, unregistered) via legacy grantToAccount + pool drain.
        GrantVoterRewardChunk (Phase B) does only what its name says —
        voter share distribution across snap.Entries — and emits the
        batched DelegateDistributed log with TotalCommission as
        attestation.

Split accomplished by carving prepareDelegateAllocation out of the old
distributeCombinedReward as a pure function: takes (cand, addr, poolAmt,
epochAmt) and returns a *delegateAllocation with the deterministic
split (blockCommission/epochCommission/totalCommission/voterPool/
epochVoterPool) or nil for the fallback cases. Phase A and Phase B
both call it — the allocation is deterministic across the two blocks
because the frozen poll snapshot pins the inputs.

Unclaimed-balance debit accounting split accordingly:
  Phase A: += alloc.epochCommission
  Phase B: += alloc.epochVoterPool
  sum    == epochAmt (unchanged from pre-C2.1)

Block-stream (poolAmt) was already debited at GrantBlockReward time,
so neither phase re-debits it.

Verified:
  go build/vet ./action/protocol/rewarding/... — clean
  go test ./action/protocol/rewarding/... — pass
  go test ./action/protocol/staking/... ./action/protocol/poll/... — pass
  go test -run TestIIP59EpochGrantPerf -tags e2e ./e2etest/ (small) — pass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ochReward (#4948)

Cleans up IIP-59 C3 phase: pushes commission split down into
GrantBlockReward (so a delegate's block-side take is paid immediately
rather than sitting in the pending pool for a whole era) and collapses
the parallel legacy and post-fork epoch bodies into one GrantEpochReward.

GrantBlockReward now:
  - reads the frozen CandidatePollSnapshot for the block producer;
  - for opt-in delegates, splits blockReward by snap.BlockCommissionBasisPoints
    — commission is credited to rewardAddr immediately, voter share
    accumulates in the pending pool;
  - emits BLOCK_REWARD log with amount = commission (block-side commission
    becomes observable via per-block logs, no longer batched into
    DelegateDistributed).

GrantEpochReward becomes one function whose shape matches the user
description ("split by ratio, delegate portion to reward address, voter
portion to record for later"):
  1. pre-A checks + slashing (unchanged)
  2. per-delegate epoch split via splitDelegateEpochReward
     — returns (amount, 0) on fork-off, opt-out, missing snap,
     unregistered, or empty-voter, so pre-fork chains flow through the
     same body with no separate grantLegacyEpochReward
  3. foundation bonus (moved back here from Phase C coda)
  4. cursor persist iff any delegate has voter share (pool + epoch)
  5. sentinel updateRewardHistory (moved back here)
  6. updateAvailableBalance

VoterRewardChunk (Phase B) coda shrinks to: orphan sweep, available-balance
update, cursor delete. Pool decrement replaces pool delete so late-arriving
voter accruals (blocks that closed after the era boundary) survive to the
next era's cursor. DelegateDistributed's TotalCommission now reports
epoch commission only; consumers who want the era total sum the per-block
BLOCK_REWARD logs themselves.

Deleted:
  - delegateAllocation struct and prepareDelegateAllocation
  - distributeVoterReward one-line wrapper
  - runPhaseADelegateGrants (100 lines, absorbed into GrantEpochReward)
  - grantLegacyEpochReward (90 lines, no longer needed)

Cursor field renamed PoolAmountFrozen -> VoterAmountFrozen at the same
proto tag (wire compatible); semantics now match the state — the frozen
number is the voter share, not the whole pool.

Added decrementPendingBlockRewardPool helper — subtracts amount, deletes
the entry (and its index membership) when balance goes to zero. Preserves
late-arriving voter accruals across era boundaries.

Tests:
  - voter_reward_test.go rewritten around splitDelegateEpochReward
    (fork-off, opt-out, missing snap, unregistered, empty-voter, happy path)
  - chunked_drain_test.go updated: cursor entries freeze voter share
    sum of pool + epoch-side; zero-voter delegates skipped;
    foundation bonus + sentinel now asserted in GrantEpochReward
  - voter_reward_chunk_test.go: coda shrinks to orphan sweep + cursor delete
  - pending_block_reward_test.go: decrement partial + zero-clear tests

Perf harness (staking/perf_seeder.go, e2etest/iip59_perf_test.go):
  extended TestOnlySeedPerfBenchState to plant a CandidatePollSnapshot
  per delegate at genesis with Registered=true, VoterRewardOnchainOptIn=true,
  BPs=9000, and voter Entries built from the seeded buckets. The LifeLong
  poll protocol only calls setCandidates at genesis (fork gate off) and
  never emits PutPollResult, so the production PutPollResult-time freeze
  never runs in the perf harness. Without this snapshot every delegate
  would land in the Registered=false fallback and the drain would never
  begin.

Verification: build + vet clean; unit suites for rewarding and staking
pass; e2e small tier (3 delegates, 100 voters, era=2 epochs, batch=2)
drain begins at h=6 and spans 3 continuation blocks.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…R 5)

Adds a three-layer test-only regression harness for the IIP-59 chunked
voter-reward drain. Production code unchanged.

Layer 1 — unit-level determinism (action/protocol/rewarding/):
  * chunked_drain_determinism_test.go — same delegate+voter fixture
    drained under chunkSize=1/2/all produces byte-identical fund state
    and cursor lifecycle; replay-from-persisted-cursor equals
    uninterrupted drain; opt-out between chunks does not corrupt the
    frozen work list.
  * fund_invariant_test.go — asserts the conservation identity
        totalBalance == unclaimedBalance + Σ(perAddress) + Σ(pool)
    holds after Deposit, after pre-fork GrantEpochReward, and after
    post-fork Phase A with no cursor. Includes a deliberate-break test
    proving the helper reports the delta on divergence.
  * test_helpers.go — TestOnlyDumpRewardState / TestOnlyAllPoolEntries /
    TestOnlyAssertFundInvariant, sole consumers are the new tests.

Layer 2 — cross-era continuation (voter_reward_chunk_test.go):
  * TestGrantVoterRewardChunk_LateAccrualSurvivesToNextEra — a
    late-arriving pool credit during era-N drain survives into era N+1's
    Phase A cursor with the full frozen amount.

Layer 3 — e2e stress (e2etest/iip59_stress_test.go):
  * TestIIP59ChunkedDrainStress_SmallTier — after every committed
    block, the invariant helper is called across the full seeded address
    set (delegates + voters). Drain must span ≥ 2 continuation blocks so
    the chunking mechanism is actually exercised. This is the only
    e2e-level assertion that Phase B's grantToAccount side-effects
    balance the pool decrement, which the unit scaffold's mock view
    cannot verify.
  * TestIIP59ChunkedDrainStress_MultiEra — mints across three era
    boundaries, tracks cursor-present transitions, asserts three
    distinct drain lifecycles with no residual cursor between eras and
    the invariant holds throughout.
  * medium tier (20 delegates × 2000 voters) is env-gated via
    IIP59_STRESS_TIER for opt-in CI runs.

Verification: rewarding unit suite green, all three IIP-59 e2e tests
(perf + two stress) pass under 4s total.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces IIP-59's incremental per-(candidate, voter) weight aggregate.
The view lives inside viewData alongside candCenter and bucketPool, and
its deterministic 32-byte digest is persisted under a new
_voterWeights state-key tag in StakingNamespace.

Type layer (voter_weight_view.go):
  * VoterWeightView interface with three overlay implementations —
    voterWeightBase (terminal, sorted-by-voter map),
    voterWeightWrap (read-through overlay for viewData.Snapshot), and
    voterWeightFork (commit-in-clone overlay for viewData.Fork).
  * Apply(candID, voter, delta) — additive, negative-clamps-to-zero,
    unknown-voter withdraw is silent no-op.
  * VoterWeightsByCandidate returns entries pre-sorted by voter bytes
    lexicographically so downstream freeze paths get a stable order for
    free.
  * Hash() is a deterministic digest over (candID, voter, weight) triples
    sorted the same way — safe to compare across nodes to detect a
    missed hook.
  * voterWeightDigest is the on-chain persistence form: exactly 32 bytes;
    Deserialize rejects any other length so corruption fails loudly.
  * NewVoterWeightViewFromBuckets rebuilds the full in-memory state from
    an iterator over native + contract-staking buckets. Called at
    CreateBaseView; the rebuild-vs-persisted digest comparison detects
    divergence between the incremental hooks and ground truth.

Integration (viewdata.go, candidate_statereader.go,
candidate_statemanager.go, protocol.go):
  * viewData gains a voterWeights field routed through Fork/Snapshot/
    Revert/Commit mirroring candCenter/bucketPool.
  * CreateBaseView populates the view via NewVoterWeightViewFromBuckets
    over both native and all three contract-staking indexers, then
    reads the persisted digest and refuses to boot on mismatch (a
    boot-time error, not a silent fork).
  * DirtyView() carries voterWeights forward by pointer so applyVoter
    WeightDelta (added in the following commit) mutates the same
    object other handlers in the same workflow see. Stripping it to
    nil here was the exact bug flagged in the abandoned pr2.5 review.
  * protocol.go reserves _voterWeights as the next 1-byte namespace tag
    after _candidatePollSnapshot.

Tests (voter_weight_view_test.go): 14 cases covering Apply
add/aggregate/decrease/clamp/unknown-voter, cross-candidate isolation,
sort invariant, deep-copy safety in VoterWeightsByCandidate, Fork
isolation, Wrap merge, hash determinism, and incremental-matches-
rebuild parity.

Part 1/4 of IIP-59 PR 5.5a. Alone this commit only builds up the
plumbing; the freezer still writes empty Entries until commit 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires every bucket-mutating handler (native + contract-staking) into
the VoterWeightView so the freezer at PutPollResult has an authoritative
per-(cand, voter) weight aggregate to read from.

Helper (voter_weight_hooks.go):
  applyVoterWeightDelta(csm, candIdentifier, voter, delta) is the single
  funnel every hook site calls. Nil-safe on all four arguments: nil csm,
  nil candIdentifier, nil voter, nil delta, zero delta, and view-not-
  installed all no-op silently. This shape is deliberate — the fork gate
  is enforced by the *presence* of the view (installed in CreateBaseView
  post-fork), not by callers, so hooks compile in as unconditional call
  sites and are always safe to execute pre-fork.

Native handler hooks (11 sites across 7 files):
  * handlers.go — CreateStake, Unstake, ChangeCandidate (pair),
    TransferStake (pair), DepositToStake, Restake.
  * add_deposit_compound.go — AddDepositForCompound (introduced after
    the abandoned pr2.5 branch, so absent from the original hook list).
  * handler_candidate_selfstake.go — Activate self-stake, both prev-
    bucket transitions (self-stake↔normal) plus new-bucket seed.
  * handler_candidate_endorsement.go — clearCandidateSelfStake now
    takes csm so revoking an endorsement drops the self-stake bonus in
    the view.
  * handler_stake_migrate.go — native leg of stake migration removes
    the bucket's contribution.
  * candidate_statemanager.go — deactivate() records the (negative)
    net delta when a self-stake bucket loses its bonus.

Contract-staking hooks (single funnel, 3 event types):
  * nfteventhandler.go — PutBucket (+w), DeductBucket (−Δ),
    DeleteBucket (−w). Each fires once per NFT event, dispatched to
    all three V1/V2/V3 indexers by the shared handler.

Explicitly not hooked:
  * vote_reviser.go — historical fork revisions predate IIP-59.
  * handleWithdrawStake — the vote was already removed at Unstake.
  * Self-stake bucket weights — excluded from voter distribution per
    IIP-59 spec; the hook records only the (negative) delta when a
    self-stake bonus is added/removed, not the self-stake principal.

Part 2/4 of PR 5.5a. After this commit the view state matches ground
truth per block but nothing reads from it yet — the freezer still
writes empty Entries. That flip happens in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tView

FreezePollSnapshot at PutPollResult now walks each frozen candidate's
per-voter aggregate in the VoterWeightView and copies it into
snap.Entries. Downstream Phase B (splitDelegateEpochReward in the
rewarding protocol) reads that blob and distributes voter rewards.

Before this commit the freezer wrote empty Entries; downstream, the
"no voters known" degenerate branch routed 100% of every epoch's voter
allotment to delegate commission via fallback. This is the fix that
turns IIP-59 voter distribution on for the first time in production.

Per-item degradation for the fetch:
  * View not installed (typically pre-fork or in unit tests that skip
    the Protocol.Start bootstrap) — snap.Entries stays nil for every
    candidate. Legacy rewarding path takes over cleanly.
  * View installed but VoterWeightsByCandidate returns empty for a
    given candidate — snap.Entries stays nil for that candidate only.
    Other candidates still get their populated Entries.
  * Weights are deep-copied via new(big.Int).Set(w.weight) so a later
    handler that mutates the same *big.Int cannot retroactively rewrite
    a persisted snapshot.

Order comes from VoterWeightsByCandidate, which returns entries pre-
sorted by voter bytes lexicographically — matches the ordering that
downstream determinism tests already rely on.

Part 3/4 of PR 5.5a.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three new test files pin the correctness properties that make PR 5.5a
safe to activate in production.

voter_weight_hooks_test.go — 10 cases pinning the no-op semantics of
applyVoterWeightDelta. Every upstream call site is allowed to hand in
nil csm / nil cand / nil voter / nil delta / zero delta / view-not-
installed without a pre-check; each of those must be a silent no-op.
Regressions here silently drop hook applications and only surface much
later at the view-hash verification on restart. Also covers positive/
negative flow, over-withdraw clamps to zero, and unknown-voter
withdraw is a no-op (not a phantom zero entry).

poll_snapshot_entries_test.go — 6 cases covering the freezer:
  * NativeVoters — happy path with 3 voters on one candidate.
  * MultipleCandidatesIsolated — a voter's weight on cand A does not
    bleed into cand B's Entries.
  * CandidateWithNoVoters — a candidate whose view slot is empty gets
    nil Entries, other candidates still populate.
  * ViewMissingDegrades — no view installed = every Entries nil, no
    panic. Freeze itself succeeds; downstream falls to legacy path.
  * DeterministicOrder — Entries come out sorted by voter bytes
    lexicographically regardless of Apply insertion order. Guards the
    consensus invariant Phase B (splitDelegateEpochReward) depends on.
  * WeightCloneIsolation — mutating the view after freeze does not
    retroactively rewrite the persisted snapshot's weights.

poll_snapshot_test.go — drop two now-stale r.Empty(snap.Entries)
assertions. Those tests don't seed voter buckets so their Entries stay
nil, but the assertion is misleading now that population is a first-
class concern; the new Entries suite covers the positive path.

bench_freeze_snapshot_test.go — Design B (bucket enumeration) bench
gated by //go:build iip59bench. Kept in-tree as regression protection
for the design decision recorded in the plan: if a future refactor
tries to move back to enumeration-at-freeze, running the bench at the
30k-bucket ceiling will show the ~7s cost that made Design A
mandatory.

Part 4/4 of PR 5.5a. After this commit CandidatePollSnapshot.Entries
is populated from an authoritative source and voter distribution
finally runs end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the mid-delegate resume field that lets a per-block voter cap
stop payout inside a single delegate's frozen entry list. VoterIndex
is 0 whenever the entry at DelegateIndex is fresh; the follow-up
commit wires the reader that consults it.

Proto: uint32 voter_index = 4 on EpochDrainCursor (regen carried).
Struct: VoterIndex uint32 with round-trip coverage on Serialize /
Deserialize / WriteOverwrites.

Part of IIP-59 PR 5.5b.
Wires the fork-gated reader that runVoterDistributionChunk will consult
in the next commit. Returns 0 whenever NoVoterRewardDistribution is
still true (pre-fork, unbounded), else uint32(cfg.VoterBudgetPerBlock).

Also extends TestOnlyEpochDrainSnapshot to surface the new VoterIndex,
mirrored through TestOnlyDumpRewardState and the e2e drainSnapshot
helper. Determinism tests and the perf harness consume this now that
mid-delegate resume is a real state.

Part of IIP-59 PR 5.5b.
…-block

Pivots the era-boundary chunk semantics from delegate-count-only to a
dual-budget model. runVoterDistributionChunk now bounds each block by
two independent caps that either short-circuit the loop:

  - delegateBudget (CompoundBatchSize) — max delegates per block.
  - voterBudget (VoterBudgetPerBlock) — max voters paid per block,
    across all delegates touched this call.

When voterBudget stops payout mid-delegate, the cursor's VoterIndex
records the resume position and DelegateIndex stays put. The next
block picks up at [VoterIndex, VoterIndex+remainingBudget). Either
budget=0 means "disabled"; both zero = single-block full drain, i.e.
the pre-fork behaviour already returned by the fork gates.

distributeVoterOnly / allocateAndRouteVoters now accept
(startVoter, voterBudget). Allocation still runs across the full
snap.Entries every chunk so per-voter amounts stay byte-identical to
the un-split single-block run (last-with-weight voter absorbs the
dust once, whichever chunk reaches them). Payment loop iterates only
the [startVoter, startVoter+voterBudget) window. Return signature
grows to (logs, routed, paid, consumed, totalVoters, err) so the
caller can drive cursor advance without leaking snapshot types back
into reward.go.

decrementPendingBlockRewardPool is called per chunk with the window
sum, not the frozen total; the pending pool is partial-safe and
cumulative decrements across a delegate's chunks equal
VoterAmountFrozen.

Semantic shift called out in code: the DelegateDistributed log's
TotalVoterPool now reflects the sum of Amounts[] paid in *this*
chunk, not the delegate's era-wide frozen amount. Off-chain
consumers (hermes-patch, verifier) must aggregate partial logs by
(SnapshotHash, delegate, epoch) to recover era-wide totals.

Part of IIP-59 PR 5.5b.
Covers the new voter-count cap and cursor field:

  - TestVoterBudgetPerBlock — fork-gate parity with
    TestEpochDrainChunkSize. Pre-fork budget must be 0 regardless of
    genesis config; post-fork the configured value is passed through.

  - TestDistributeVoterOnly_WindowedDeterminism — 1 delegate × K
    voters partitioned into windows produces byte-identical per-voter
    amounts to a single-window run, and each partial log's
    TotalVoterPool equals the sum of its Amounts[].

  - Extend TestChunkedDrain_ReplayFromPersistedCursor and
    TestChunkedDrain_MidEraOptOut to assert VoterIndex stays 0 in the
    delegate-cap-only paths (no poll snapshots seeded ⇒ payout window
    never entered ⇒ VoterIndex must not advance).

Part of IIP-59 PR 5.5b.
envestcc and others added 3 commits August 6, 2026 17:55
…many

The cross-block backfill job carried a whole problem domain that existed only
because it was cross-block: a persisted job record with a hand-written codec, a
cursor, a per-block work budget, an id-space wraparound guard, and -- because
the index is knowingly partial while it runs -- a branch in rewarding that
declined era boundaries until it finished.

None of that bought anything. seedOwnerIndexBackfillJob already called
MaxBucketIDInState per contract, which is a full States() scan of that
contract's bucket namespace; States() decodes every value and the scan threw
all of them away through rawKeyOnly{} to read an id off the key. The activation
block was already enumerating every bucket of every contract. Taking the owners
from that same scan costs nothing extra, and it removes the old walk's actual
cost: it point-read every id from 0 to the maximum, and the mainnet id space is
sparse, so most of those reads found nothing.

So the backfill now runs in the single block at ToBeEnabledBlockHeight, from
CreatePreStates, using csr.Buckets() -- one sequential scan per contract, and
one owner-index write per distinct owner via the new batch AddOwnerRefs rather
than one read-modify-write per bucket.

No era copy-on-write window can be open when it runs, which is what lets the
rewarding decline branch be deleted rather than merely left unreachable. Two
independent reasons: beginEraCOWWindow is only reachable from the PutPollResult
action handler, and actions run after CreatePreStates; and the freeze returns
early while NoVoterRewardDistribution, which is the same fork gate. The same
gate governs OwnerIndexEnabled, so the index is provably empty at the
activation block -- there is no half-built state to reconcile.

The cost ceiling has a heavier precedent in the same function: Xingu's
contractsStake.Migrate writes every contract bucket into state in one block.
This reads the same buckets and writes one key per owner.

Removed with the job: OwnerIndexBackfillCursor, BackfillOwnerIndex,
BackfillContract, MaxBucketIDInState, rawKeyOnly, the _lsdBackfillJob state tag,
and OwnerIndexBackfillComplete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frozen-read rejection for a bucket that did not exist at the freeze
height has two independent mechanisms, and only one of them was tested.

The high-water mark rejects ids above it. That is the whole story for
native buckets, whose indices come from a monotone counter, which is why
putBucket deliberately skips the copy-on-write. It is only half the story
for contract buckets: the mark is the highest id ever minted, not a count,
and the id space below it is full of holes from burnt buckets. An id minted
into a hole after the boundary is <= the mark, so ContractBucketExisted
admits it and Resolve is reached. What stops it there is the Exists=false
tombstone that Snapshot writes when it finds prior == nil -- and why
UpsertBucket, unlike putBucket, snapshots on the create path.

TestFrozenContractBucketRejectsPostFreezeID only exercised an id above the
mark, so it could not distinguish the two. Add the gap case, asserting
first that the mark does admit the id, so the rejection cannot be coming
from the mark.

Verified by deleting the tombstone write: three tests turn red, and this is
the only one whose failing assertion is about a bucket value.
eracow.TestFrozenReadResolution covers Resolve one layer down, and
TestFrozenNativeBucketRejectsPostFreezeIndex fails on its voter-index
assertion while its bucket assertion still passes on the mark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FreezePollSnapshot took a poll list it had no use for. The list is
filtered twice before PutPollResult carries it (isActiveCandidate, then
the vote-score threshold) and it is frozen once per reward era, while
the set that actually receives epoch rewards is recomputed every epoch
inside that era. The two drift, and an opted-in candidate absent from
the frozen set loses its voters the whole era: every reader treats "no
snapshot" as "not on the rails", so the split falls back to 100%
delegate / 0% voter, silently, for up to a day.

A widening loop already covered that by unioning the list with every
opted-in candidate in the center. It was dead weight: routing reads the
same candidate namespace the center is built from, so opted-in list
members were re-derived, and opted-out list members produced only
OnchainRewardEnabled=false placeholders that voter_reward.go maps to
the identical outcome as their own absence. Dropping the parameter and
sourcing the set from the opt-in bit alone makes the guarantee
structural instead of additive, and removes a state write per candidate
per era that no consensus reader could see. Reads go down too: what
used to cost two routing reads for center-only members and one for list
members now costs one for everyone.

Body goes from 163 lines to 103, and identity parsing (including the
legacy Identity-empty-falls-back-to-Address branch) disappears with the
list.

Two behaviour changes:

ConstructBaseView failure and a nil candCenter are now returned rather
than degraded. The old code could absorb protocol.ErrNoName because the
list still supplied a set and the view supplied only the numbers in it.
The view supplies the set now, so degrading would freeze an EMPTY era --
not one degraded item but every delegate on the 100%-commission
fallback with no voter paid, identically and irrecoverably on every
validator that saw the fault. A block that does not produce is
recoverable; a frozen wrong era is not. Unreachable post-fork, since
staking's own Start installs the view and that is the protocol the poll
layer holds.

An opted-out candidate now returns ErrStateNotExist from ReadState
"VoterRewardSnapshot" / voterRewardDelegateSnapshot(address) instead of
a zeroed record. That RPC is the only reader that could tell a
placeholder from an absence.

e2etest passes in full, both payout goldens unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
envestcc and others added 6 commits August 7, 2026 08:50
EpochsPerRewardEra was documented as MUST be at least 2 (IIP-59 section 14)
and validated nowhere. Both bad values fail silently rather than loudly:

- 0 makes IsEraBoundary return false for every epoch, so a chain activates
  IIP-59, accrues voter rewards forever and never settles an era. Nothing in
  the logs says why.
- 1 leaves no room between a settlement and the freeze that supersedes its
  era window, because that freeze lands roughly one and a half epochs before
  the next era boundary.

Validation runs on the YAML load path only, and only once toBeEnabledHeight
is scheduled -- IIP-59 shares that gate, so an unscheduled chain never reads
the era length and must still start. Callers that build a Genesis literal
(tests, defaultConfig) are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eracow.Begin does not refuse to supersede an open copy-on-write window; it
queues the old one for collection and installs the new one. The next era's
freeze rides PutPollResult, which fires around the midpoint of the epoch
before the boundary epoch -- roughly 1.5 epochs before the boundary block
where Phase A would notice the overrun and call handlePhaseAEntryOverrun.

For that stretch EraCOWWindow answers at the new freeze height while every
work item in the outstanding cursor still carries the old one, and the two
travel together into staking.FrozenVoterWeight. The reads do not fail, they
answer for the wrong era: a bucket that grew since the old H pays at its
grown amount, and a bucket minted after the old H becomes payable at all,
because the high-water marks moved with the window. Silent wrong payments,
not a stall.

runVoterDistributionChunk now compares the live window's FreezeHeight
against its cursor's and settles a Failure receipt rather than paying
through a window it does not own. Settleable because both heights are
committed state every node reads identically; halting here would stop the
chain for 1.5 epochs instead of one settlement. Nothing is lost by
stopping -- the pending pools stay put and Phase A of the incoming era
rolls the residue forward.

The e2e stress fixture was sized so its last chunk landed exactly on the
next era boundary block, which is where the window is superseded. It passed
only because the overrun handler deleted the stale cursor and the test read
"cursor absent" as "drain complete"; the settlement seed made which side of
the edge it fell on vary run to run. Widen the era from 11 epochs to 16 so
the drain finishes with four chunks of slack. The per-block cap still bites.

Also corrects the comment in eracow.Begin, which claimed callers were
expected to prevent this, to point at the guard that actually handles it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
envestcc added a commit that referenced this pull request Aug 10, 2026
…hReward

IIP-59 (#4953) rewrote GrantEpochReward and, in doing so, moved the epoch
reward history sentinel ahead of the available-balance update. Both writes
target different keys with nothing reading between them, so the rewritten
function computes the same state -- but the delta state digest is
hash256b(SerializeQueue()), a hash over the write queue *in order*, so swapping
two entries changes the digest of the block.

GrantEpochReward runs at every epoch boundary regardless of fork height, and
this reordering sits outside the NoVoterRewardDistribution gate, so it applied
to historical blocks too. A node replaying mainnet history rejects the first
epoch boundary it reaches, and a live node upgrading would fork off at the
next one.

Found by the fullsync verification run: replaying mainnet from the 36m
checkpoint, rc_2.5.0 failed at height 36000360 (an epoch boundary) while
v2.4.4 passed. Dumping the write queue on both sides showed 154 identical
entries with exactly two of them transposed -- the "fnd" balance write and the
"erh" epoch sentinel -- and the block header's digest agreed with the
balance-first order.

Restores the original order and says why it is load-bearing, since nothing
about the function reads as order-sensitive on inspection.
…hReward

IIP-59 (#4953) rewrote GrantEpochReward and, in doing so, moved the epoch
reward history sentinel ahead of the available-balance update. Both writes
target different keys with nothing reading between them, so the rewritten
function computes the same state -- but the delta state digest is
hash256b(SerializeQueue()), a hash over the write queue *in order*, so swapping
two entries changes the digest of the block.

GrantEpochReward runs at every epoch boundary regardless of fork height, and
this reordering sits outside the NoVoterRewardDistribution gate, so it applied
to historical blocks too. A node replaying mainnet history rejects the first
epoch boundary it reaches, and a live node upgrading would fork off at the
next one.

Found by the fullsync verification run: replaying mainnet from the 36m
checkpoint, rc_2.5.0 failed at height 36000360 (an epoch boundary) while
v2.4.4 passed. Dumping the write queue on both sides showed 154 identical
entries with exactly two of them transposed -- the "fnd" balance write and the
"erh" epoch sentinel -- and the block header's digest agreed with the
balance-first order.

Restores the original order and says why it is load-bearing, since nothing
about the function reads as order-sensitive on inspection.
The startup indexer catch-up (blockIndexerChecker.CheckIndexer) replays every
block between the state indexer height and the block DAO tip, validating each
one. That loop is exactly what a segmented fullsync verification run needs, but
its target was hardcoded to 0 ("run to the DAO tip"), so a segment could only be
ended by killing the process from outside -- which is indistinguishable from the
node having stalled, and leaves the state DB closed uncleanly.

CheckIndexer already accepts a targetHeight (staking/protocol.go passes a real
one); this just plumbs a value through to blockdao's call and shuts the node
down once it returns.

Shutdown raises SIGTERM on ourselves rather than returning early from
StartServer: that reuses the normal signal path, so ctx is cancelled, the
deferred Stop() runs, and the process exits 0 with the state DB closed cleanly
-- which is what makes the resulting data dir reusable as a checkpoint. It is
done after Start() returns, not during: cancelling ctx mid-catch-up makes
CheckIndexer fail with "terminate the indexer checking: context canceled",
i.e. a failure, not a completion.

StopAtHeight defaults to 0, which preserves current behaviour exactly.

TestCheckIndexer never exercised the targetHeight argument (it always passed 0),
so the truncation itself was untested; TestCheckIndexerTargetHeight covers the
cap being below, equal to, above, and already past the DAO tip.
A "delta state digest doesn't match" error currently reports only the two
digests, which says a divergence happened but nothing about where. Since the
digest is hash256b(flusher.SerializeQueue()) -- a hash over the ordered
(writeType, namespace, key, value) queue -- the divergence is always a specific
entry in that queue, and dumping it makes the failure localisable: replay the
same block under two binaries, diff the dumps, and the first differing line
names the namespace and key that diverged.

Values are recorded as a hash rather than verbatim so a dump stays small on
blocks that touch a lot of contract storage.

Gated on the IOTEX_DIGEST_DUMP_DIR env var, so this is a no-op on a normal node
-- no config schema change and nothing to plumb through the builder for what is
a debugging facility. The erigon working set store does not keep a serialized
write queue (its Digest is a fixed zero hash) and returns an error.
@sonarqubecloud

Copy link
Copy Markdown

envestcc added a commit that referenced this pull request Aug 17, 2026
Brings rc_2.5.0's IIP-59 work up to the current state of
#4953 (head 1c9a36b),
squashing the increment since the previously integrated head 82d342d.

Main content is the PR's refactoring pass: recompute voter rewards from
frozen buckets, separate voter reward responsibilities, simplify the
drain pagination / settlement state plumbing, collapse the epoch drain
cursor, persist candidate reward opt-in, and cap voter reward chunks at
256.

Notes:

- The PR already forward-ported the rc-only work from #4968 (exported
  Unpack/ABI/Topic0 surface plus unpack_test.go) and #4969 (event
  rename), so the 16 conflicts were all "both sides changed the same
  hunk" and were resolved to the PR side. Verified afterwards that the
  distributedlog exported surface and unpack_test.go survive.

- Three commits already cherry-picked onto rc_2.5.0 (-stop-at-height,
  state write queue dump, fund-before-sentinel write order) merged
  without conflict, being byte-identical.

- The PR's refactor dropped the exported NewCandidateStateManager in
  favour of NewCandidateStateManagerWithContext. #4854's
  handlers_blspop_test.go still called the old constructor; updated its
  three call sites to match the surrounding idiom.

- go.mod moves from the v0.6.11 pseudo-version to the tagged
  iotex-proto v0.6.12, which contains blsPop.

- Carries master's #4972 (actpool keccak bundle hash) because the PR
  merged master in.

Feature gates from #4854 (EnforceBLSPoP) and #4869
(CorrectPrestateForAbsentKeys) verified intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
envestcc added a commit that referenced this pull request Aug 17, 2026
Added in d9319ab alongside the pinned bytecode fixtures, but not carried
over when the IIP-59 work was consolidated into #4953 -- the fixtures
landed without the script that documents where they came from.

Re-running it against mainnet reproduces both fixtures byte-identically.

Also records the deployed DelegateProfile and AutoDepositRegister
addresses on both networks, including the testnet AutoDepositRegister
deployed from this script's own fixture so its storage layout matches
mainnet's and the slot constants in autodeposit/slot_reader.go hold on
both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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