feat(iip-59): protocol-native voter reward distribution#4811
feat(iip-59): protocol-native voter reward distribution#4811raullenchai wants to merge 10 commits into
Conversation
Proof of concept for IIP-59: automatic on-chain voter reward distribution that replaces the centralized Hermes service. Changes: - Add CommissionRate field to staking.Candidate (protobuf + Go struct) - Add ActiveBucketsByCandidate() helper on staking.Protocol - Add CandidateByIdentifier() helper on staking.Protocol - Add distributeVoterReward() in rewarding protocol - Modify GrantEpochReward() to auto-distribute when CommissionRate > 0 When a delegate sets CommissionRate > 0 (basis points, 0-10000): 1. Protocol calculates commission = totalReward * rate / 10000 2. Commission credited to delegate's reward account 3. Remaining voterPool distributed proportionally by weighted vote 4. Voter weights aggregated per-address (avoids per-bucket rounding loss) 5. Rounding dust goes to delegate 6. Voters claim via existing ClaimFromRewardingFund When CommissionRate == 0 (default): exact legacy behavior preserved. All existing tests pass. No consensus change until a delegate opts in. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add the SetCommissionRate action that allows delegates to opt-in to automatic voter reward distribution: - action/set_commission_rate.go: New action type with rate (0-10000 bps) - staking/voter_reward.go: handleSetCommissionRate handler - Only candidate owner can call - Updates CommissionRate on the Candidate record - Emits receipt log with candidate identifier - staking/protocol.go: Register handler in the action dispatch switch Usage flow: 1. Delegate calls SetCommissionRate(1000) → sets 10% commission 2. Next GrantEpochReward auto-distributes voter rewards 3. Voters claim via ClaimFromRewardingFund Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- TestCalculateVoterShares: verifies proportional distribution with 3 voters (different amounts, durations, auto-stake settings). Checks weights, proportional shares, dust <= voter count, and commission + distributed = total. - TestCommissionRateEdgeCases: verifies 0%, 10%, 25%, 100% commission math. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. [P1] ActiveBucketsByCandidate/CandidateByIdentifier now use NewCandidateStateManager(sm) instead of asserting ReadView to CandidateStateReader. The view stored is *viewData, so the old type assertion always failed, causing distributeVoterReward to silently fall back to legacy every time. 2. [P1] splitEpochReward now returns the filtered candidate list alongside addrs/amounts. GrantEpochReward uses filteredCandidates[i] (not the original candidates[i]) when calling distributeVoterReward, fixing the misalignment caused by exempt filtering and top-N truncation. 3. [P2] Vote weight calculation now uses stakingProtocol.VoteWeightCalConsts() instead of hardcoded genesis.Default. Added public accessor on the staking Protocol so rewarding can read the active chain config. 4. [P1] SetCommissionRate action: SanityCheck already existed but FillAction/Proto/LoadProto are not yet implemented (noted as TODO — the action dispatch works for the POC via the handle switch but cannot be submitted via RPC yet without the proto integration). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ct staking
P0-1: Hard fork activation gating
- Add YosemiteBlockHeight to genesis.go (default: MaxUint64 = disabled)
- Add IsYosemite() helper
- Add EnableVoterRewardDistribution feature flag to FeatureCtx
- distributeVoterReward() checks feature flag before executing
- handleSetCommissionRate() rejects before Yosemite activation
P0-2: Commission rate change cooldown
- Add CommissionRateLastEpoch field to Candidate (proto field 12)
- handleSetCommissionRate() enforces 168-epoch (~7 day) cooldown
- Prevents delegate from rapidly switching rates to game voters
P0-3: Contract staking bucket support
- ActiveBucketsByCandidate() now reads from all 3 contract staking
indexers (V1, V2, V3) in addition to native buckets
- Uses isUnstaked() for proper unstake detection (handles both
native timestamp-based and contract block-height-based checks)
- contractStakingIndexers() helper returns all configured indexers
P0-4: Cleaned up SetCommissionRate action
- MaxCommissionRate constant (10000 bps)
- CommissionRateCooldownEpochs constant (168 epochs)
- TODO documented for iotex-proto integration (field 54)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1 items done in this commit: 1. RewardLog types: Add VOTER_REWARD (5) and COMMISSION_REWARD (6) to rewardingpb.RewardLog_RewardType. distributeVoterReward now emits proper types so indexers/explorers can distinguish voter rewards from delegate epoch rewards. 2. Rounding precision test (2800 voters): Simulates real mainnet scale with realistic IOTX amounts (100-2M), durations (14-365 days), and 70% auto-stake rate. Result: dust = 1416 Rau (~10^-15 IOTX). Verifies exact conservation: commission + distributed + dust == total. 3. Performance benchmark: BenchmarkDistribute2800Voters = 310μs/op on M3 Pro. Pure vote weight calculation + proportional split for 2800 voters takes 0.3ms — well within the 5-second block time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
55d3732 to
53c962d
Compare
1. chainManager.Fork(): When BlockHeader(hash) fails, fall back to BlockHeaderByHeight(0) and verify the hash matches. This fixes the multi-node minicluster bootstrap failure where genesis block header was never stored in the hash→header index. Fixes #4812 2. TestProtocol_FetchBucketAndValidate: Use t.Fatal in the last subtest instead of parent test's require.NoError, preventing FailNow propagation from subtest to parent when gomonkey patches leak. Mitigates #4813 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
There was a problem hiding this comment.
Pull request overview
Implements IIP-59 by adding protocol-native voter reward distribution during GrantEpochReward(), gated behind a new Yosemite hard-fork height/feature flag, and introduces delegate commission rate state/action plumbing to support the distribution logic.
Changes:
- Add Yosemite fork height +
EnableVoterRewardDistributionfeature flag gating for the new distribution behavior. - Introduce commission rate fields in staking candidate state and a new
SetCommissionRateaction + handler with cooldown enforcement. - Add new rewarding logic to split epoch rewards into commission + per-voter shares, plus new reward log types and stress/perf test tooling.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/stresstest/main.go | New multi-node stress test tool to exercise block production and reward flows. |
| consensus/scheme/rolldpos/chainmanager.go | Fork genesis-hash fallback when header-by-hash lookup fails. |
| blockchain/genesis/genesis.go | Add Yosemite fork height and IsYosemite() helper. |
| action/protocol/context.go | Add EnableVoterRewardDistribution to feature context, enabled at Yosemite. |
| action/set_commission_rate.go | New action type for delegates to set commission rate (proto wiring TODO). |
| action/protocol/staking/voter_reward.go | New staking helpers for rewarding: active buckets lookup, candidate lookup, vote-weight constants, commission rate handler. |
| action/protocol/staking/protocol.go | Register SetCommissionRate in staking dispatch. |
| action/protocol/staking/candidate.go | Persist commission rate fields on candidates (clone/proto). |
| action/protocol/staking/stakingpb/staking.proto | Add commission fields to Candidate proto. |
| action/protocol/staking/stakingpb/staking.pb.go | Generated changes for new staking proto fields. |
| action/protocol/staking/handlers_test.go | Minor test assertion style changes. |
| action/protocol/rewarding/voter_reward.go | New core logic to distribute epoch reward to voters + commission with logs. |
| action/protocol/rewarding/reward.go | Hook distribution into GrantEpochReward() and fix split alignment. |
| action/protocol/rewarding/rewardingpb/rewarding.proto | Add VOTER_REWARD and COMMISSION_REWARD log types. |
| action/protocol/rewarding/rewardingpb/rewarding.pb.go | Generated changes for new rewarding proto enum values. |
| action/protocol/rewarding/voter_reward_test.go | Unit tests for share math, edge cases, rounding precision. |
| action/protocol/rewarding/voter_reward_fullscale_test.go | Full-scale dry run + benchmark tests for distribution math/perf. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Votes: new(big.Int).Set(d.Votes), | ||
| SelfStakeBucketIdx: d.SelfStakeBucketIdx, | ||
| SelfStake: new(big.Int).Set(d.SelfStake), | ||
| BLSPubKey: blsPubKey, | ||
| BLSPubKey: blsPubKey, | ||
| CommissionRate: d.CommissionRate, | ||
| CommissionRateLastEpoch: d.CommissionRateLastEpoch, | ||
| } |
There was a problem hiding this comment.
Candidate.Clone() now carries CommissionRate and CommissionRateLastEpoch, but Candidate.Equal() (used in tests and possibly state comparisons) still ignores these new fields. This can cause false positives in equality checks and hide regressions around commission rate changes. Update Equal() to include the commission fields (and any other new persisted fields).
|
|
||
| // IIP-59: Try auto-distribution to voters if candidate has CommissionRate > 0 | ||
| voterLogs, err := p.distributeVoterReward( | ||
| ctx, sm, filteredCandidates[i].Address, addrs[i], amounts[i], |
There was a problem hiding this comment.
In the IIP-59 path, distributeVoterReward expects a candidate identifier address (used by staking GetByIdentifier), but this call passes filteredCandidates[i].Address which is the operator address (state.Candidate.Address). This will usually fail the candidate lookup and silently fall back to legacy behavior, effectively disabling voter distribution. Pass filteredCandidates[i].Identity (and consider renaming the param to candidateIdentifier) so the staking candidate can be found reliably.
| ctx, sm, filteredCandidates[i].Address, addrs[i], amounts[i], | |
| ctx, sm, filteredCandidates[i].Identity, addrs[i], amounts[i], |
| // Returns the list of reward logs and nil error on success. | ||
| // If the delegate has CommissionRate == 0, this function returns (nil, nil, errNoAutoDistribution) | ||
| // and the caller should fall back to the legacy full-reward-to-delegate behavior. |
There was a problem hiding this comment.
The docstring mentions returning (nil, nil, errNoAutoDistribution), but the function signature is ([]*action.Log, error) and there is no errNoAutoDistribution in this implementation. This is misleading for callers/maintainers—please update the comment to match the actual return contract (e.g., nil, nil means "fall back to legacy").
| // Returns the list of reward logs and nil error on success. | |
| // If the delegate has CommissionRate == 0, this function returns (nil, nil, errNoAutoDistribution) | |
| // and the caller should fall back to the legacy full-reward-to-delegate behavior. | |
| // On success, it returns the list of reward logs and a nil error. | |
| // If voter reward distribution should not be applied (for example, the delegate has | |
| // CommissionRate == 0 or required protocols/features are unavailable), this function | |
| // returns (nil, nil) and the caller should fall back to the legacy full-reward-to-delegate | |
| // behavior. |
| } | ||
| contractBuckets, err := indexer.BucketsByCandidate(candidateIdentifier, height) | ||
| if err != nil { | ||
| continue // indexer may not be ready |
There was a problem hiding this comment.
Errors from contract staking indexers are swallowed (continue // indexer may not be ready). Because epoch reward execution must be deterministic across all nodes, making distribution depend on local indexer readiness can lead to consensus splits or silently skipping contract-staking voters. Prefer propagating the error (and letting the caller fall back to legacy full-delegate reward), or ensure the indexer read path is guaranteed available/deterministic at the executing height.
| continue // indexer may not be ready | |
| return nil, errors.Wrap(err, "failed to read contract staking buckets") |
| if voterPool.Sign() <= 0 { | ||
| // 100% commission, nothing for voters | ||
| data, _ := p.encodeRewardLog(rewardingpb.RewardLog_COMMISSION_REWARD, rewardAddr.String(), commission) | ||
| return []*action.Log{{ | ||
| Address: p.addr.String(), | ||
| Data: data, | ||
| BlockHeight: blkHeight, | ||
| ActionHash: actionHash, | ||
| }}, nil |
There was a problem hiding this comment.
encodeRewardLog errors are ignored in a couple of early-return branches (data, _ := ...). While marshal errors are rare, ignoring them can hide unexpected failures (and amount.String() will panic if amount is ever nil). Please handle and return the error consistently, like the rest of the rewarding code does.
| totalWeight := big.NewInt(0) | ||
|
|
||
| for _, b := range buckets { | ||
| isSelfStake := b.Index == cand.SelfStakeBucketIdx |
There was a problem hiding this comment.
isSelfStake := b.Index == cand.SelfStakeBucketIdx will incorrectly mark contract staking buckets as self-stake. Contract buckets created via convertToVoteBucket currently set Index to 0, and native self-stake bucket index can also be 0 (first bucket), causing the self-stake bonus to be applied to all contract buckets. Gate self-stake detection to native buckets only (e.g., require b.ContractAddress == ""), or use a more reliable self-stake identifier.
| isSelfStake := b.Index == cand.SelfStakeBucketIdx | |
| // Treat as self-stake only for native buckets (no contract address) | |
| isSelfStake := b.ContractAddress == "" && b.Index == cand.SelfStakeBucketIdx |
| for addrStr, weight := range voterMap { | ||
| share := new(big.Int).Mul(voterPool, weight) | ||
| share.Div(share, totalWeight) | ||
|
|
There was a problem hiding this comment.
The loop for addrStr, weight := range voterMap iterates over a Go map, which is intentionally randomized. Even though balance updates are commutative, the receipt log ordering (and the exact sequence of state writes) can become non-deterministic across nodes, which is unsafe for consensus. Collect the voter addresses into a slice, sort it (e.g., lexicographically), and iterate in that stable order.
| if !featureCtx.EnableVoterRewardDistribution { | ||
| return log, errors.New("SetCommissionRate not enabled before Yosemite hard fork") | ||
| } |
There was a problem hiding this comment.
The check for whether the feature is enabled should be moved to the protocol validation step. This way, the transaction will be ignored during minting rather than causing the mint to fail.
| if c.CommissionRateLastEpoch > 0 && currentEpoch < c.CommissionRateLastEpoch+action.CommissionRateCooldownEpochs { | ||
| return log, errors.Errorf( | ||
| "commission rate cooldown: last changed at epoch %d, current epoch %d, must wait until epoch %d", | ||
| c.CommissionRateLastEpoch, currentEpoch, c.CommissionRateLastEpoch+action.CommissionRateCooldownEpochs, | ||
| ) | ||
| } |
There was a problem hiding this comment.
When still within the cooldown period, it is better to return a handleError so that the transaction status is marked as failed, rather than causing the mint to fail.
| log.AddTopics(c.GetIdentifier().Bytes()) | ||
| log.AddAddress(actCtx.Caller) |
There was a problem hiding this comment.
The format of transaction events should be compatible with the contract events format. For reference on the contract interface format, see action/native_staking_contract_interface.sol.
|
|
||
| // ActiveBucketsByCandidate returns all non-unstaked buckets (native + contract staking) | ||
| // for a candidate. Used by the rewarding protocol (IIP-59) to distribute voter rewards. | ||
| func (p *Protocol) ActiveBucketsByCandidate(sm protocol.StateManager, candidateIdentifier address.Address) ([]*VoteBucket, error) { |
There was a problem hiding this comment.
Critical issue: Reading all buckets is an extremely time-consuming and resource-intensive operation, which may cause mint timeouts. It is recommended to dynamically maintain a view to store the votes of each voter.


Summary
IIP-59: Replace the centralized Hermes reward distribution service with protocol-native, automatic voter reward distribution. Zero gas, zero external dependencies, fully trustless.
Companion proto PR: iotexproject/iotex-proto#168
Problem: Hermes is a Centralized Bottleneck
Solution: Protocol-Native Distribution
Architecture
Production Safeguards
YosemiteBlockHeightin genesis +EnableVoterRewardDistributionfeature flagCommissionRate = 0(default) = exact legacy behavior, zero impactstakingProtocol.VoteWeightCalConsts(), not hardcoded genesissplitEpochReward()returns filtered list, no misalignment from exempt filteringVOTER_REWARD(5) andCOMMISSION_REWARD(6) for indexer/explorer differentiationFile Changes
genesis/genesis.goYosemiteBlockHeight+IsYosemite()protocol/context.goEnableVoterRewardDistributionfeature flagstaking/stakingpb/staking.protocommissionRate(11) +commissionRateLastEpoch(12)staking/stakingpb/staking.pb.gostaking/candidate.goCommissionRate+CommissionRateLastEpochin struct/Clone/Protostaking/voter_reward.go(new)ActiveBucketsByCandidate()(native+contract),CandidateByIdentifier(),VoteWeightCalConsts(),handleSetCommissionRate()with cooldownaction/set_commission_rate.go(new)SetCommissionRateaction typestaking/protocol.gorewarding/voter_reward.go(new)distributeVoterReward()core logic with feature gaterewarding/reward.goGrantEpochReward(), fixsplitEpochRewardreturnrewarding/rewardingpb/rewarding.protoVOTER_REWARD(5) +COMMISSION_REWARD(6) log typesrewarding/voter_reward_test.go(new)rewarding/voter_reward_fullscale_test.go(new)Test Results
Unit Tests — 19/19 PASS
E2E Tests (Mac Mini + Mac Studio) — All PASS
Scale-Up Performance (Mac Studio M3 Ultra, 28-core, 256GB)
Key findings:
Benchmark (5-second sustained)
Remaining TODO
Blocked on iotex-proto PR #168
FillAction()/Proto()/LoadProto()for SetCommissionRate (field 54)envelope.gotoIoTeXTypes()expose CommissionRate in CandidateV2 RPC responsesioctl stake2 setcommission <bps>CLI commandPost-merge iteration
🤖 Generated with Claude Code