You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Second PR of the IIP-59 protocol-native voter reward distribution
series (spec: iotexproject/iips#73, previous:
PR 1 — commissionRate schema + SetCommissionRate action).
Adds the per-epoch freeze that PR 3's GrantEpochReward will consume:
at PutPollResult (mid-epoch snapshot of the next-epoch active
delegate set), we now capture (a) the delegate's currently-set
commission rate and (b) the per-voter aggregated vote weight for
every delegate that will be paid next epoch. No reward-distribution
logic yet — GrantEpochReward still runs the pre-IIP-59 path; the
snapshot just sits in state until PR 3 reads it. Behavior is
unchanged pre-flag.
Design change vs. the earlier draft on this branch
--------------------------------------------------
The prior version of this PR scanned all buckets at PutPollResult
time to build the per-voter aggregate. That was O(all buckets)
inside a hot state.db call and, worse, required reading from the
contract staking indexer — a db independent of state.db — inside a
consensus mutation path. Both problems are gone in this redesign:
- A live in-memory VoterWeightView is maintained incrementally by
the native handlers and by the contract-staking receipt-driven
event dispatch. PutPollResult reads directly from the view (top-N
candidates only, O(voters-per-cand)), sorts by voter address
bytes, and writes a per-candidate blob.
- The consensus path (Handle → contractsStake.Handle) never
touches the contract staking indexer db. The view is fed through
the same receipt-driven dispatch that today updates
contractStakeView, via a `VoterDeltaSink` plumbed through
context (no widening of ContractStakeView interface, no
breakage for existing mocks/impls).
- The view is memory-only. On protocol Start, `CreateBaseView`
rebuilds it once by scanning current native buckets and asking
each contract-staking indexer for its current per-voter
aggregates. The durable half of the story is the frozen
per-epoch snapshot blobs written under `_voterWeightSnap`, not
the live view.
Storage layout
--------------
- New 1-byte namespace tag `_voterWeightSnap = 5` in the staking
namespace (protocol.go), following the existing _const / _bucket
/ _voterIndex / _candIndex / _endorsement pattern.
- Per-candidate blob key = `_voterWeightSnap || candIdentifier.Bytes()`
→ 21 bytes, mirroring `_voterIndex` / `_candIndex` layout.
- Blob payload = `stakingpb.VoterWeightSnapshot { repeated
VoterWeightEntry (bytes voter, bytes weight) }`. Entries are
written pre-sorted by voter address bytes; the marshalled output
is byte-deterministic given identical logical state — a load-
bearing invariant for the byte-equality skip in the writer and
for cross-node consensus.
Live view
---------
- `action/protocol/staking/voter_weight_view.go`: VoterWeightView
interface + baseVoterWeightView (map[candID]map[voterID]weight)
+ wrapVoterWeightView (change overlay). Wrap layers a change map
on top of the receiver so Snapshot returns cheaply and Revert
discards the overlay. Fork deep-clones for parallel working
sets. Commit folds change into base and prunes zero-weight
entries.
- Deterministic ordering: `Weights(cand)` returns
`[]VoterWeight` sorted by voter address bytes. No map iteration
reaches the consensus-visible output.
- Nil / zero delta is a no-op; negative deltas model bucket exit
(unstake, withdraw, ChangeCandidate off-ramp, TransferStake off-
ramp).
Native handler hooks
--------------------
Every native handler that changes a voter bucket's contribution to
a candidate calls `csm.ApplyVoterWeightDelta(cand, voter, Δ)`:
- handleCreateStake: +weightedVote
- handleUnstake: -weightedVote (skipped for self-stake buckets)
- handleChangeCandidate: -w on prev, +w on new (skipped when the
bucket was prev's self-stake bucket — it was never in the view)
- handleTransferStake: -w on old voter, +w on new voter (skipped
when bucket is already unstaked — it exited the view earlier)
- handleDepositToStake / handleRestake: +Δw on the same
(cand, voter) pair (skipped for self-stake buckets)
- handleCandidateEndorsement: no-op on the voter view (self-stake
gate flips SelfStakeBucketIdx but does not add/remove voter
buckets)
- handleStakeMigrate: -native_w on (cand, oldOwner); the new
contract bucket enters the view via the contract-staking event
sink below
Self-stake buckets are never in the view. The exclusion rule is
uniform: `ContractAddress == "" && Index == cand.SelfStakeBucketIdx`
— fixes review issue #5 on the PoC PR (#4811) where any Index=0
contract bucket was silently treated as self-stake.
Contract staking sink
---------------------
- `staking/protocol.go` wraps `contractsStake.Handle` with
`WithVoterDeltaSink(ctx, viewData.voterWeights)`. All V1/V2/V3
event processors get the sink through context.
- `systemcontractindex/stakingindex/vote_view_handler.go` pulls
the sink out of context and emits deltas on PutBucket/DeleteBucket:
fresh puts emit +w; owner or candidate changes emit split (-old,
+new); deletes emit -w. Muted buckets emit no delta (matching
the "not in view" invariant).
- All sink calls funnel through `VoterWeightView.Apply` which is
the single source of truth for the aggregation.
Snapshot entry point (staking)
------------------------------
- `action/protocol/staking/voter_weight_snapshot.go`:
`Protocol.SnapshotForEpochReward(ctx, sm, cands)` is the public
API poll calls. For each candidate in the next-epoch active list:
1. copies `staking.Candidate.CommissionRate` into
`state.Candidate.CommissionRate`. poll then persists this via
its existing `NxtCandidateKey` PutState — no extra state key
needed, the `shiftCandidates` path already promotes next →
current at the epoch boundary.
2. reads `vd.voterWeights.Weights(candID)` (already sorted),
encodes with `encodeVoterWeightSnapshot`, and writes an
incremental per-candidate blob under `_voterWeightSnap` —
with a byte-equality skip against the existing stored blob
so steady-state (voter set unchanged epoch-over-epoch) does
zero writes.
- Guarded on `FeatureCtx.NoVoterRewardDistribution` — a no-op
pre-fork.
- Empty entries after aggregation → `DelState`, not "write empty
blob." `VoterWeightsFromSnapshot` returns (nil, nil) for the
ErrStateNotExist path, so PR 3 sees the same result either way,
but keeping state clean matches how the other candidate-scoped
namespaces behave when nothing is left to store.
- State read errors other than ErrStateNotExist propagate wrapped.
Fixes review issue #4 on the PoC — corruption / IO errors no
longer silently drop voter entries.
Reader for PR 3
---------------
- `VoterWeightsFromSnapshot(sr, candID) → ([]VoterWeight, error)`:
a single sm.State call, decoded from the persisted blob.
(nil, nil) for a candidate with no snapshot (either not yet
activated, or all voters left).
Tests
-----
- `voter_weight_view_test.go` (19 cases): base apply/aggregate/read,
sorting stability, wrap overlay semantics, Snapshot/Revert parity
against a Fork+Apply reference, Commit fold + zero-prune, nested
Wrap, per-candidate isolation, sink context plumbing.
- `vote_view_handler_test.go` (9 cases, systemcontractindex/
stakingindex): PutBucket fresh / same-cand-same-owner /
owner-change / candidate-change; DeleteBucket / missing-bucket
/ muted-bucket; nil-sink tolerance; sink integration that a
fresh-then-delete sequence balances to zero after Commit.
- `voter_weight_snapshot_test.go`: updated to construct a
populated view rather than seeding buckets, then assert
SnapshotForEpochReward writes the expected blob and
byte-equality skip triggers on unchanged inputs.
0 commit comments