feat(rewarding,staking): IIP-59 on-chain voter reward distribution - #4953
Open
envestcc wants to merge 98 commits into
Open
feat(rewarding,staking): IIP-59 on-chain voter reward distribution#4953envestcc wants to merge 98 commits into
envestcc wants to merge 98 commits into
Conversation
This was referenced Jul 20, 2026
…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.
…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>
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.
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
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 afollow-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:
operator;
from block state;
The design fits four constraints from the IIP-59 proposal
(
iip_proposals/iip-59.md): (1) rates come from an on-chainDelegateProfilecontract, (2) compound routing comes from anon-chain
AutoDepositcontract, (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
time via
GrantBlockReward; voter portion credited toPendingBlockRewardPool[candidateID].voter portion to the same per-delegate pool. Runs every epoch.
(
epochNum % EpochsPerRewardEra == 0),FreezePollSnapshotwrites per-voter weights into
CandidatePollSnapshot.Entries; acursor 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
runVoterDistributionChunkiterates delegates in a deterministic orderand is bounded by two independent genesis-configured caps:
CompoundBatchSize— max delegates processed per block. Preventsa chain with many small delegates from spending its whole block on
per-delegate overhead.
VoterBudgetPerBlock— max voters paid per block. Lets one largedelegate 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 resumablePersisted per era. Two fields:
DelegateIndex(next delegate) andVoterIndex(next voter withinDelegateIndex). Deterministicallocation over the full frozen
snap.Entriesis recomputed everychunk; 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 sourceReward 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
VoterWeightViewis maintained incrementally: hooked into 13mutation sites in the staking handlers, it applies deltas per block
in O(1) amortized.
FreezePollSnapshotreads a materializedsnap.Entriesslice from it in O(voters).The view is rebuilt from all buckets at
CreateBaseView; its digestis persisted and validated at boot so any hook drift fails loudly.
Contract bridges (read-only)
rewarding/delegateprofile/— reads(commissionRateBlock, commissionRateEpoch, optIn)atPutPollResult; frozen into the poll snapshot. Per-delegate readfailures 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 routecompound-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 EVMcalls in the reward handler.
Batched
DelegateDistributedlogOne log per delegate per chunk, encoding
Voters[],Amounts[],Routings[],TotalVoterPool,SnapshotHash.SnapshotHashcoversthe full frozen entries and is identical across a delegate's chunks.
Off-chain semantic to be aware of:
TotalVoterPoolequals the sumof
Amounts[]in this chunk, not the era-wide frozen amount. Anyoff-chain consumer that reassembles distributions must aggregate
partial logs by
(SnapshotHash, delegate, epoch). Hermes-patch andthe off-chain verifier are being updated separately for this.
Genesis knobs
All new; safe defaults for the pre-fork branch.
EpochsPerRewardEra24EpochDrainChunkSize50CompoundBatchSize: delegates/blockVoterBudgetPerBlock2000AutoDepositContractAddress""DelegateProfileContractAddress""Feature-flag semantics
Bound to
NoVoterRewardDistributioninprotocol.FeatureCtx:NoVoterRewardDistribution == true):PutPollResultdoes not freeze the snapshot;
runVoterDistributionChunkis notinvoked;
voterBudgetPerBlock(ctx)andepochDrainChunkSize(ctx)both return 0. Every code path added here is inert.
NoVoterRewardDistribution == false): the snapshotfreezes at every
PutPollResult; era boundaries kick off chunkeddrains; both caps read from genesis.
No new named fork; the existing
ToBeEnabledgate is reused becauseIIP-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, andSingleDelegateLargeVoterall pass)Coverage highlights:
chunkSize ∈ {1, 2, all}producesbyte-identical per-voter amounts and fund invariants.
starts land in the next era, not the current one.
DelegateIndex=k, VoterIndex>0, resumes on the next block, endsidentically to a single-block run.
handler sites keeps the view in sync with the bucket store;
digest-mismatch on boot is an error.
runtime bytecode and asserts direct-slot lookups match what the
contract itself wrote.
Reviewer sanity checks
VoterWeightViewcostjustification:
go test -tags iip59bench -bench BenchmarkFreezeSnapshot -benchtime=5x ./action/protocol/staking/.VoterWeightViewhook is intentionally removed.runVoterDistributionChunkfor the twobreakpaths(delegate-cap and voter-cap) and confirm the cursor persists in
both.
NoVoterRewardDistribution == trueshort-circuits everynew call site (this is the pre-fork guarantee).
Follow-ups (separate PRs)
address genesis fields, set
ToBeEnabledBlockHeight. Governanceprerequisites:
DelegateProfileownership handover (Add JSON test to action #43) andthe hermes-patch service filter for opted-in delegates (Add JSON test into action and fix a bug #44).
DelegateDistributedfrom voter snapshots; must aggregate partial chunks by
(SnapshotHash, delegate, epoch).🤖 Generated with Claude Code