Skip to content

Commit 45a343a

Browse files
envestccclaude
andcommitted
feat(action,staking,state): commissionRate schema + SetCommissionRate (IIP-59 PR 1/6)
First PR of the IIP-59 protocol-native voter reward distribution series (spec: iotexproject/iips#73). Introduces the on-chain delegate opt-in surface — a commissionRate field on the candidate schema plus the SetCommissionRate action delegates use to change it. No reward-distribution logic yet: that lands in PR 3, gated on the same feature flag as this PR. Chain behavior is unchanged pre-flag. Schema ------ - stakingpb.Candidate gains commissionRate=11; Go Candidate struct mirrors it. Equal / Clone / toProto / fromProto updated (the PoC at iotexproject#4811 missed Equal — flagged in review #2 there). - state.Candidate gains CommissionRate. Populated per epoch from staking.Candidate by PutPollResult in PR 2; the latest user-set value lives on staking.Candidate, and state.Candidate holds the frozen per-epoch value consumed by GrantEpochReward in PR 3. - iotextypes.Candidate.commissionRate is set/read in candidateToPb / pbToCandidate so the field travels through poll snapshots and RPC. Feature flag ------------ - FeatureCtx.NoVoterRewardDistribution, bound to !g.IsToBeEnabled(height) per AGENTS.md convention for WIP features (the gate will be swapped for a real hardfork height at release). - Named so the bool zero value (false) corresponds to the post-fork activated behavior, matching NoCandidateExitQueue / NotSlashUnproductiveDelegates. Action (SetCommissionRate) -------------------------- - action/set_commission_rate.go: SetCommissionRate{rate uint64} with IntrinsicGas (10000) and SanityCheck (rate in [0, 10000]). - Full Proto / LoadProto / FillAction wiring for the iotex-proto oneof slot (setCommissionRate = 56) shipped in iotex-proto v0.6.7 (iotexproject/iotex-proto#174). - EthCompatibleAction + NewSetCommissionRateFromABIBinary so MetaMask / hardhat can submit via the same path as candidateActivate / candidateDeactivate. - PackCommissionRateSetEvent helper producing keccak-anchored topic-0 + indexed candidate address for the receipt log indexers subscribe to. - action/native_staking_contract_interface.sol + native_staking_contract_abi.json: declare \`function setCommissionRate(uint64 rate)\` and \`event CommissionRateSet(address indexed candidate, uint64 newRate)\` so external tooling has an ABI to bind against. Handler (staking) ----------------- - action/protocol/staking/handler_set_commission_rate.go: resolves the caller to a registered candidate by owner, writes candidate.CommissionRate, emits CommissionRateSet on the receipt. No cooldown enforcement — voters already get a ~1.5 epoch reaction window from the PutPollResult snapshot (§3.4 of the IIP), so a separate per-rate-change gate is not required. - validations.go: rejects SetCommissionRate at Validate when the feature flag is off, so pre-flag the action never leaves mempool. - protocol.go: registers the action in the handler switch. Tests ----- - candidate_test.go / state/candidate_test.go: proto roundtrip + Equal / Clone coverage for the new field. - set_commission_rate_test.go: SanityCheck bounds, IntrinsicGas, ABI encode/decode roundtrip. - handler_set_commission_rate_test.go: owner check, rate cap, successful write path, pre-flag Validate rejection, receipt event encoding. go.mod ------ - Bumps github.com/iotexproject/iotex-proto to v0.6.7 for the new SetCommissionRate action + Candidate.commissionRate fields. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 89e7507 commit 45a343a

18 files changed

Lines changed: 811 additions & 69 deletions

action/envelope.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,12 @@ func (elp *envelope) loadProtoActionPayload(pbAct *iotextypes.ActionCore) error
453453
return err
454454
}
455455
elp.payload = act
456+
case pbAct.GetSetCommissionRate() != nil:
457+
act := &SetCommissionRate{}
458+
if err := act.LoadProto(pbAct.GetSetCommissionRate()); err != nil {
459+
return err
460+
}
461+
elp.payload = act
456462
default:
457463
return errors.Errorf("no applicable action to handle proto type %T", pbAct.Action)
458464
}

action/native_staking_contract_abi.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@
3131
"name": "CandidateDeactivated",
3232
"type": "event"
3333
},
34+
{
35+
"anonymous": false,
36+
"inputs": [
37+
{
38+
"indexed": true,
39+
"internalType": "address",
40+
"name": "candidate",
41+
"type": "address"
42+
},
43+
{
44+
"indexed": false,
45+
"internalType": "uint64",
46+
"name": "newRate",
47+
"type": "uint64"
48+
}
49+
],
50+
"name": "CommissionRateSet",
51+
"type": "event"
52+
},
3453
{
3554
"anonymous": false,
3655
"inputs": [
@@ -212,6 +231,19 @@
212231
"stateMutability": "nonpayable",
213232
"type": "function"
214233
},
234+
{
235+
"inputs": [
236+
{
237+
"internalType": "uint64",
238+
"name": "rate",
239+
"type": "uint64"
240+
}
241+
],
242+
"name": "setCommissionRate",
243+
"outputs": [],
244+
"stateMutability": "nonpayable",
245+
"type": "function"
246+
},
215247
{
216248
"inputs": [
217249
{

action/native_staking_contract_interface.sol

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ interface INativeStakingContract {
4141
address rewardAddress,
4242
bytes blsPubKey
4343
);
44+
// IIP-59: emitted when a delegate updates its voter reward commission
45+
// rate via setCommissionRate. The new rate takes effect at the next
46+
// epoch boundary (poll PutPollResult snapshot).
47+
event CommissionRateSet(
48+
address indexed candidate,
49+
uint64 newRate
50+
);
4451

4552
function candidateRegister(
4653
string memory name,
@@ -82,6 +89,11 @@ interface INativeStakingContract {
8289

8390
function revokeEndorsement(uint64 bucketIndex) external;
8491

92+
// IIP-59: set the voter reward commission rate (basis points, 0-10000).
93+
// Only the candidate owner can call. Takes effect at the next epoch
94+
// boundary via the existing poll snapshot machinery.
95+
function setCommissionRate(uint64 rate) external;
96+
8597
// Candidate Transfer Ownership
8698
function candidateTransferOwnership(
8799
address newOwner,

action/protocol/context.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,20 @@ type (
172172
// contracts are committed and written back
173173
AlwaysWriteCachedContract bool
174174
NoCandidateExitQueue bool
175+
// NoVoterRewardDistribution gates IIP-59: protocol-native voter reward
176+
// distribution. When false (the post-fork / activated state, which is also
177+
// the bool zero value), GrantEpochReward auto-splits each delegate's
178+
// epoch reward between the delegate's commission and a proportional
179+
// distribution to voters, and SetCommissionRate actions are accepted.
180+
// When true (the pre-fork legacy state), the protocol keeps today's
181+
// behavior of granting the full epoch reward to the delegate.
182+
//
183+
// Naming convention: feature flags should be named such that the bool
184+
// zero value (false) corresponds to the post-fork activated behavior.
185+
// This matches NoCandidateExitQueue / NotSlashUnproductiveDelegates and
186+
// makes a missing / partially-initialized FeatureCtx default to the
187+
// long-term mainnet behavior rather than the temporary pre-fork state.
188+
NoVoterRewardDistribution bool
175189
}
176190

177191
// FeatureWithHeightCtx provides feature check functions.
@@ -346,6 +360,7 @@ func WithFeatureCtx(ctx context.Context) context.Context {
346360
PrePectraEVM: !g.IsYap(height),
347361
AlwaysWriteCachedContract: !g.IsYap(height),
348362
NoCandidateExitQueue: !g.IsYap(height),
363+
NoVoterRewardDistribution: !g.IsToBeEnabled(height),
349364
},
350365
)
351366
}

action/protocol/staking/candidate.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ type (
3535
Votes *big.Int
3636
SelfStakeBucketIdx uint64
3737
SelfStake *big.Int
38+
// CommissionRate is IIP-59's voter reward commission rate in basis points
39+
// (0-10000). 0 means legacy behavior (the full epoch reward goes to the
40+
// delegate's reward account; no auto-distribution to voters).
41+
CommissionRate uint64
3842
}
3943

4044
// CandidateList is a list of candidates which is sortable
@@ -65,6 +69,7 @@ func (d *Candidate) Clone() *Candidate {
6569
SelfStakeBucketIdx: d.SelfStakeBucketIdx,
6670
SelfStake: new(big.Int).Set(d.SelfStake),
6771
BLSPubKey: blsPubKey,
72+
CommissionRate: d.CommissionRate,
6873
}
6974
}
7075

@@ -79,7 +84,8 @@ func (d *Candidate) Equal(c *Candidate) bool {
7984
d.Votes.Cmp(c.Votes) == 0 &&
8085
d.SelfStake.Cmp(c.SelfStake) == 0 &&
8186
d.DeactivatedAt == c.DeactivatedAt &&
82-
bytes.Equal(d.BLSPubKey, c.BLSPubKey)
87+
bytes.Equal(d.BLSPubKey, c.BLSPubKey) &&
88+
d.CommissionRate == c.CommissionRate
8389
}
8490

8591
// Validate does the sanity check
@@ -274,6 +280,7 @@ func (d *Candidate) toProto() (*stakingpb.Candidate, error) {
274280
SelfStake: d.SelfStake.String(),
275281
Pubkey: pubkey,
276282
DeactivatedAt: d.DeactivatedAt,
283+
CommissionRate: d.CommissionRate,
277284
}, nil
278285
}
279286

@@ -324,6 +331,7 @@ func (d *Candidate) fromProto(pb *stakingpb.Candidate) error {
324331
d.BLSPubKey = nil
325332
}
326333
d.DeactivatedAt = pb.GetDeactivatedAt()
334+
d.CommissionRate = pb.GetCommissionRate()
327335
return nil
328336
}
329337

@@ -341,6 +349,7 @@ func (d *Candidate) toIoTeXTypes() *iotextypes.CandidateV2 {
341349
Id: d.GetIdentifier().String(),
342350
BlsPubKey: blsPubKey,
343351
DeactivatedAt: d.DeactivatedAt,
352+
CommissionRate: d.CommissionRate,
344353
}
345354
}
346355

action/protocol/staking/candidate_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,48 @@ func TestCloneWithDeactivation(t *testing.T) {
180180
r.Equal(uint64(12345), original.DeactivatedAt)
181181
}
182182

183+
// TestCandidateCommissionRate verifies IIP-59's CommissionRate field
184+
// survives Equal, Clone, and proto roundtrip — the missing-Equal-update
185+
// was a bug in the original PoC.
186+
func TestCandidateCommissionRate(t *testing.T) {
187+
r := require.New(t)
188+
189+
original := &Candidate{
190+
Owner: identityset.Address(1),
191+
Operator: identityset.Address(2),
192+
Reward: identityset.Address(3),
193+
Name: "test_candidate",
194+
Votes: big.NewInt(100),
195+
SelfStake: big.NewInt(1000),
196+
SelfStakeBucketIdx: 1,
197+
CommissionRate: 1500, // 15%
198+
}
199+
200+
clone := original.Clone()
201+
r.True(original.Equal(clone))
202+
r.Equal(uint64(1500), clone.CommissionRate)
203+
204+
// Differing CommissionRate must compare not-equal (PoC missed this).
205+
clone.CommissionRate = 1000
206+
r.False(original.Equal(clone))
207+
clone.CommissionRate = 1500
208+
r.True(original.Equal(clone))
209+
210+
// Mutation isolation: changing clone does not affect original.
211+
clone.CommissionRate = 2000
212+
r.Equal(uint64(1500), original.CommissionRate)
213+
214+
// Proto roundtrip.
215+
pb, err := original.toProto()
216+
r.NoError(err)
217+
r.Equal(uint64(1500), pb.GetCommissionRate())
218+
219+
restored := &Candidate{}
220+
r.NoError(restored.fromProto(pb))
221+
r.Equal(uint64(1500), restored.CommissionRate)
222+
r.True(original.Equal(restored))
223+
}
224+
183225
var (
184226
testCandidates = []struct {
185227
d *Candidate
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2026 IoTeX Foundation
2+
// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability
3+
// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed.
4+
// This source code is governed by Apache License 2.0 that can be found in the LICENSE file.
5+
6+
package staking
7+
8+
import (
9+
"context"
10+
11+
"github.com/iotexproject/iotex-core/v2/action"
12+
"github.com/iotexproject/iotex-core/v2/action/protocol"
13+
)
14+
15+
// handleSetCommissionRate handles IIP-59's SetCommissionRate action.
16+
//
17+
// Semantics:
18+
// - Only the candidate's owner may set the rate.
19+
// - The new rate is stored on the staking candidate immediately. It only
20+
// takes effect at distribution time after the next PutPollResult
21+
// snapshots it into the per-epoch state.Candidate, which gives voters
22+
// ~1.5 epochs of reaction time. No separate cooldown field is needed —
23+
// IIP-59 doesn't prescribe one.
24+
// - The rate-range check happens at Validate (validateSetCommissionRate)
25+
// so out-of-range values never reach the handler.
26+
// - A non-owner caller returns a handleError so the tx receipt is marked
27+
// failed; block production continues.
28+
func (p *Protocol) handleSetCommissionRate(
29+
ctx context.Context,
30+
act *action.SetCommissionRate,
31+
csm CandidateStateManager,
32+
) (*receiptLog, error) {
33+
actCtx := protocol.MustGetActionCtx(ctx)
34+
rLog := newReceiptLog(p.addr.String())
35+
36+
cand := csm.GetByOwner(actCtx.Caller)
37+
if cand == nil {
38+
return rLog, errCandNotExist
39+
}
40+
41+
cand.CommissionRate = act.Rate()
42+
if err := csm.Upsert(cand); err != nil {
43+
return rLog, csmErrorToHandleError(cand.GetIdentifier().String(), err)
44+
}
45+
46+
topics, eventData, err := action.PackCommissionRateSetEvent(cand.GetIdentifier(), act.Rate())
47+
if err != nil {
48+
return rLog, err
49+
}
50+
rLog.AddEvent(topics, eventData)
51+
return rLog, nil
52+
}
53+

0 commit comments

Comments
 (0)