Skip to content
Open
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 @@ -453,6 +453,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: 11 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 @@ -212,6 +213,16 @@ func setCandidates(
return errors.Wrapf(err, "failed to put candidatelist into indexer at height %d", height)
}
}
// IIP-59: freeze per-epoch commission rate + per-voter weights for the
// next epoch's active delegates. No-op when the feature is disabled.
// Must run before PutState below — the commission-rate copy mutates
// candidates in place, and the persisted state.Candidate needs the
// frozen value.
if stakingProto := staking.FindProtocol(protocol.MustGetRegistry(ctx)); stakingProto != nil {
if err := stakingProto.SnapshotForEpochReward(ctx, sm, candidates); err != nil {
return errors.Wrap(err, "failed to snapshot voter reward state for next epoch")
}
}
if loadCandidatesLegacy {
key, org := candidatesutil.ConstructLegacyKeyWithOrg(height)
_, err := sm.PutState(&candidates, protocol.LegacyKeyOption(key), protocol.ErigonStoreKeyOption(org))
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
23 changes: 23 additions & 0 deletions action/protocol/staking/candidate_statemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ type (
Upsert(*Candidate) error
CreditBucketPool(*big.Int, bool) error
DebitBucketPool(*big.Int, bool) error
// ApplyVoterWeightDelta pushes a (cand, voter, Δweight) delta into
// the incremental voter-weight view. No-op when IIP-59 voter reward
// distribution is inactive (pre-fork or view not yet initialized).
// Handlers call this at each SubVote / AddVote site.
ApplyVoterWeightDelta(cand, voter address.Address, delta *big.Int)
Commit(context.Context) error
SM() protocol.StateManager
SR() protocol.StateReader
Expand Down Expand Up @@ -124,6 +129,7 @@ func (csm *candSM) DirtyView() *viewData {
candCenter: csm.candCenter,
bucketPool: csm.bucketPool,
contractsStake: vd.contractsStake,
voterWeights: vd.voterWeights,
}
}

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

// ApplyVoterWeightDelta forwards to the live voterWeights view stored on the
// protocol viewData. It reads the view fresh each call — Snapshot()/Revert()
// swap the view instance in place, so a cached pointer would go stale after
// a mid-action snapshot. When the view is nil (pre-fork or no IIP-59
// initialization), the call is a no-op so handlers can invoke it
// unconditionally without extra feature-flag gating.
func (csm *candSM) ApplyVoterWeightDelta(cand, voter address.Address, delta *big.Int) {
if delta == nil || delta.Sign() == 0 {
return
}
view := csm.DirtyView()
if view == nil || view.voterWeights == nil {
return
}
view.voterWeights.Apply(cand, voter, delta)
}

func (csm *candSM) DebitBucketPool(amount *big.Int, newBucket bool) error {
return csm.bucketPool.DebitPool(csm, amount, newBucket)
}
Expand Down
1 change: 1 addition & 0 deletions action/protocol/staking/candidate_statereader.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ func ConstructBaseView(sr protocol.StateReader) (CandidateStateReader, error) {
candCenter: view.candCenter,
bucketPool: view.bucketPool,
contractsStake: view.contractsStake,
voterWeights: view.voterWeights,
},
}, nil
}
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
2 changes: 2 additions & 0 deletions action/protocol/staking/handler_candidate_endorsement.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func (p *Protocol) handleCandidateEndorsement(ctx context.Context, act *action.C
if err := csm.deactivate(cand, bucket, protocol.MustGetBlockCtx(ctx).BlockHeight, p.calculateVoteWeight); err != nil {
return log, nil, csmErrorToHandleError(cand.GetIdentifier().String(), err)
}
csm.ApplyVoterWeightDelta(cand.GetIdentifier(), bucket.Owner, p.calculateVoteWeight(bucket, false))
} else {
// TODO: Check that the bucket is ready for dequeue
if err := p.clearCandidateSelfStake(bucket, cand); err != nil {
Expand All @@ -92,6 +93,7 @@ func (p *Protocol) handleCandidateEndorsement(ctx context.Context, act *action.C
if err := csm.Upsert(cand); err != nil {
return log, nil, csmErrorToHandleError(actCtx.Caller.String(), err)
}
csm.ApplyVoterWeightDelta(cand.GetIdentifier(), bucket.Owner, p.calculateVoteWeight(bucket, false))
}
}
if err := esm.Delete(bucket.Index); err != nil {
Expand Down
11 changes: 11 additions & 0 deletions action/protocol/staking/handler_candidate_selfstake.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package staking
import (
"context"
"math"
"math/big"

"github.com/iotexproject/iotex-proto/golang/iotextypes"
"github.com/pkg/errors"
Expand Down Expand Up @@ -54,9 +55,15 @@ func (p *Protocol) handleCandidateActivate(ctx context.Context, act *action.Cand
if err := cand.AddVote(p.calculateVoteWeight(prevBucket, false)); err != nil {
return log, nil, err
}
// IIP-59: self-stake bucket → vote bucket → enters the voter weight view.
csm.ApplyVoterWeightDelta(cand.GetIdentifier(), prevBucket.Owner, p.calculateVoteWeight(prevBucket, false))
}

// convert vote bucket to self-stake bucket
// IIP-59: vote bucket → self-stake bucket → exits the voter weight view.
// Emit the -w BEFORE mutating SelfStakeBucketIdx so isSelfStake checks
// downstream see the pre-transition membership.
csm.ApplyVoterWeightDelta(cand.GetIdentifier(), bucket.Owner, new(big.Int).Neg(p.calculateVoteWeight(bucket, false)))
cand.SelfStakeBucketIdx = bucket.Index
cand.SelfStake.SetBytes(bucket.StakedAmount.Bytes())
if err := cand.SubVote(p.calculateVoteWeight(bucket, false)); err != nil {
Expand Down Expand Up @@ -100,6 +107,10 @@ func (p *Protocol) handleCandidateDeactivate(ctx context.Context, act *action.Ca
return nil, nil, rErr
}
if err = csm.deactivate(cand, bucket, protocol.MustGetBlockCtx(ctx).BlockHeight, p.calculateVoteWeight); err == nil {
// IIP-59: deactivate converts the self-stake bucket back to a vote
// bucket (clears SelfStakeBucketIdx and rebalances Votes). Mirror
// that transition in the voter weight view.
csm.ApplyVoterWeightDelta(id, bucket.Owner, p.calculateVoteWeight(bucket, false))
topics, eventData, err = action.PackCandidateDeactivatedEvent(id)
}
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func (p *Protocol) handleCandidateTransferOwnership(ctx context.Context, act *ac
return false, nil, errors.Wrap(err, "failed to get self-stake bucket")
}
if candidate.isSelfStakeBucketSettled() {
// Capture the settled self-stake bucket before mutating candidate — needed
// for the IIP-59 view delta below when we actually convert it back.
prevSelfStakeBucket, _ := csm.NativeBucket(candidate.SelfStakeBucketIdx)
clear, subVotes, err := needClear()
if err != nil {
return log, nil, err
Expand All @@ -76,6 +79,12 @@ func (p *Protocol) handleCandidateTransferOwnership(ctx context.Context, act *ac
candidate.SelfStakeBucketIdx = candidateNoSelfStakeBucketIndex
candidate.SelfStake = big.NewInt(0)
candidate.Votes.Sub(candidate.Votes, subVotes)
// IIP-59: the settled self-stake bucket became a normal vote bucket.
// Emit the +w so the voter weight view sees the new membership.
// Skip unstaked buckets — they are excluded from the view.
if prevSelfStakeBucket != nil && !prevSelfStakeBucket.isUnstaked() {
csm.ApplyVoterWeightDelta(candidate.GetIdentifier(), prevSelfStakeBucket.Owner, p.calculateVoteWeight(prevSelfStakeBucket, false))
}
}
}
if err := csm.Upsert(candidate); err != nil {
Expand Down
Loading
Loading