Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions action/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,12 @@ func (elp *envelope) loadProtoActionPayload(pbAct *iotextypes.ActionCore) error
return err
}
elp.payload = act
case pbAct.GetSetCommissionRate() != nil:
act := &SetCommissionRate{}
if err := act.LoadProto(pbAct.GetSetCommissionRate()); err != nil {
return err
}
elp.payload = act
default:
return errors.Errorf("no applicable action to handle proto type %T", pbAct.Action)
}
Expand Down
32 changes: 32 additions & 0 deletions action/native_staking_contract_abi.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@
"name": "CandidateDeactivated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "candidate",
"type": "address"
},
{
"indexed": false,
"internalType": "uint64",
"name": "newRate",
"type": "uint64"
}
],
"name": "CommissionRateSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
Expand Down Expand Up @@ -212,6 +231,19 @@
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint64",
"name": "rate",
"type": "uint64"
}
],
"name": "setCommissionRate",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
Expand Down
12 changes: 12 additions & 0 deletions action/native_staking_contract_interface.sol
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ interface INativeStakingContract {
address rewardAddress,
bytes blsPubKey
);
// IIP-59: emitted when a delegate updates its voter reward commission
// rate via setCommissionRate. The new rate takes effect at the next
// epoch boundary (poll PutPollResult snapshot).
event CommissionRateSet(
address indexed candidate,
uint64 newRate
);

function candidateRegister(
string memory name,
Expand Down Expand Up @@ -82,6 +89,11 @@ interface INativeStakingContract {

function revokeEndorsement(uint64 bucketIndex) external;

// IIP-59: set the voter reward commission rate (basis points, 0-10000).
// Only the candidate owner can call. Takes effect at the next epoch
// boundary via the existing poll snapshot machinery.
function setCommissionRate(uint64 rate) external;

// Candidate Transfer Ownership
function candidateTransferOwnership(
address newOwner,
Expand Down
15 changes: 15 additions & 0 deletions action/protocol/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,20 @@ type (
// contracts are committed and written back
AlwaysWriteCachedContract bool
NoCandidateExitQueue bool
// NoVoterRewardDistribution gates IIP-59: protocol-native voter reward
// distribution. When false (the post-fork / activated state, which is also
// the bool zero value), GrantEpochReward auto-splits each delegate's
// epoch reward between the delegate's commission and a proportional
// distribution to voters, and SetCommissionRate actions are accepted.
// When true (the pre-fork legacy state), the protocol keeps today's
// behavior of granting the full epoch reward to the delegate.
//
// Naming convention: feature flags should be named such that the bool
// zero value (false) corresponds to the post-fork activated behavior.
// This matches NoCandidateExitQueue / NotSlashUnproductiveDelegates and
// makes a missing / partially-initialized FeatureCtx default to the
// long-term mainnet behavior rather than the temporary pre-fork state.
NoVoterRewardDistribution bool
}

// FeatureWithHeightCtx provides feature check functions.
Expand Down Expand Up @@ -346,6 +360,7 @@ func WithFeatureCtx(ctx context.Context) context.Context {
PrePectraEVM: !g.IsYap(height),
AlwaysWriteCachedContract: !g.IsYap(height),
NoCandidateExitQueue: !g.IsYap(height),
NoVoterRewardDistribution: !g.IsToBeEnabled(height),
},
)
}
Expand Down
65 changes: 65 additions & 0 deletions action/protocol/poll/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/iotexproject/iotex-core/v2/action/protocol"
accountutil "github.com/iotexproject/iotex-core/v2/action/protocol/account/util"
"github.com/iotexproject/iotex-core/v2/action/protocol/rolldpos"
"github.com/iotexproject/iotex-core/v2/action/protocol/staking"
"github.com/iotexproject/iotex-core/v2/action/protocol/vote"
"github.com/iotexproject/iotex-core/v2/action/protocol/vote/candidatesutil"
"github.com/iotexproject/iotex-core/v2/pkg/log"
Expand Down Expand Up @@ -207,6 +208,31 @@ func setCandidates(
zap.String("score", candidate.Votes.String()),
)
}
// IIP-59: snapshot the latest user-set commissionRate from the staking
// candidate state onto each entry in this next-epoch candidate list.
// The frozen value travels with the snapshot through shiftCandidates
// (at the epoch boundary) and is what GrantEpochReward reads when it
// computes the voter / commission split.
//
// This is what gives voters their ~1.5-epoch reaction window: a
// SetCommissionRate action only affects rewards in the epoch *after*
// the next PutPollResult fires (mid-epoch), not the in-flight epoch.
if !protocol.MustGetFeatureCtx(ctx).NoVoterRewardDistribution {
if err := snapshotCommissionRates(sm, candidates); err != nil {
return err
}
// IIP-59: freeze per-candidate voter weights into per-candidate
// state blobs. distributeVoterReward (at the epoch's last block)
// reads from these snapshots, so any stake activity between this
// mid-epoch PutPollResult and the epoch boundary does NOT shift
// the distribution — matching the commission-rate snapshot
// semantics one line above.
if stakingProto := staking.FindProtocol(protocol.MustGetRegistry(ctx)); stakingProto != nil {
if err := stakingProto.SnapshotVoterWeights(sm); err != nil {
return errors.Wrap(err, "failed to snapshot voter weights")
}
}
}
if indexer != nil {
if err := indexer.PutCandidateList(height, &candidates); err != nil {
return errors.Wrapf(err, "failed to put candidatelist into indexer at height %d", height)
Expand All @@ -222,6 +248,45 @@ func setCandidates(
return err
}

// snapshotCommissionRates copies the latest CommissionRate from each
// staking candidate onto the next-epoch poll snapshot, in place.
//
// Skips any entry whose Identity is empty or doesn't decode to a valid
// address (mirrors the optional-field semantics already present in
// poll's setCandidates for legacy candidates without an explicit
// identity).
//
// Tolerates a missing staking view (returns nil instead of erroring)
// because (a) test fixtures sometimes drive setCandidates without
// registering the staking protocol — pre-IIP-59 code paths didn't need
// it; and (b) in production a missing staking view is a deployment
// misconfiguration that would surface much louder elsewhere (Validate,
// Handle, etc.). Either way, no snapshot is the right pre-flag default.
func snapshotCommissionRates(sm protocol.StateManager, candidates state.CandidateList) error {
csm, err := staking.NewCandidateStateManager(sm)
if err != nil {
// Staking view not registered — leave commissionRate at the
// default zero. Pre-flag callers and isolated test fixtures hit
// this; post-flag production has staking registered always.
return nil
}
for _, c := range candidates {
if c.Identity == "" {
continue
}
identityAddr, err := address.FromString(c.Identity)
if err != nil {
continue
}
stakingCand := csm.GetByIdentifier(identityAddr)
if stakingCand == nil {
continue
}
c.CommissionRate = stakingCand.CommissionRate
}
return nil
}

// setNextEpochProbationList sets the probation list with next key
func setNextEpochProbationList(
sm protocol.StateManager,
Expand Down
47 changes: 47 additions & 0 deletions action/protocol/poll/util_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2026 IoTeX Foundation
// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability
// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed.
// This source code is governed by Apache License 2.0 that can be found in the LICENSE file.

package poll

import (
"math/big"
"testing"

"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/iotexproject/iotex-core/v2/state"
"github.com/iotexproject/iotex-core/v2/test/identityset"
"github.com/iotexproject/iotex-core/v2/testutil/testdb"
)

// TestSnapshotCommissionRates_TolerantToMissingIdentity verifies the
// IIP-59 PutPollResult hook gracefully skips entries where Identity is
// empty or unparseable. Legacy candidates pre-dating the identity field
// have Identity="", and the snapshot helper must not crash on them — it
// simply leaves their CommissionRate at the default zero (legacy
// behavior). The staking-side happy path is exercised end-to-end by the
// integration test in PR 5.
func TestSnapshotCommissionRates_TolerantToMissingIdentity(t *testing.T) {
r := require.New(t)
ctrl := gomock.NewController(t)
sm := testdb.NewMockStateManagerWithoutHeightFunc(ctrl)
sm.EXPECT().Height().Return(uint64(0), nil).AnyTimes()

candidates := state.CandidateList{
// Empty Identity — legacy candidate; helper must skip silently.
{Identity: "", Address: identityset.Address(7).String(), Votes: big.NewInt(100)},
// Bogus Identity — helper must skip, not error.
{Identity: "not-an-address", Address: identityset.Address(8).String(), Votes: big.NewInt(100)},
// Identity that doesn't map to any staking candidate — helper
// must leave the rate at 0 (no panic, no error).
{Identity: identityset.Address(9).String(), Address: identityset.Address(8).String(), Votes: big.NewInt(100)},
}

r.NoError(snapshotCommissionRates(sm, candidates))
for i, c := range candidates {
r.Equal(uint64(0), c.CommissionRate, "candidate %d should be left at the default rate", i)
}
}
35 changes: 30 additions & 5 deletions action/protocol/rewarding/reward.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ func (p *Protocol) GrantEpochReward(
if featureWithHeightCtx.GetUnproductiveDelegates(epochStartHeight) {
epochRewardSplitUqdMap = uqdMap
}
addrs, amounts, err := p.splitEpochReward(candidates, a.epochReward, a.numDelegatesForEpochReward, exemptAddrs, epochRewardSplitUqdMap)
addrs, amounts, filteredCandidates, err := p.splitEpochReward(candidates, a.epochReward, a.numDelegatesForEpochReward, exemptAddrs, epochRewardSplitUqdMap)
if err != nil {
return nil, nil, err
}
Expand All @@ -295,6 +295,24 @@ func (p *Protocol) GrantEpochReward(
if amounts[i].Cmp(big.NewInt(0)) == 0 {
continue
}
// IIP-59: when the feature is active and this candidate has opted
// in (CommissionRate > 0 in the per-epoch poll snapshot), split
// the reward between the delegate's commission and a proportional
// voter distribution. distributeVoterReward returns (nil, nil)
// when the feature is off OR the rate is 0, in which case we fall
// through to the legacy single-grant path below.
voterLogs, err := p.distributeVoterReward(
ctx, sm, filteredCandidates[i], addrs[i], amounts[i],
blkCtx.BlockHeight, actionCtx.ActionHash,
)
if err != nil {
return nil, nil, err
}
if voterLogs != nil {
rewardLogs = append(rewardLogs, voterLogs...)
actualTotalReward = big.NewInt(0).Add(actualTotalReward, amounts[i])
continue
}
if err := p.grantToAccount(ctx, sm, addrs[i], amounts[i]); err != nil {
return nil, nil, err
}
Expand Down Expand Up @@ -649,7 +667,7 @@ func (p *Protocol) splitEpochReward(
numDelegatesForEpochReward uint64,
exemptAddrs map[string]interface{},
uqd map[string]uint64,
) ([]address.Address, []*big.Int, error) {
) ([]address.Address, []*big.Int, []*state.Candidate, error) {
filteredCandidates := make([]*state.Candidate, 0)
for _, candidate := range candidates {
if _, ok := exemptAddrs[candidate.Address]; ok {
Expand All @@ -659,7 +677,7 @@ func (p *Protocol) splitEpochReward(
}
candidates = filteredCandidates
if len(candidates) == 0 {
return nil, nil, nil
return nil, nil, nil, nil
}
// We at most allow numDelegatesForEpochReward delegates to get the epoch reward
if uint64(len(candidates)) > numDelegatesForEpochReward {
Expand All @@ -673,7 +691,7 @@ func (p *Protocol) splitEpochReward(
if candidate.RewardAddress != "" {
rewardAddr, err = address.FromString(candidate.RewardAddress)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
} else {
log.S().Warnf("Candidate %s doesn't have a reward address", candidate.Address)
Expand All @@ -696,7 +714,14 @@ func (p *Protocol) splitEpochReward(
amountPerAddr = big.NewInt(0).Div(big.NewInt(0).Mul(totalAmount, candidate.Votes), totalWeight)
amounts = append(amounts, amountPerAddr)
}
return rewardAddrs, amounts, nil
// IIP-59: the caller needs the per-candidate *state.Candidate alongside
// the parallel addrs/amounts slices so distributeVoterReward can read
// each candidate's frozen CommissionRate (and Identity for the
// voter-weight view lookup). PoC #4811 review finding #1 was that the
// PoC passed candidate.Address (operator) where the staking view
// expected Identity — handing through the whole pointer here avoids
// that whole class of mistake.
return rewardAddrs, amounts, candidates, nil
}

func (p *Protocol) assertNoRewardYet(ctx context.Context, sm protocol.StateManager, prefix []byte, index uint64) error {
Expand Down
Loading
Loading