From a13a11a090d786592e8e0e61e22e53594440df20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Buko=C5=A1ek?= Date: Wed, 16 Aug 2023 09:13:59 +0200 Subject: [PATCH] go/staking: Enable changing the reward schedule --- .changelog/5352.feature.md | 4 + go/beacon/api/api.go | 3 + .../scenario/e2e/change_reward_schedule.go | 354 ++++++++++++++++++ .../e2e/consensus_parameter_upgrade.go | 99 +---- .../scenario/e2e/helpers_consensus.go | 105 ++++++ go/oasis-test-runner/scenario/e2e/scenario.go | 2 + go/staking/api/api.go | 6 + go/staking/api/api_test.go | 57 +++ go/staking/api/sanity_check.go | 12 + 9 files changed, 544 insertions(+), 98 deletions(-) create mode 100644 .changelog/5352.feature.md create mode 100644 go/oasis-test-runner/scenario/e2e/change_reward_schedule.go diff --git a/.changelog/5352.feature.md b/.changelog/5352.feature.md new file mode 100644 index 00000000000..69f32e39ba9 --- /dev/null +++ b/.changelog/5352.feature.md @@ -0,0 +1,4 @@ +go/staking: Enable changing the reward schedule + +The ability to change the reward schedule in the staking consensus +parameters through a governance vote was added. diff --git a/go/beacon/api/api.go b/go/beacon/api/api.go index 840a54c13f1..5a7c5450abf 100644 --- a/go/beacon/api/api.go +++ b/go/beacon/api/api.go @@ -18,6 +18,9 @@ const ( // EpochInvalid is the placeholder invalid epoch. EpochInvalid EpochTime = 0xffffffffffffffff // ~50 quadrillion years away. + // EpochMax is the end of time. + EpochMax EpochTime = 0xfffffffffffffffe + // BackendInsecure is the name of the insecure backend. BackendInsecure = "insecure" diff --git a/go/oasis-test-runner/scenario/e2e/change_reward_schedule.go b/go/oasis-test-runner/scenario/e2e/change_reward_schedule.go new file mode 100644 index 00000000000..cbdf8191120 --- /dev/null +++ b/go/oasis-test-runner/scenario/e2e/change_reward_schedule.go @@ -0,0 +1,354 @@ +package e2e + +import ( + "context" + "fmt" + "reflect" + + beacon "github.com/oasisprotocol/oasis-core/go/beacon/api" + "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/quantity" + consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" + "github.com/oasisprotocol/oasis-core/go/governance/api" + "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/env" + "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/oasis" + "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/scenario" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" +) + +var rewardScheduleChanges = staking.ConsensusParameterChanges{ + RewardSchedule: &[]staking.RewardStep{ + // 1% until the end of time. + { + Until: beacon.EpochMax, + Scale: *quantity.NewFromUint64(1_000_000), + }, + }, +} + +// ChangeParametersRewardSchedule is the governance change parameters scenario +// that changes the reward schedule. +var ChangeParametersRewardSchedule scenario.Scenario = newChangeRewardScheduleImpl( + "change-parameters-reward-schedule", + &api.ChangeParametersProposal{ + Module: staking.ModuleName, + Changes: cbor.Marshal(rewardScheduleChanges), + }, +) + +type changeRewardScheduleImpl struct { + Scenario + + ctx context.Context + parameters *api.ChangeParametersProposal + + currentEpoch beacon.EpochTime + entityNonce uint64 + entity *oasis.Entity +} + +func newChangeRewardScheduleImpl(name string, parameters *api.ChangeParametersProposal) scenario.Scenario { + sc := &changeRewardScheduleImpl{ + Scenario: *NewScenario(name), + parameters: parameters, + } + return sc +} + +func (sc *changeRewardScheduleImpl) Clone() scenario.Scenario { + return &changeRewardScheduleImpl{ + Scenario: sc.Scenario.Clone(), + parameters: sc.parameters, + currentEpoch: sc.currentEpoch, + entityNonce: sc.entityNonce, + } +} + +func (sc *changeRewardScheduleImpl) Fixture() (*oasis.NetworkFixture, error) { + f, err := sc.Scenario.Fixture() + if err != nil { + return nil, err + } + + // Needed so we can fast-forward to upgrade epoch. + f.Network.SetMockEpoch() + // Needed as we will vote as validators. + f.Network.DeterministicIdentities = true + + f.Network.GovernanceParameters = &api.ConsensusParameters{ + MinProposalDeposit: *quantity.NewFromUint64(100), + VotingPeriod: 5, + StakeThreshold: 100, + UpgradeMinEpochDiff: 20, + UpgradeCancelMinEpochDiff: 8, + } + f.Network.StakingGenesis = &staking.Genesis{ + TotalSupply: *quantity.NewFromUint64(1_201_000_000), + CommonPool: *quantity.NewFromUint64(1_000_000_000), + Parameters: staking.ConsensusParameters{ + CommissionScheduleRules: staking.CommissionScheduleRules{ + RateChangeInterval: 1, + RateBoundLead: 1, + MaxRateSteps: 1, + MaxBoundSteps: 1, + }, + // Initial reward schedule (we'll change this with a proposal during + // the course of this e2e test). + RewardSchedule: []staking.RewardStep{ + // Reward 10% for the first 20 epochs. + { + Until: 20, + Scale: *quantity.NewFromUint64(10_000_000), + }, + }, + // Give full rewards each epoch to entities that have signed over + // at least a third of all blocks signed that epoch. + RewardFactorEpochSigned: *quantity.NewFromUint64(1), + SigningRewardThresholdNumerator: 1, + SigningRewardThresholdDenominator: 3, + }, + Ledger: map[staking.Address]*staking.Account{ + // Fund entity account so we'll be able to submit the proposal. + DeterministicEntity1: { + General: staking.GeneralAccount{ + Balance: *quantity.NewFromUint64(1_000_000), + }, + Escrow: staking.EscrowAccount{ + Active: staking.SharePool{ + Balance: *quantity.NewFromUint64(100_000_000), + TotalShares: *quantity.NewFromUint64(100_000_000), + }, + // Note that the scale for the rates in the commission + // schedule is 1/1_000 and not 1/1_000_000 as in the reward + // schedule. + CommissionSchedule: staking.CommissionSchedule{ + Rates: []staking.CommissionRateStep{ + { + Start: 0, + Rate: *quantity.NewFromUint64(10_000), // 10% + }, + }, + Bounds: []staking.CommissionRateBoundStep{ + { + Start: 0, + RateMin: *quantity.NewFromUint64(0), // 0% + RateMax: *quantity.NewFromUint64(100_000), // 100% + }, + }, + }, + }, + }, + DeterministicValidator0: { + Escrow: staking.EscrowAccount{ + Active: staking.SharePool{ + Balance: *quantity.NewFromUint64(100_000_000), + TotalShares: *quantity.NewFromUint64(100_000_000), + }, + CommissionSchedule: staking.CommissionSchedule{ + Rates: []staking.CommissionRateStep{ + { + Start: 0, + Rate: *quantity.NewFromUint64(10_000), // 10% + }, + }, + Bounds: []staking.CommissionRateBoundStep{ + { + Start: 0, + RateMin: *quantity.NewFromUint64(0), // 0% + RateMax: *quantity.NewFromUint64(100_000), // 100% + }, + }, + }, + }, + }, + }, + Delegations: map[staking.Address]map[staking.Address]*staking.Delegation{ + DeterministicEntity1: { + DeterministicValidator0: &staking.Delegation{ + Shares: *quantity.NewFromUint64(100_000_000), + }, + }, + DeterministicValidator0: { + DeterministicEntity1: &staking.Delegation{ + Shares: *quantity.NewFromUint64(100_000_000), + }, + }, + }, + } + + return f, nil +} + +func (sc *changeRewardScheduleImpl) nextEpoch() error { + sc.currentEpoch++ + if err := sc.Net.Controller().SetEpoch(sc.ctx, sc.currentEpoch); err != nil { + // Errors can happen because an upgrade happens exactly during an epoch + // transition. So make sure to ignore them. + sc.Logger.Warn("failed to set epoch", + "epoch", sc.currentEpoch, + "err", err, + ) + } + return nil +} + +func (sc *changeRewardScheduleImpl) fetchAccount(owner staking.Address) (*staking.Account, error) { + a, err := sc.Net.Controller().Staking.Account(sc.ctx, + &staking.OwnerQuery{ + Height: consensus.HeightLatest, + Owner: owner, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed querying account: %w", err) + } + + return a, nil +} + +func (sc *changeRewardScheduleImpl) fetchEscrowBalance(owner staking.Address) (*quantity.Quantity, error) { + a, err := sc.fetchAccount(owner) + if err != nil { + return nil, err + } + return &a.Escrow.Active.Balance, nil +} + +func (sc *changeRewardScheduleImpl) Run(ctx context.Context, childEnv *env.Env) error { + sc.ctx = ctx + + if err := sc.Net.Start(); err != nil { + return err + } + + // Wait for the validators to come up. + sc.Logger.Info("waiting for validators to initialize", + "num_validators", len(sc.Net.Validators()), + ) + for _, n := range sc.Net.Validators() { + if err := n.WaitReady(ctx); err != nil { + return fmt.Errorf("failed to wait for a validator: %w", err) + } + } + + if err := sc.nextEpoch(); err != nil { + return err + } + + // Consensus parameters before the vote should be different from the ones + // we want to have after the vote. + oldParams, err := sc.Net.Controller().Staking.ConsensusParameters(ctx, consensus.HeightLatest) + if err != nil { + return err + } + if reflect.DeepEqual(oldParams.RewardSchedule, *rewardScheduleChanges.RewardSchedule) { + return fmt.Errorf("starting reward schedule is incorrect") + } + + sc.entity = sc.Net.Entities()[1] + entityAcc, err := sc.fetchAccount(DeterministicEntity1) + if err != nil { + return err + } + sc.entityNonce = entityAcc.General.Nonce + + acc := DeterministicEntity1 + + initialBalance, err := sc.fetchEscrowBalance(acc) + if err != nil { + return err + } + sc.Logger.Info("initial escrow balance", "balance", initialBalance) + + // Do an epoch transition. + sc.Logger.Info("first epoch transition") + if err = sc.nextEpoch(); err != nil { + return err + } + balance1, err := sc.fetchEscrowBalance(acc) + if err != nil { + return err + } + sc.Logger.Info("escrow balance after first epoch transition", "balance", balance1) + + if initialBalance.Cmp(balance1) != -1 { + return fmt.Errorf("should have received a reward after first epoch transition") + } + + // Do an epoch transition. + sc.Logger.Info("second epoch transition") + if err = sc.nextEpoch(); err != nil { + return err + } + balance2, err := sc.fetchEscrowBalance(acc) + if err != nil { + return err + } + sc.Logger.Info("escrow balance after second epoch transition", "balance", balance2) + + if balance1.Cmp(balance2) != -1 { + return fmt.Errorf("should have received a reward after second epoch transition") + } + + // Submit change parameters proposal. + content := &api.ProposalContent{ + ChangeParameters: sc.parameters, + } + _, sc.entityNonce, sc.currentEpoch, err = sc.EnsureProposalFinalized(ctx, content, sc.entity, sc.entityNonce, sc.currentEpoch) + if err != nil { + return fmt.Errorf("upgrade proposal error: %w", err) + } + + // The consensus parameters after the proposal has been finalized + // should match the parameters we proposed. + newParams, err := sc.Net.Controller().Staking.ConsensusParameters(ctx, consensus.HeightLatest) + if err != nil { + return err + } + if !reflect.DeepEqual(newParams.RewardSchedule, *rewardScheduleChanges.RewardSchedule) { + return fmt.Errorf("failed to change reward schedule") + } + + // Do an epoch transition. + sc.Logger.Info("third epoch transition") + if err = sc.nextEpoch(); err != nil { + return err + } + balance3, err := sc.fetchEscrowBalance(acc) + if err != nil { + return err + } + sc.Logger.Info("escrow balance after third epoch transition", "balance", balance3) + + // Do an epoch transition. + sc.Logger.Info("fourth epoch transition") + if err = sc.nextEpoch(); err != nil { + return err + } + balance4, err := sc.fetchEscrowBalance(acc) + if err != nil { + return err + } + sc.Logger.Info("escrow balance after fourth epoch transition", "balance", balance4) + + if balance3.Cmp(balance4) != -1 { + return fmt.Errorf("should have received a reward after fourth epoch transition") + } + + // We should have received greater rewards between 2nd and 1st transition + // than between 4th and 3rd, because the original reward schedule awarded + // 10% and the new one only 1%. + diff21 := balance2.Clone() + if err = diff21.Sub(balance1); err != nil { + return err + } + diff43 := balance4.Clone() + if err = diff43.Sub(balance3); err != nil { + return err + } + if diff43.Cmp(diff21) != -1 { + return fmt.Errorf("rewards before the schedule change should have been greater than the rewards after the schedule change") + } + + return nil +} diff --git a/go/oasis-test-runner/scenario/e2e/consensus_parameter_upgrade.go b/go/oasis-test-runner/scenario/e2e/consensus_parameter_upgrade.go index 0ce82944e9d..cc3dc12de97 100644 --- a/go/oasis-test-runner/scenario/e2e/consensus_parameter_upgrade.go +++ b/go/oasis-test-runner/scenario/e2e/consensus_parameter_upgrade.go @@ -9,7 +9,6 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/cbor" "github.com/oasisprotocol/oasis-core/go/common/quantity" consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" - "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" "github.com/oasisprotocol/oasis-core/go/governance/api" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/env" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/oasis" @@ -328,102 +327,6 @@ func (sc *consensusParameterUpgradeImpl) nextEpoch(ctx context.Context) error { return nil } -// Submits a proposal, votes for it and ensures the proposal is finalized. -func (sc *consensusParameterUpgradeImpl) ensureProposalFinalized(ctx context.Context, content *api.ProposalContent) (*api.Proposal, error) { - // Submit proposal. - tx := api.NewSubmitProposalTx(sc.entityNonce, &transaction.Fee{Gas: 2000}, content) - sc.entityNonce++ - sigTx, err := transaction.Sign(sc.entity.Signer(), tx) - if err != nil { - return nil, fmt.Errorf("failed signing submit proposal transaction: %w", err) - } - sc.Logger.Info("submitting proposal", "content", content) - err = sc.Net.Controller().Consensus.SubmitTx(ctx, sigTx) - if err != nil { - return nil, fmt.Errorf("failed submitting proposal transaction: %w", err) - } - - // Ensure proposal created. - aps, err := sc.Net.Controller().Governance.ActiveProposals(ctx, consensus.HeightLatest) - if err != nil { - return nil, fmt.Errorf("failed querying active proposals: %w", err) - } - var proposal *api.Proposal - for _, p := range aps { - if p.Content.Equals(content) { - proposal = p - break - } - } - if proposal == nil { - return nil, fmt.Errorf("submitted proposal %v not found", content) - } - - // Vote for the proposal. - vote := api.ProposalVote{ - ID: proposal.ID, - Vote: api.VoteYes, - } - tx = api.NewCastVoteTx(sc.entityNonce, &transaction.Fee{Gas: 2000}, &vote) - sc.entityNonce++ - sigTx, err = transaction.Sign(sc.entity.Signer(), tx) - if err != nil { - return nil, fmt.Errorf("failed signing cast vote transaction: %w", err) - } - sc.Logger.Info("submitting vote for proposal", "proposal", proposal, "vote", vote) - err = sc.Net.Controller().Consensus.SubmitTx(ctx, sigTx) - if err != nil { - return nil, fmt.Errorf("failed submitting cast vote transaction: %w", err) - } - - // Ensure vote was cast. - votes, err := sc.Net.Controller().Governance.Votes(ctx, - &api.ProposalQuery{ - Height: consensus.HeightLatest, - ProposalID: aps[0].ID, - }, - ) - if err != nil { - return nil, fmt.Errorf("failed queying votes: %w", err) - } - if l := len(votes); l != 1 { - return nil, fmt.Errorf("expected one vote, got: %v", l) - } - if vote := votes[0].Vote; vote != api.VoteYes { - return nil, fmt.Errorf("expected vote Yes, got: %s", string(vote)) - } - - // Transition to the epoch when proposal finalizes. - for ep := sc.currentEpoch + 1; ep < aps[0].ClosesAt+1; ep++ { - sc.Logger.Info("transitioning to epoch", "epoch", ep) - if err = sc.nextEpoch(ctx); err != nil { - return nil, err - } - } - - p, err := sc.Net.Controller().Governance.Proposal(ctx, - &api.ProposalQuery{ - Height: consensus.HeightLatest, - ProposalID: proposal.ID, - }, - ) - if err != nil { - return nil, fmt.Errorf("failed to query proposal: %w", err) - } - sc.Logger.Info("got proposal", - "state", p.State.String(), - "results", p.Results, - "len", len(p.Results), - "invalid", p.InvalidVotes, - ) - // Ensure proposal finalized. - if p.State == api.StateActive || p.State == api.StateFailed { - return nil, fmt.Errorf("expected finalized proposal, proposal state: %v", p.State) - } - - return p, nil -} - func (sc *consensusParameterUpgradeImpl) Run(ctx context.Context, childEnv *env.Env) error { if err := sc.Net.Start(); err != nil { return err @@ -465,7 +368,7 @@ func (sc *consensusParameterUpgradeImpl) Run(ctx context.Context, childEnv *env. content := &api.ProposalContent{ ChangeParameters: sc.parameters, } - _, err = sc.ensureProposalFinalized(ctx, content) + _, sc.entityNonce, sc.currentEpoch, err = sc.EnsureProposalFinalized(ctx, content, sc.entity, sc.entityNonce, sc.currentEpoch) if err != nil { return fmt.Errorf("upgrade proposal error: %w", err) } diff --git a/go/oasis-test-runner/scenario/e2e/helpers_consensus.go b/go/oasis-test-runner/scenario/e2e/helpers_consensus.go index 9247f577689..0c10383b1c1 100644 --- a/go/oasis-test-runner/scenario/e2e/helpers_consensus.go +++ b/go/oasis-test-runner/scenario/e2e/helpers_consensus.go @@ -15,6 +15,8 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/entity" consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" + "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" + governance "github.com/oasisprotocol/oasis-core/go/governance/api" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/env" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/oasis" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/oasis/cli" @@ -220,6 +222,109 @@ func (sc *Scenario) RegisterRuntime(ctx context.Context, childEnv *env.Env, cli return nil } +// EnsureProposalFinalized submits a proposal, votes for it and ensures the +// proposal is finalized. +func (sc *Scenario) EnsureProposalFinalized(ctx context.Context, content *governance.ProposalContent, entity *oasis.Entity, entityNonce uint64, currentEpoch beacon.EpochTime) (*governance.Proposal, uint64, beacon.EpochTime, error) { + // Submit proposal. + tx := governance.NewSubmitProposalTx(entityNonce, &transaction.Fee{Gas: 2000}, content) + entityNonce++ + sigTx, err := transaction.Sign(entity.Signer(), tx) + if err != nil { + return nil, entityNonce, currentEpoch, fmt.Errorf("failed signing submit proposal transaction: %w", err) + } + sc.Logger.Info("submitting proposal", "content", content) + err = sc.Net.Controller().Consensus.SubmitTx(ctx, sigTx) + if err != nil { + return nil, entityNonce, currentEpoch, fmt.Errorf("failed submitting proposal transaction: %w", err) + } + + // Ensure proposal created. + aps, err := sc.Net.Controller().Governance.ActiveProposals(ctx, consensus.HeightLatest) + if err != nil { + return nil, entityNonce, currentEpoch, fmt.Errorf("failed querying active proposals: %w", err) + } + var proposal *governance.Proposal + for _, p := range aps { + if p.Content.Equals(content) { + proposal = p + break + } + } + if proposal == nil { + return nil, entityNonce, currentEpoch, fmt.Errorf("submitted proposal %v not found", content) + } + + // Vote for the proposal. + vote := governance.ProposalVote{ + ID: proposal.ID, + Vote: governance.VoteYes, + } + tx = governance.NewCastVoteTx(entityNonce, &transaction.Fee{Gas: 2000}, &vote) + entityNonce++ + sigTx, err = transaction.Sign(entity.Signer(), tx) + if err != nil { + return nil, entityNonce, currentEpoch, fmt.Errorf("failed signing cast vote transaction: %w", err) + } + sc.Logger.Info("submitting vote for proposal", "proposal", proposal, "vote", vote) + err = sc.Net.Controller().Consensus.SubmitTx(ctx, sigTx) + if err != nil { + return nil, entityNonce, currentEpoch, fmt.Errorf("failed submitting cast vote transaction: %w", err) + } + + // Ensure vote was cast. + votes, err := sc.Net.Controller().Governance.Votes(ctx, + &governance.ProposalQuery{ + Height: consensus.HeightLatest, + ProposalID: aps[0].ID, + }, + ) + if err != nil { + return nil, entityNonce, currentEpoch, fmt.Errorf("failed queying votes: %w", err) + } + if l := len(votes); l != 1 { + return nil, entityNonce, currentEpoch, fmt.Errorf("expected one vote, got: %v", l) + } + if vote := votes[0].Vote; vote != governance.VoteYes { + return nil, entityNonce, currentEpoch, fmt.Errorf("expected vote Yes, got: %s", string(vote)) + } + + // Transition to the epoch when proposal finalizes. + for ep := currentEpoch + 1; ep < aps[0].ClosesAt+1; ep++ { + sc.Logger.Info("transitioning to epoch", "epoch", ep) + currentEpoch++ + if err = sc.Net.Controller().SetEpoch(ctx, currentEpoch); err != nil { + // Errors can happen because an upgrade happens exactly during + // an epoch transition. So make sure to ignore them. + sc.Logger.Warn("failed to set epoch", + "epoch", currentEpoch, + "err", err, + ) + } + } + + p, err := sc.Net.Controller().Governance.Proposal(ctx, + &governance.ProposalQuery{ + Height: consensus.HeightLatest, + ProposalID: proposal.ID, + }, + ) + if err != nil { + return nil, entityNonce, currentEpoch, fmt.Errorf("failed to query proposal: %w", err) + } + sc.Logger.Info("got proposal", + "state", p.State.String(), + "results", p.Results, + "len", len(p.Results), + "invalid", p.InvalidVotes, + ) + // Ensure proposal finalized. + if p.State == governance.StateActive || p.State == governance.StateFailed { + return nil, entityNonce, currentEpoch, fmt.Errorf("expected finalized proposal, proposal state: %v", p.State) + } + + return p, entityNonce, currentEpoch, nil +} + // uniqueFilepath joins any number of path elements into a single path, checks if a file exists // at that path, and if it does, appends a unique suffix to the filename to ensure the returned // path is not already in use. diff --git a/go/oasis-test-runner/scenario/e2e/scenario.go b/go/oasis-test-runner/scenario/e2e/scenario.go index bdec0cb1622..4cad65eab53 100644 --- a/go/oasis-test-runner/scenario/e2e/scenario.go +++ b/go/oasis-test-runner/scenario/e2e/scenario.go @@ -152,6 +152,8 @@ func RegisterScenarios() error { MinTransactBalance, // Consensus governance update parameters tests. ChangeParametersMinCommissionRate, + // Consensus governance change reward schedule test. + ChangeParametersRewardSchedule, } { if err := cmd.Register(s); err != nil { return err diff --git a/go/staking/api/api.go b/go/staking/api/api.go index 9f5269a0f04..5acedcf1bb9 100644 --- a/go/staking/api/api.go +++ b/go/staking/api/api.go @@ -1224,6 +1224,9 @@ type ConsensusParameterChanges struct { // DebondingInterval is the new debonding interval. DebondingInterval *beacon.EpochTime `json:"debonding_interval,omitempty"` + // RewardSchedule is the new reward schedule. + RewardSchedule *[]RewardStep `json:"reward_schedule,omitempty"` + // GasCosts are the new gas costs. GasCosts transaction.Costs `json:"gas_costs,omitempty"` @@ -1265,6 +1268,9 @@ func (c *ConsensusParameterChanges) Apply(params *ConsensusParameters) error { if c.DebondingInterval != nil { params.DebondingInterval = *c.DebondingInterval } + if c.RewardSchedule != nil { + params.RewardSchedule = *c.RewardSchedule + } if c.GasCosts != nil { params.GasCosts = c.GasCosts } diff --git a/go/staking/api/api_test.go b/go/staking/api/api_test.go index 5746f6d1847..f85f34924ed 100644 --- a/go/staking/api/api_test.go +++ b/go/staking/api/api_test.go @@ -45,6 +45,63 @@ func TestConsensusParameters(t *testing.T) { FeeSplitWeightNextPropose: mustInitQuantity(t, 0), } require.Error(degenerateFeeSplit.SanityCheck(), "consensus parameters with degenerate fee split should be invalid") + + // Reward schedule steps. + r1 := ConsensusParameters{ + Thresholds: validThresholds, + FeeSplitWeightVote: mustInitQuantity(t, 1), + RewardSchedule: nil, + } + require.NoError(r1.SanityCheck(), "nil reward schedule should be valid") + + r2 := ConsensusParameters{ + Thresholds: validThresholds, + FeeSplitWeightVote: mustInitQuantity(t, 1), + RewardSchedule: []RewardStep{}, + } + require.NoError(r2.SanityCheck(), "empty reward schedule should be valid") + + r3 := ConsensusParameters{ + Thresholds: validThresholds, + FeeSplitWeightVote: mustInitQuantity(t, 1), + RewardSchedule: []RewardStep{ + {10, mustInitQuantity(t, 10_000)}, + {20, mustInitQuantity(t, 5_000)}, + {30, mustInitQuantity(t, 1_000)}, + }, + } + require.NoError(r3.SanityCheck(), "sequential reward schedule should be valid") + + r4 := ConsensusParameters{ + Thresholds: validThresholds, + FeeSplitWeightVote: mustInitQuantity(t, 1), + RewardSchedule: []RewardStep{ + {30, mustInitQuantity(t, 1_000)}, + {10, mustInitQuantity(t, 10_000)}, + {20, mustInitQuantity(t, 5_000)}, + }, + } + require.Error(r4.SanityCheck(), "non-sequential reward schedule should be invalid") + + r5 := ConsensusParameters{ + Thresholds: validThresholds, + FeeSplitWeightVote: mustInitQuantity(t, 1), + RewardSchedule: []RewardStep{ + {10, mustInitQuantity(t, 10_000)}, + {20, mustInitQuantity(t, 5_000)}, + {5, mustInitQuantity(t, 1_000)}, + }, + } + require.Error(r5.SanityCheck(), "another non-sequential reward schedule should be invalid") + + r6 := ConsensusParameters{ + Thresholds: validThresholds, + FeeSplitWeightVote: mustInitQuantity(t, 1), + RewardSchedule: []RewardStep{ + {api.EpochMax, mustInitQuantity(t, 1_000)}, + }, + } + require.NoError(r6.SanityCheck(), "reward schedule with only one step should be valid") } func TestThresholdKind(t *testing.T) { diff --git a/go/staking/api/sanity_check.go b/go/staking/api/sanity_check.go index f7acee13766..c60426eea33 100644 --- a/go/staking/api/sanity_check.go +++ b/go/staking/api/sanity_check.go @@ -42,12 +42,24 @@ func (p *ConsensusParameters) SanityCheck() error { return fmt.Errorf("minimum commission %v/%v over unity", p.CommissionScheduleRules, CommissionRateDenominator) } + // Reward schedule steps must be sequential. + if len(p.RewardSchedule) > 0 { + var prevUntil beacon.EpochTime + for _, step := range p.RewardSchedule { + if step.Until < prevUntil { + return fmt.Errorf("reward schedule steps must be sequential (previous is %d, current is %d)", prevUntil, step.Until) + } + prevUntil = step.Until + } + } + return nil } // SanityCheck performs a sanity check on the consensus parameter changes. func (c *ConsensusParameterChanges) SanityCheck() error { if c.DebondingInterval == nil && + c.RewardSchedule == nil && c.GasCosts == nil && c.MinDelegationAmount == nil && c.MinTransferAmount == nil &&