Skip to content

Commit 4e15806

Browse files
committed
feat(staking,poll): incremental voter weight view + snapshot at PutPollResult (IIP-59 PR 2/6)
Second PR of the IIP-59 protocol-native voter reward distribution series (spec: iotexproject/iips#73, previous: PR 1 — commissionRate schema + SetCommissionRate action). Adds the per-epoch freeze that PR 3's GrantEpochReward will consume: at PutPollResult (mid-epoch snapshot of the next-epoch active delegate set), we now capture (a) the delegate's currently-set commission rate and (b) the per-voter aggregated vote weight for every delegate that will be paid next epoch. No reward-distribution logic yet — GrantEpochReward still runs the pre-IIP-59 path; the snapshot just sits in state until PR 3 reads it. Behavior is unchanged pre-flag. Design change vs. the earlier draft on this branch -------------------------------------------------- The prior version of this PR scanned all buckets at PutPollResult time to build the per-voter aggregate. That was O(all buckets) inside a hot state.db call and, worse, required reading from the contract staking indexer — a db independent of state.db — inside a consensus mutation path. Both problems are gone in this redesign: - A live in-memory VoterWeightView is maintained incrementally by the native handlers and by the contract-staking receipt-driven event dispatch. PutPollResult reads directly from the view (top-N candidates only, O(voters-per-cand)), sorts by voter address bytes, and writes a per-candidate blob. - The consensus path (Handle → contractsStake.Handle) never touches the contract staking indexer db. The view is fed through the same receipt-driven dispatch that today updates contractStakeView, via a `VoterDeltaSink` plumbed through context (no widening of ContractStakeView interface, no breakage for existing mocks/impls). - The view is memory-only. On protocol Start, `CreateBaseView` rebuilds it once by scanning current native buckets and asking each contract-staking indexer for its current per-voter aggregates. The durable half of the story is the frozen per-epoch snapshot blobs written under `_voterWeightSnap`, not the live view. Storage layout -------------- - New 1-byte namespace tag `_voterWeightSnap = 5` in the staking namespace (protocol.go), following the existing _const / _bucket / _voterIndex / _candIndex / _endorsement pattern. - Per-candidate blob key = `_voterWeightSnap || candIdentifier.Bytes()` → 21 bytes, mirroring `_voterIndex` / `_candIndex` layout. - Blob payload = `stakingpb.VoterWeightSnapshot { repeated VoterWeightEntry (bytes voter, bytes weight) }`. Entries are written pre-sorted by voter address bytes; the marshalled output is byte-deterministic given identical logical state — a load- bearing invariant for the byte-equality skip in the writer and for cross-node consensus. Live view --------- - `action/protocol/staking/voter_weight_view.go`: VoterWeightView interface + baseVoterWeightView (map[candID]map[voterID]weight) + wrapVoterWeightView (change overlay). Wrap layers a change map on top of the receiver so Snapshot returns cheaply and Revert discards the overlay. Fork deep-clones for parallel working sets. Commit folds change into base and prunes zero-weight entries. - Deterministic ordering: `Weights(cand)` returns `[]VoterWeight` sorted by voter address bytes. No map iteration reaches the consensus-visible output. - Nil / zero delta is a no-op; negative deltas model bucket exit (unstake, withdraw, ChangeCandidate off-ramp, TransferStake off- ramp). Native handler hooks -------------------- Every native handler that changes a voter bucket's contribution to a candidate calls `csm.ApplyVoterWeightDelta(cand, voter, Δ)`: - handleCreateStake: +weightedVote - handleUnstake: -weightedVote (skipped for self-stake buckets) - handleChangeCandidate: -w on prev, +w on new (skipped when the bucket was prev's self-stake bucket — it was never in the view) - handleTransferStake: -w on old voter, +w on new voter (skipped when bucket is already unstaked — it exited the view earlier) - handleDepositToStake / handleRestake: +Δw on the same (cand, voter) pair (skipped for self-stake buckets) - handleCandidateEndorsement: no-op on the voter view (self-stake gate flips SelfStakeBucketIdx but does not add/remove voter buckets) - handleStakeMigrate: -native_w on (cand, oldOwner); the new contract bucket enters the view via the contract-staking event sink below Self-stake buckets are never in the view. The exclusion rule is uniform: `ContractAddress == "" && Index == cand.SelfStakeBucketIdx` — fixes review issue #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.
1 parent 45a343a commit 4e15806

17 files changed

Lines changed: 2240 additions & 19 deletions

action/protocol/poll/util.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/iotexproject/iotex-core/v2/action/protocol"
2222
accountutil "github.com/iotexproject/iotex-core/v2/action/protocol/account/util"
2323
"github.com/iotexproject/iotex-core/v2/action/protocol/rolldpos"
24+
"github.com/iotexproject/iotex-core/v2/action/protocol/staking"
2425
"github.com/iotexproject/iotex-core/v2/action/protocol/vote"
2526
"github.com/iotexproject/iotex-core/v2/action/protocol/vote/candidatesutil"
2627
"github.com/iotexproject/iotex-core/v2/pkg/log"
@@ -212,6 +213,16 @@ func setCandidates(
212213
return errors.Wrapf(err, "failed to put candidatelist into indexer at height %d", height)
213214
}
214215
}
216+
// IIP-59: freeze per-epoch commission rate + per-voter weights for the
217+
// next epoch's active delegates. No-op when the feature is disabled.
218+
// Must run before PutState below — the commission-rate copy mutates
219+
// candidates in place, and the persisted state.Candidate needs the
220+
// frozen value.
221+
if stakingProto := staking.FindProtocol(protocol.MustGetRegistry(ctx)); stakingProto != nil {
222+
if err := stakingProto.SnapshotForEpochReward(ctx, sm, candidates); err != nil {
223+
return errors.Wrap(err, "failed to snapshot voter reward state for next epoch")
224+
}
225+
}
215226
if loadCandidatesLegacy {
216227
key, org := candidatesutil.ConstructLegacyKeyWithOrg(height)
217228
_, err := sm.PutState(&candidates, protocol.LegacyKeyOption(key), protocol.ErigonStoreKeyOption(org))

action/protocol/staking/candidate_statemanager.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ type (
5757
Upsert(*Candidate) error
5858
CreditBucketPool(*big.Int, bool) error
5959
DebitBucketPool(*big.Int, bool) error
60+
// ApplyVoterWeightDelta pushes a (cand, voter, Δweight) delta into
61+
// the incremental voter-weight view. No-op when IIP-59 voter reward
62+
// distribution is inactive (pre-fork or view not yet initialized).
63+
// Handlers call this at each SubVote / AddVote site.
64+
ApplyVoterWeightDelta(cand, voter address.Address, delta *big.Int)
6065
Commit(context.Context) error
6166
SM() protocol.StateManager
6267
SR() protocol.StateReader
@@ -124,6 +129,7 @@ func (csm *candSM) DirtyView() *viewData {
124129
candCenter: csm.candCenter,
125130
bucketPool: csm.bucketPool,
126131
contractsStake: vd.contractsStake,
132+
voterWeights: vd.voterWeights,
127133
}
128134
}
129135

@@ -176,6 +182,23 @@ func (csm *candSM) CreditBucketPool(amount *big.Int, deleteBucket bool) error {
176182
return csm.bucketPool.CreditPool(csm.StateManager, amount, deleteBucket)
177183
}
178184

185+
// ApplyVoterWeightDelta forwards to the live voterWeights view stored on the
186+
// protocol viewData. It reads the view fresh each call — Snapshot()/Revert()
187+
// swap the view instance in place, so a cached pointer would go stale after
188+
// a mid-action snapshot. When the view is nil (pre-fork or no IIP-59
189+
// initialization), the call is a no-op so handlers can invoke it
190+
// unconditionally without extra feature-flag gating.
191+
func (csm *candSM) ApplyVoterWeightDelta(cand, voter address.Address, delta *big.Int) {
192+
if delta == nil || delta.Sign() == 0 {
193+
return
194+
}
195+
view := csm.DirtyView()
196+
if view == nil || view.voterWeights == nil {
197+
return
198+
}
199+
view.voterWeights.Apply(cand, voter, delta)
200+
}
201+
179202
func (csm *candSM) DebitBucketPool(amount *big.Int, newBucket bool) error {
180203
return csm.bucketPool.DebitPool(csm, amount, newBucket)
181204
}

action/protocol/staking/candidate_statereader.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ func ConstructBaseView(sr protocol.StateReader) (CandidateStateReader, error) {
141141
candCenter: view.candCenter,
142142
bucketPool: view.bucketPool,
143143
contractsStake: view.contractsStake,
144+
voterWeights: view.voterWeights,
144145
},
145146
}, nil
146147
}

action/protocol/staking/handler_candidate_endorsement.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ func (p *Protocol) handleCandidateEndorsement(ctx context.Context, act *action.C
8484
if err := csm.deactivate(cand, bucket, protocol.MustGetBlockCtx(ctx).BlockHeight, p.calculateVoteWeight); err != nil {
8585
return log, nil, csmErrorToHandleError(cand.GetIdentifier().String(), err)
8686
}
87+
csm.ApplyVoterWeightDelta(cand.GetIdentifier(), bucket.Owner, p.calculateVoteWeight(bucket, false))
8788
} else {
8889
// TODO: Check that the bucket is ready for dequeue
8990
if err := p.clearCandidateSelfStake(bucket, cand); err != nil {
@@ -92,6 +93,7 @@ func (p *Protocol) handleCandidateEndorsement(ctx context.Context, act *action.C
9293
if err := csm.Upsert(cand); err != nil {
9394
return log, nil, csmErrorToHandleError(actCtx.Caller.String(), err)
9495
}
96+
csm.ApplyVoterWeightDelta(cand.GetIdentifier(), bucket.Owner, p.calculateVoteWeight(bucket, false))
9597
}
9698
}
9799
if err := esm.Delete(bucket.Index); err != nil {

action/protocol/staking/handler_stake_migrate.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ func (p *Protocol) withdrawBucket(ctx context.Context, withdrawer *state.Account
129129
return nil, nil, errors.Wrapf(err, "failed to update staking bucket pool %s", err.Error())
130130
}
131131
// update candidate vote
132+
wasSelfStake := cand.SelfStakeBucketIdx == bucket.Index
132133
weightedVote := p.calculateVoteWeight(bucket, false)
133134
if err := cand.SubVote(weightedVote); err != nil {
134135
return nil, nil, &handleError{
@@ -137,13 +138,16 @@ func (p *Protocol) withdrawBucket(ctx context.Context, withdrawer *state.Account
137138
}
138139
}
139140
// clear candidate's self stake if the
140-
if cand.SelfStakeBucketIdx == bucket.Index {
141+
if wasSelfStake {
141142
cand.SelfStake = big.NewInt(0)
142143
cand.SelfStakeBucketIdx = candidateNoSelfStakeBucketIndex
143144
}
144145
if err := csm.Upsert(cand); err != nil {
145146
return nil, nil, csmErrorToHandleError(cand.GetIdentifier().String(), err)
146147
}
148+
if !wasSelfStake {
149+
csm.ApplyVoterWeightDelta(cand.GetIdentifier(), bucket.Owner, new(big.Int).Neg(weightedVote))
150+
}
147151
// update withdrawer balance
148152
if err := withdrawer.AddBalance(bucket.StakedAmount); err != nil {
149153
return nil, nil, errors.Wrapf(err, "failed to add balance %s", bucket.StakedAmount)

action/protocol/staking/handlers.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ func (p *Protocol) handleCreateStake(ctx context.Context, act *action.CreateStak
9191
failureStatus: iotextypes.ReceiptStatus_ErrInvalidBucketAmount,
9292
}
9393
}
94+
// IIP-59: new voter bucket, credit the voter's weight to this candidate.
95+
csm.ApplyVoterWeightDelta(candidate.GetIdentifier(), bucket.Owner, weightedVote)
9496
if err := csm.Upsert(candidate); err != nil {
9597
return log, nil, csmErrorToHandleError(candidate.GetIdentifier().String(), err)
9698
}
@@ -213,6 +215,11 @@ func (p *Protocol) handleUnstake(ctx context.Context, act *action.Unstake, csm C
213215
failureStatus: iotextypes.ReceiptStatus_ErrNotEnoughBalance,
214216
}
215217
}
218+
// IIP-59: voter buckets exit the view on unstake. Self-stake buckets are
219+
// never in the view — skip the delta for those.
220+
if !selfStake {
221+
csm.ApplyVoterWeightDelta(candidate.GetIdentifier(), bucket.Owner, new(big.Int).Neg(weightedVote))
222+
}
216223
// clear candidate's self stake if the bucket is self staking
217224
if selfStake {
218225
candidate.SelfStake = big.NewInt(0)
@@ -369,15 +376,22 @@ func (p *Protocol) handleChangeCandidate(ctx context.Context, act *action.Change
369376

370377
// update previous candidate
371378
weightedVotes := p.calculateVoteWeight(bucket, false)
379+
// IIP-59: capture pre-write self-stake state so we can correctly gate the
380+
// voter-weight delta on prev. If this bucket was prev's self-stake bucket
381+
// it was NOT in the view (self-stake exclusion), so we must skip the -w.
382+
wasSelfStakeOnPrev := !featureCtx.DisableDelegateEndorsement && prevCandidate.SelfStakeBucketIdx == bucket.Index
372383
if err := prevCandidate.SubVote(weightedVotes); err != nil {
373384
return log, &handleError{
374385
err: errors.Wrapf(err, "failed to subtract vote for previous candidate %s", prevCandidate.GetIdentifier().String()),
375386
failureStatus: iotextypes.ReceiptStatus_ErrNotEnoughBalance,
376387
}
377388
}
389+
if !wasSelfStakeOnPrev {
390+
csm.ApplyVoterWeightDelta(prevCandidate.GetIdentifier(), bucket.Owner, new(big.Int).Neg(weightedVotes))
391+
}
378392
// if the bucket equals to the previous candidate's self-stake bucket, it must be expired endorse bucket
379393
// so we need to clear the self-stake of the previous candidate
380-
if !featureCtx.DisableDelegateEndorsement && prevCandidate.SelfStakeBucketIdx == bucket.Index {
394+
if wasSelfStakeOnPrev {
381395
prevCandidate.SelfStake.SetInt64(0)
382396
prevCandidate.SelfStakeBucketIdx = candidateNoSelfStakeBucketIndex
383397
}
@@ -392,6 +406,9 @@ func (p *Protocol) handleChangeCandidate(ctx context.Context, act *action.Change
392406
failureStatus: iotextypes.ReceiptStatus_ErrInvalidBucketAmount,
393407
}
394408
}
409+
// IIP-59: bucket now votes for the new candidate — always a voter contribution,
410+
// never self-stake (ChangeCandidate does not create a self-stake link).
411+
csm.ApplyVoterWeightDelta(candidate.GetIdentifier(), bucket.Owner, weightedVotes)
395412
if err := csm.Upsert(candidate); err != nil {
396413
return log, csmErrorToHandleError(candidate.GetIdentifier().String(), err)
397414
}
@@ -437,6 +454,15 @@ func (p *Protocol) handleTransferStake(ctx context.Context, act *action.Transfer
437454
}
438455
}
439456

457+
// IIP-59: TransferStake reassigns the bucket's voter without touching the
458+
// candidate's total. If the bucket was already unstaked it was not in the
459+
// view; skip the delta in that case.
460+
var voterDelta *big.Int
461+
prevOwner := bucket.Owner
462+
if !bucket.isUnstaked() {
463+
voterDelta = p.calculateVoteWeight(bucket, false)
464+
}
465+
440466
// update bucket index
441467
if err := csm.delVoterBucketIndex(bucket.Owner, act.BucketIndex()); err != nil {
442468
return log, errors.Wrapf(err, "failed to delete voter bucket index for voter %s", bucket.Owner.String())
@@ -451,6 +477,11 @@ func (p *Protocol) handleTransferStake(ctx context.Context, act *action.Transfer
451477
return log, errors.Wrapf(err, "failed to update bucket for voter %s", bucket.Owner.String())
452478
}
453479

480+
if voterDelta != nil && voterDelta.Sign() > 0 {
481+
csm.ApplyVoterWeightDelta(bucket.Candidate, prevOwner, new(big.Int).Neg(voterDelta))
482+
csm.ApplyVoterWeightDelta(bucket.Candidate, newOwner, voterDelta)
483+
}
484+
454485
log.AddAddress(actionCtx.Caller)
455486
return log, nil
456487
}
@@ -549,6 +580,11 @@ func (p *Protocol) handleDepositToStake(ctx context.Context, act *action.Deposit
549580
failureStatus: iotextypes.ReceiptStatus_ErrInvalidBucketAmount,
550581
}
551582
}
583+
// IIP-59: voter contribution only. Self-stake buckets never enter the view.
584+
if !selfStake {
585+
delta := new(big.Int).Sub(weightedVotes, prevWeightedVotes)
586+
csm.ApplyVoterWeightDelta(candidate.GetIdentifier(), bucket.Owner, delta)
587+
}
552588
if selfStake {
553589
if err := candidate.AddSelfStake(act.Amount()); err != nil {
554590
return log, nil, &handleError{
@@ -668,6 +704,11 @@ func (p *Protocol) handleRestake(ctx context.Context, act *action.Restake, csm C
668704
failureStatus: iotextypes.ReceiptStatus_ErrInvalidBucketAmount,
669705
}
670706
}
707+
// IIP-59: voter contribution only. Self-stake buckets never enter the view.
708+
if !selfStake {
709+
delta := new(big.Int).Sub(weightedVotes, prevWeightedVotes)
710+
csm.ApplyVoterWeightDelta(candidate.GetIdentifier(), bucket.Owner, delta)
711+
}
671712
if err := csm.Upsert(candidate); err != nil {
672713
return log, csmErrorToHandleError(candidate.GetIdentifier().String(), err)
673714
}

action/protocol/staking/protocol.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ const (
6161
_voterIndex
6262
_candIndex
6363
_endorsement
64+
// _voterWeightSnap holds IIP-59 per-candidate frozen voter weight blobs,
65+
// written at PutPollResult and read by rewarding at GrantEpochReward.
66+
_voterWeightSnap
6467
)
6568

6669
// Errors
@@ -374,9 +377,49 @@ func (p *Protocol) Start(ctx context.Context, sr protocol.StateReader) (protocol
374377
return nil, err
375378
}
376379
}
380+
381+
// IIP-59: build the initial voter weight base once, from state.db native
382+
// buckets + contract staking indexers verified above. Skip the scan when
383+
// the fork isn't active — the view stays nil and Apply calls are no-ops.
384+
if fCtx := protocol.MustGetFeatureCtx(ctx); !fCtx.NoVoterRewardDistribution {
385+
csr := newCandidateStateReader(sr)
386+
vw, err := buildVoterWeightBaseFromState(
387+
csr,
388+
c.candCenter.All(),
389+
p.activeContractStakingIndexers(height),
390+
height,
391+
p.config.VoteWeightCalConsts,
392+
)
393+
if err != nil {
394+
return nil, errors.Wrap(err, "failed to build voter weight base view")
395+
}
396+
c.voterWeights = vw
397+
}
377398
return c, nil
378399
}
379400

401+
// activeContractStakingIndexers returns the registered indexers whose
402+
// StartHeight has already been reached at the given height. Nil entries are
403+
// omitted. Used at protocol Start for the initial voter-weight base scan.
404+
func (p *Protocol) activeContractStakingIndexers(height uint64) []ContractStakingIndexer {
405+
var out []ContractStakingIndexer
406+
candidates := []ContractStakingIndexer{
407+
p.contractStakingIndexer,
408+
p.contractStakingIndexerV2,
409+
p.contractStakingIndexerV3,
410+
}
411+
for _, idx := range candidates {
412+
if idx == nil {
413+
continue
414+
}
415+
if idx.StartHeight() > height {
416+
continue
417+
}
418+
out = append(out, idx)
419+
}
420+
return out
421+
}
422+
380423
// CreateGenesisStates is used to setup BootstrapCandidates from genesis config.
381424
func (p *Protocol) CreateGenesisStates(
382425
ctx context.Context,
@@ -837,7 +880,12 @@ func (p *Protocol) HandleReceipt(ctx context.Context, elp action.Envelope, sm pr
837880
if err != nil {
838881
return err
839882
}
840-
if err := v.(*viewData).contractsStake.Handle(ctx, receipt); err != nil {
883+
vd := v.(*viewData)
884+
handleCtx := ctx
885+
if vd.voterWeights != nil {
886+
handleCtx = WithVoterDeltaSink(ctx, vd.voterWeights)
887+
}
888+
if err := vd.contractsStake.Handle(handleCtx, receipt); err != nil {
841889
return err
842890
}
843891
handler = newNFTBucketEventHandlerErigonOnly(sm, ccvw)

0 commit comments

Comments
 (0)