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
11 changes: 10 additions & 1 deletion action/protocol/staking/candidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ type (
Votes *big.Int
SelfStakeBucketIdx uint64
SelfStake *big.Int
// CommissionRate is IIP-59's voter reward commission rate in basis points
// (0-10000). 0 means legacy behavior (the full epoch reward goes to the
// delegate's reward account; no auto-distribution to voters).
CommissionRate uint64
}

// CandidateList is a list of candidates which is sortable
Expand Down Expand Up @@ -65,6 +69,7 @@ func (d *Candidate) Clone() *Candidate {
SelfStakeBucketIdx: d.SelfStakeBucketIdx,
SelfStake: new(big.Int).Set(d.SelfStake),
BLSPubKey: blsPubKey,
CommissionRate: d.CommissionRate,
}
}

Expand All @@ -79,7 +84,8 @@ func (d *Candidate) Equal(c *Candidate) bool {
d.Votes.Cmp(c.Votes) == 0 &&
d.SelfStake.Cmp(c.SelfStake) == 0 &&
d.DeactivatedAt == c.DeactivatedAt &&
bytes.Equal(d.BLSPubKey, c.BLSPubKey)
bytes.Equal(d.BLSPubKey, c.BLSPubKey) &&
d.CommissionRate == c.CommissionRate
}

// Validate does the sanity check
Expand Down Expand Up @@ -274,6 +280,7 @@ func (d *Candidate) toProto() (*stakingpb.Candidate, error) {
SelfStake: d.SelfStake.String(),
Pubkey: pubkey,
DeactivatedAt: d.DeactivatedAt,
CommissionRate: d.CommissionRate,
}, nil
}

Expand Down Expand Up @@ -324,6 +331,7 @@ func (d *Candidate) fromProto(pb *stakingpb.Candidate) error {
d.BLSPubKey = nil
}
d.DeactivatedAt = pb.GetDeactivatedAt()
d.CommissionRate = pb.GetCommissionRate()
return nil
}

Expand All @@ -341,6 +349,7 @@ func (d *Candidate) toIoTeXTypes() *iotextypes.CandidateV2 {
Id: d.GetIdentifier().String(),
BlsPubKey: blsPubKey,
DeactivatedAt: d.DeactivatedAt,
CommissionRate: d.CommissionRate,
}
}

Expand Down
42 changes: 42 additions & 0 deletions action/protocol/staking/candidate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,48 @@ func TestCloneWithDeactivation(t *testing.T) {
r.Equal(uint64(12345), original.DeactivatedAt)
}

// TestCandidateCommissionRate verifies IIP-59's CommissionRate field
// survives Equal, Clone, and proto roundtrip — the missing-Equal-update
// was a bug in the original PoC.
func TestCandidateCommissionRate(t *testing.T) {
r := require.New(t)

original := &Candidate{
Owner: identityset.Address(1),
Operator: identityset.Address(2),
Reward: identityset.Address(3),
Name: "test_candidate",
Votes: big.NewInt(100),
SelfStake: big.NewInt(1000),
SelfStakeBucketIdx: 1,
CommissionRate: 1500, // 15%
}

clone := original.Clone()
r.True(original.Equal(clone))
r.Equal(uint64(1500), clone.CommissionRate)

// Differing CommissionRate must compare not-equal (PoC missed this).
clone.CommissionRate = 1000
r.False(original.Equal(clone))
clone.CommissionRate = 1500
r.True(original.Equal(clone))

// Mutation isolation: changing clone does not affect original.
clone.CommissionRate = 2000
r.Equal(uint64(1500), original.CommissionRate)

// Proto roundtrip.
pb, err := original.toProto()
r.NoError(err)
r.Equal(uint64(1500), pb.GetCommissionRate())

restored := &Candidate{}
r.NoError(restored.fromProto(pb))
r.Equal(uint64(1500), restored.CommissionRate)
r.True(original.Equal(restored))
}

var (
testCandidates = []struct {
d *Candidate
Expand Down
53 changes: 53 additions & 0 deletions action/protocol/staking/handler_set_commission_rate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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 staking

import (
"context"

"github.com/iotexproject/iotex-core/v2/action"
"github.com/iotexproject/iotex-core/v2/action/protocol"
)

// handleSetCommissionRate handles IIP-59's SetCommissionRate action.
//
// Semantics:
// - Only the candidate's owner may set the rate.
// - The new rate is stored on the staking candidate immediately. It only
// takes effect at distribution time after the next PutPollResult
// snapshots it into the per-epoch state.Candidate, which gives voters
// ~1.5 epochs of reaction time. No separate cooldown field is needed —
// IIP-59 doesn't prescribe one.
// - The rate-range check happens at Validate (validateSetCommissionRate)
// so out-of-range values never reach the handler.
// - A non-owner caller returns a handleError so the tx receipt is marked
// failed; block production continues.
func (p *Protocol) handleSetCommissionRate(
ctx context.Context,
act *action.SetCommissionRate,
csm CandidateStateManager,
) (*receiptLog, error) {
actCtx := protocol.MustGetActionCtx(ctx)
rLog := newReceiptLog(p.addr.String())

cand := csm.GetByOwner(actCtx.Caller)
if cand == nil {
return rLog, errCandNotExist
}

cand.CommissionRate = act.Rate()
if err := csm.Upsert(cand); err != nil {
return rLog, csmErrorToHandleError(cand.GetIdentifier().String(), err)
}

topics, eventData, err := action.PackCommissionRateSetEvent(cand.GetIdentifier(), act.Rate())
if err != nil {
return rLog, err
}
rLog.AddEvent(topics, eventData)
return rLog, nil
}

Loading
Loading