From 52e30a99715c68a285dcc874e8a4c75941c81fb3 Mon Sep 17 00:00:00 2001 From: "razvan.angheluta" Date: Tue, 16 Sep 2025 16:01:36 +0300 Subject: [PATCH 1/3] continuous staking implementation --- vms/components/avax/utxo_fetching.go | 21 + vms/components/avax/utxo_fetching_test.go | 51 ++ vms/platformvm/metrics/tx_metrics.go | 14 + vms/platformvm/state/diff.go | 63 ++- vms/platformvm/state/diff_test.go | 34 ++ vms/platformvm/state/metadata_validator.go | 1 + vms/platformvm/state/mock_chain.go | 42 ++ vms/platformvm/state/mock_diff.go | 42 ++ vms/platformvm/state/mock_state.go | 42 ++ vms/platformvm/state/staker.go | 151 +++++- vms/platformvm/state/staker_status.go | 16 + vms/platformvm/state/staker_test.go | 165 ++++++ vms/platformvm/state/stakers.go | 132 ++++- vms/platformvm/state/state.go | 45 +- vms/platformvm/state/state_test.go | 14 + .../txs/add_continuous_validator_tx.go | 189 +++++++ .../txs/add_continuous_validator_tx_test.go | 508 +++++++++++++++++ vms/platformvm/txs/codec.go | 4 + .../txs/executor/atomic_tx_executor.go | 8 + .../txs/executor/proposal_tx_executor.go | 272 +++++++++- .../txs/executor/reward_validator_test.go | 334 ++++++++++++ .../txs/executor/staker_tx_verification.go | 177 ++++++ .../executor/staker_tx_verification_test.go | 511 ++++++++++++++++++ .../txs/executor/standard_tx_executor.go | 90 ++- .../txs/executor/standard_tx_executor_test.go | 330 +++++++++++ vms/platformvm/txs/executor/warp_verifier.go | 8 + vms/platformvm/txs/fee/complexity.go | 61 +++ vms/platformvm/txs/staker_tx.go | 13 +- .../txs/stop_continuous_validator_tx.go | 56 ++ vms/platformvm/txs/tx.go | 2 + vms/platformvm/txs/txheap/by_end_time.go | 9 +- vms/platformvm/txs/visitor.go | 4 + wallet/chain/p/builder/builder.go | 143 +++++ wallet/chain/p/builder/with_options.go | 33 ++ wallet/chain/p/signer/visitor.go | 17 + wallet/chain/p/wallet/backend_visitor.go | 9 + wallet/chain/p/wallet/wallet.go | 59 ++ wallet/chain/p/wallet/with_options.go | 34 ++ 38 files changed, 3644 insertions(+), 60 deletions(-) create mode 100644 vms/platformvm/txs/add_continuous_validator_tx.go create mode 100644 vms/platformvm/txs/add_continuous_validator_tx_test.go create mode 100644 vms/platformvm/txs/stop_continuous_validator_tx.go diff --git a/vms/components/avax/utxo_fetching.go b/vms/components/avax/utxo_fetching.go index eb7cbee4060f..64120c29a3b9 100644 --- a/vms/components/avax/utxo_fetching.go +++ b/vms/components/avax/utxo_fetching.go @@ -5,9 +5,11 @@ package avax import ( "bytes" + "errors" "fmt" "math" + "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils" "github.com/ava-labs/avalanchego/utils/set" @@ -44,6 +46,25 @@ func GetAllUTXOs(db UTXOReader, addrs set.Set[ids.ShortID]) ([]*UTXO, error) { return utxos, err } +func GetNextOutputIndex(utxos UTXOGetter, txID ids.ID) (uint32, error) { + for i := uint32(0); i < math.MaxUint32; i++ { + utxoID := UTXOID{ + TxID: txID, + OutputIndex: i, + } + + _, err := utxos.GetUTXO(utxoID.InputID()) + switch { + case errors.Is(err, database.ErrNotFound): + return i, nil + case err != nil: + return 0, err + } + } + + panic("output index out of range") +} + // GetPaginatedUTXOs returns UTXOs such that at least one of the addresses in // [addrs] is referenced. // diff --git a/vms/components/avax/utxo_fetching_test.go b/vms/components/avax/utxo_fetching_test.go index 162e4caa7494..5c55fc1331ef 100644 --- a/vms/components/avax/utxo_fetching_test.go +++ b/vms/components/avax/utxo_fetching_test.go @@ -158,3 +158,54 @@ func TestGetPaginatedUTXOs(t *testing.T) { require.NoError(err) require.Len(notPaginatedUTXOs, len(totalUTXOs)) } + +func TestGetNextOutputIndex(t *testing.T) { + require := require.New(t) + + txID := ids.GenerateTestID() + assetID := ids.GenerateTestID() + addr := ids.GenerateTestShortID() + utxo := &UTXO{ + UTXOID: UTXOID{ + TxID: txID, + OutputIndex: 0, + }, + Asset: Asset{ID: assetID}, + Out: &secp256k1fx.TransferOutput{ + Amt: 12345, + OutputOwners: secp256k1fx.OutputOwners{ + Locktime: 54321, + Threshold: 1, + Addrs: []ids.ShortID{addr}, + }, + }, + } + + c := linearcodec.NewDefault() + manager := codec.NewDefaultManager() + + require.NoError(c.RegisterType(&secp256k1fx.TransferOutput{})) + require.NoError(manager.RegisterCodec(codecVersion, c)) + + db := memdb.New() + s, err := NewUTXOState(db, manager, trackChecksum) + require.NoError(err) + + require.NoError(s.PutUTXO(utxo)) + + utxo.UTXOID = UTXOID{ + TxID: txID, + OutputIndex: 1, + } + require.NoError(s.PutUTXO(utxo)) + + utxo.UTXOID = UTXOID{ + TxID: txID, + OutputIndex: 2, + } + require.NoError(s.PutUTXO(utxo)) + + nextOutputIndex, err := GetNextOutputIndex(s, txID) + require.NoError(err) + require.Equal(uint32(3), nextOutputIndex) +} diff --git a/vms/platformvm/metrics/tx_metrics.go b/vms/platformvm/metrics/tx_metrics.go index 560854f1d993..9916ce7318b3 100644 --- a/vms/platformvm/metrics/tx_metrics.go +++ b/vms/platformvm/metrics/tx_metrics.go @@ -173,3 +173,17 @@ func (m *txMetrics) DisableL1ValidatorTx(*txs.DisableL1ValidatorTx) error { }).Inc() return nil } + +func (m *txMetrics) AddContinuousValidatorTx(tx *txs.AddContinuousValidatorTx) error { + m.numTxs.With(prometheus.Labels{ + txLabel: "add_continuous_validator", + }).Inc() + return nil +} + +func (m *txMetrics) StopContinuousValidatorTx(tx *txs.StopContinuousValidatorTx) error { + m.numTxs.With(prometheus.Labels{ + txLabel: "stop_continuous_validator", + }).Inc() + return nil +} diff --git a/vms/platformvm/state/diff.go b/vms/platformvm/state/diff.go index 1cb5e24481ad..3e422c4058c5 100644 --- a/vms/platformvm/state/diff.go +++ b/vms/platformvm/state/diff.go @@ -28,7 +28,7 @@ var ( type Diff interface { Chain - Apply(Chain) error + Apply(Chain) error // todo: test commit with the new stuff added } type diff struct { @@ -78,6 +78,7 @@ func NewDiff( if !ok { return nil, fmt.Errorf("%w: %s", ErrMissingParentState, parentID) } + return &diff{ parentID: parentID, stateVersions: stateVersions, @@ -267,7 +268,7 @@ func (d *diff) GetCurrentValidator(subnetID ids.ID, nodeID ids.NodeID) (*Staker, // validator. newValidator, status := d.currentStakerDiffs.GetValidator(subnetID, nodeID) switch status { - case added: + case added, modified: return newValidator, nil case deleted: return nil, database.ErrNotFound @@ -310,6 +311,58 @@ func (d *diff) PutCurrentValidator(staker *Staker) error { return d.currentStakerDiffs.PutValidator(staker) } +// todo: add test for this +func (d *diff) UpdateCurrentValidator(staker *Staker) error { + parentState, ok := d.stateVersions.GetState(d.parentID) + if !ok { + return fmt.Errorf("%w: %s", ErrMissingParentState, d.parentID) + } + + return d.currentStakerDiffs.updateValidator(parentState, staker.SubnetID, staker.NodeID, func(validator Staker) (*Staker, error) { + return staker, nil + }) +} + +// todo: add test for this +func (d *diff) StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) error { + parentState, ok := d.stateVersions.GetState(d.parentID) + if !ok { + return fmt.Errorf("%w: %s", ErrMissingParentState, d.parentID) + } + + return d.currentStakerDiffs.updateValidator(parentState, subnetID, nodeID, func(validator Staker) (*Staker, error) { + if validator.ContinuationPeriod == 0 { + return nil, fmt.Errorf("validator %s is not in a continuous staker cycle", nodeID) + } + + validator.ContinuationPeriod = 0 + + return &validator, nil + }) +} + +// todo: add test for this +func (d *diff) ResetContinuousValidatorCycle( + subnetID ids.ID, + nodeID ids.NodeID, + startTime time.Time, + weight uint64, + potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64, +) error { + parentState, ok := d.stateVersions.GetState(d.parentID) + if !ok { + return fmt.Errorf("%w: %s", ErrMissingParentState, d.parentID) + } + + return d.currentStakerDiffs.updateValidator(parentState, subnetID, nodeID, func(validator Staker) (*Staker, error) { + if err := validator.resetContinuationStakerCycle(startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards); err != nil { + return nil, err + } + + return &validator, nil + }) +} + func (d *diff) DeleteCurrentValidator(staker *Staker) { d.currentStakerDiffs.DeleteValidator(staker) } @@ -601,6 +654,10 @@ func (d *diff) Apply(baseState Chain) error { } case deleted: baseState.DeleteCurrentValidator(validatorDiff.validator) + case modified: + if err := baseState.UpdateCurrentValidator(validatorDiff.validator); err != nil { + return err + } } addedDelegatorIterator := iterator.FromTree(validatorDiff.addedDelegators) @@ -630,6 +687,8 @@ func (d *diff) Apply(baseState Chain) error { } case deleted: baseState.DeletePendingValidator(validatorDiff.validator) + case modified: + return fmt.Errorf("pending stakers cannot be modified") } addedDelegatorIterator := iterator.FromTree(validatorDiff.addedDelegators) diff --git a/vms/platformvm/state/diff_test.go b/vms/platformvm/state/diff_test.go index 7951b87ebdb1..a036ae56e9e9 100644 --- a/vms/platformvm/state/diff_test.go +++ b/vms/platformvm/state/diff_test.go @@ -37,6 +37,40 @@ func TestDiffMissingState(t *testing.T) { require.ErrorIs(t, err, ErrMissingParentState) } +func TestMutatedValidatorDiffState(t *testing.T) { + require := require.New(t) + + state := newTestState(t, memdb.New()) + + // Put a current validator + currentValidator := &Staker{ + TxID: ids.GenerateTestID(), + SubnetID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + Weight: 100, + ContinuationPeriod: 100 * time.Second, + } + require.NoError(state.PutCurrentValidator(currentValidator)) + + d, err := NewDiffOn(state) + require.NoError(err) + + staker, err := d.GetCurrentValidator(currentValidator.SubnetID, currentValidator.NodeID) + require.NoError(err) + require.Equal(100*time.Second, staker.ContinuationPeriod) + + err = d.StopContinuousValidator(staker.SubnetID, staker.NodeID) + require.NoError(err) + + stakerAgain, err := d.GetCurrentValidator(currentValidator.SubnetID, currentValidator.NodeID) + require.NoError(err) + require.Equal(time.Duration(0), stakerAgain.ContinuationPeriod) + + stateStaker, err := state.GetCurrentValidator(currentValidator.SubnetID, currentValidator.NodeID) + require.NoError(err) + require.Equal(100*time.Second, stateStaker.ContinuationPeriod) +} + func TestNewDiffOn(t *testing.T) { require := require.New(t) diff --git a/vms/platformvm/state/metadata_validator.go b/vms/platformvm/state/metadata_validator.go index af9b2d4905f6..941121b0e9f9 100644 --- a/vms/platformvm/state/metadata_validator.go +++ b/vms/platformvm/state/metadata_validator.go @@ -34,6 +34,7 @@ type validatorMetadata struct { PotentialReward uint64 `v0:"true"` PotentialDelegateeReward uint64 `v0:"true"` StakerStartTime uint64 ` v1:"true"` + ContinuationPeriod uint64 `v1:"true"` txID ids.ID lastUpdated time.Time diff --git a/vms/platformvm/state/mock_chain.go b/vms/platformvm/state/mock_chain.go index 98877bd403e8..657660578653 100644 --- a/vms/platformvm/state/mock_chain.go +++ b/vms/platformvm/state/mock_chain.go @@ -610,6 +610,20 @@ func (mr *MockChainMockRecorder) PutPendingValidator(staker any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PutPendingValidator", reflect.TypeOf((*MockChain)(nil).PutPendingValidator), staker) } +// ResetContinuousValidatorCycle mocks base method. +func (m *MockChain) ResetContinuousValidatorCycle(subnetID ids.ID, nodeID ids.NodeID, startTime time.Time, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResetContinuousValidatorCycle", subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) + ret0, _ := ret[0].(error) + return ret0 +} + +// ResetContinuousValidatorCycle indicates an expected call of ResetContinuousValidatorCycle. +func (mr *MockChainMockRecorder) ResetContinuousValidatorCycle(subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetContinuousValidatorCycle", reflect.TypeOf((*MockChain)(nil).ResetContinuousValidatorCycle), subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) +} + // SetAccruedFees mocks base method. func (m *MockChain) SetAccruedFees(f uint64) { m.ctrl.T.Helper() @@ -708,6 +722,34 @@ func (mr *MockChainMockRecorder) SetTimestamp(tm any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTimestamp", reflect.TypeOf((*MockChain)(nil).SetTimestamp), tm) } +// StopContinuousValidator mocks base method. +func (m *MockChain) StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StopContinuousValidator", subnetID, nodeID) + ret0, _ := ret[0].(error) + return ret0 +} + +// StopContinuousValidator indicates an expected call of StopContinuousValidator. +func (mr *MockChainMockRecorder) StopContinuousValidator(subnetID, nodeID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopContinuousValidator", reflect.TypeOf((*MockChain)(nil).StopContinuousValidator), subnetID, nodeID) +} + +// UpdateCurrentValidator mocks base method. +func (m *MockChain) UpdateCurrentValidator(staker *Staker) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCurrentValidator", staker) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateCurrentValidator indicates an expected call of UpdateCurrentValidator. +func (mr *MockChainMockRecorder) UpdateCurrentValidator(staker any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCurrentValidator", reflect.TypeOf((*MockChain)(nil).UpdateCurrentValidator), staker) +} + // WeightOfL1Validators mocks base method. func (m *MockChain) WeightOfL1Validators(subnetID ids.ID) (uint64, error) { m.ctrl.T.Helper() diff --git a/vms/platformvm/state/mock_diff.go b/vms/platformvm/state/mock_diff.go index 9c2c5d8db8a5..d076a9c0b003 100644 --- a/vms/platformvm/state/mock_diff.go +++ b/vms/platformvm/state/mock_diff.go @@ -624,6 +624,20 @@ func (mr *MockDiffMockRecorder) PutPendingValidator(staker any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PutPendingValidator", reflect.TypeOf((*MockDiff)(nil).PutPendingValidator), staker) } +// ResetContinuousValidatorCycle mocks base method. +func (m *MockDiff) ResetContinuousValidatorCycle(subnetID ids.ID, nodeID ids.NodeID, startTime time.Time, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResetContinuousValidatorCycle", subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) + ret0, _ := ret[0].(error) + return ret0 +} + +// ResetContinuousValidatorCycle indicates an expected call of ResetContinuousValidatorCycle. +func (mr *MockDiffMockRecorder) ResetContinuousValidatorCycle(subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetContinuousValidatorCycle", reflect.TypeOf((*MockDiff)(nil).ResetContinuousValidatorCycle), subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) +} + // SetAccruedFees mocks base method. func (m *MockDiff) SetAccruedFees(f uint64) { m.ctrl.T.Helper() @@ -722,6 +736,34 @@ func (mr *MockDiffMockRecorder) SetTimestamp(tm any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTimestamp", reflect.TypeOf((*MockDiff)(nil).SetTimestamp), tm) } +// StopContinuousValidator mocks base method. +func (m *MockDiff) StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StopContinuousValidator", subnetID, nodeID) + ret0, _ := ret[0].(error) + return ret0 +} + +// StopContinuousValidator indicates an expected call of StopContinuousValidator. +func (mr *MockDiffMockRecorder) StopContinuousValidator(subnetID, nodeID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopContinuousValidator", reflect.TypeOf((*MockDiff)(nil).StopContinuousValidator), subnetID, nodeID) +} + +// UpdateCurrentValidator mocks base method. +func (m *MockDiff) UpdateCurrentValidator(staker *Staker) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCurrentValidator", staker) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateCurrentValidator indicates an expected call of UpdateCurrentValidator. +func (mr *MockDiffMockRecorder) UpdateCurrentValidator(staker any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCurrentValidator", reflect.TypeOf((*MockDiff)(nil).UpdateCurrentValidator), staker) +} + // WeightOfL1Validators mocks base method. func (m *MockDiff) WeightOfL1Validators(subnetID ids.ID) (uint64, error) { m.ctrl.T.Helper() diff --git a/vms/platformvm/state/mock_state.go b/vms/platformvm/state/mock_state.go index 1dd577d1ebdf..2c2233ec157e 100644 --- a/vms/platformvm/state/mock_state.go +++ b/vms/platformvm/state/mock_state.go @@ -876,6 +876,20 @@ func (mr *MockStateMockRecorder) ReindexBlocks(lock, log any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReindexBlocks", reflect.TypeOf((*MockState)(nil).ReindexBlocks), lock, log) } +// ResetContinuousValidatorCycle mocks base method. +func (m *MockState) ResetContinuousValidatorCycle(subnetID ids.ID, nodeID ids.NodeID, startTime time.Time, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResetContinuousValidatorCycle", subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) + ret0, _ := ret[0].(error) + return ret0 +} + +// ResetContinuousValidatorCycle indicates an expected call of ResetContinuousValidatorCycle. +func (mr *MockStateMockRecorder) ResetContinuousValidatorCycle(subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetContinuousValidatorCycle", reflect.TypeOf((*MockState)(nil).ResetContinuousValidatorCycle), subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) +} + // SetAccruedFees mocks base method. func (m *MockState) SetAccruedFees(f uint64) { m.ctrl.T.Helper() @@ -1012,6 +1026,20 @@ func (mr *MockStateMockRecorder) SetUptime(nodeID, upDuration, lastUpdated any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUptime", reflect.TypeOf((*MockState)(nil).SetUptime), nodeID, upDuration, lastUpdated) } +// StopContinuousValidator mocks base method. +func (m *MockState) StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StopContinuousValidator", subnetID, nodeID) + ret0, _ := ret[0].(error) + return ret0 +} + +// StopContinuousValidator indicates an expected call of StopContinuousValidator. +func (mr *MockStateMockRecorder) StopContinuousValidator(subnetID, nodeID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopContinuousValidator", reflect.TypeOf((*MockState)(nil).StopContinuousValidator), subnetID, nodeID) +} + // UTXOIDs mocks base method. func (m *MockState) UTXOIDs(addr []byte, previous ids.ID, limit int) ([]ids.ID, error) { m.ctrl.T.Helper() @@ -1027,6 +1055,20 @@ func (mr *MockStateMockRecorder) UTXOIDs(addr, previous, limit any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UTXOIDs", reflect.TypeOf((*MockState)(nil).UTXOIDs), addr, previous, limit) } +// UpdateCurrentValidator mocks base method. +func (m *MockState) UpdateCurrentValidator(staker *Staker) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCurrentValidator", staker) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateCurrentValidator indicates an expected call of UpdateCurrentValidator. +func (mr *MockStateMockRecorder) UpdateCurrentValidator(staker any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCurrentValidator", reflect.TypeOf((*MockState)(nil).UpdateCurrentValidator), staker) +} + // WeightOfL1Validators mocks base method. func (m *MockState) WeightOfL1Validators(subnetID ids.ID) (uint64, error) { m.ctrl.T.Helper() diff --git a/vms/platformvm/state/staker.go b/vms/platformvm/state/staker.go index a5a1fca18ce5..95760689e230 100644 --- a/vms/platformvm/state/staker.go +++ b/vms/platformvm/state/staker.go @@ -5,6 +5,7 @@ package state import ( "bytes" + "fmt" "time" "github.com/google/btree" @@ -14,20 +15,31 @@ import ( "github.com/ava-labs/avalanchego/vms/platformvm/txs" ) -var _ btree.LessFunc[*Staker] = (*Staker).Less +var ( + _ btree.LessFunc[*Staker] = (*Staker).Less + + errInvalidContinuationPeriod = fmt.Errorf("continuation period invalid transition") + errDecreasedWeight = fmt.Errorf("weight decreased") + errDecreasedAccruedRewards = fmt.Errorf("accrued rewards decreased") + errDecreasedAccruedDelegateeRewards = fmt.Errorf("accrued delegatee rewards decreased") + errImmutableFieldsModified = fmt.Errorf("immutable fields modified") + errStartTimeTooEarly = fmt.Errorf("start time too early") +) // Staker contains all information required to represent a validator or // delegator in the current and pending validator sets. // Invariant: Staker's size is bounded to prevent OOM DoS attacks. type Staker struct { - TxID ids.ID - NodeID ids.NodeID - PublicKey *bls.PublicKey - SubnetID ids.ID - Weight uint64 - StartTime time.Time - EndTime time.Time - PotentialReward uint64 + TxID ids.ID + NodeID ids.NodeID + PublicKey *bls.PublicKey + SubnetID ids.ID + Weight uint64 // it includes [AccruedRewards] and [AccruedDelegateeRewards] + StartTime time.Time + EndTime time.Time + PotentialReward uint64 + AccruedRewards uint64 + AccruedDelegateeRewards uint64 // NextTime is the next time this staker will be moved from a validator set. // If the staker is in the pending validator set, NextTime will equal @@ -41,6 +53,11 @@ type Staker struct { // [priorities.go] and depends on if the stakers are in the pending or // current validator set. Priority txs.Priority + + // ContinuationPeriod is used by continuous stakers. + // ContinuationPeriod > 0 => running continuous staker + // ContinuationPeriod == 0 => a stopped continuous staker OR a fixed staker, we don't care since we will stop at EndTime. + ContinuationPeriod time.Duration } // A *Staker is considered to be less than another *Staker when: @@ -78,18 +95,36 @@ func NewCurrentStaker( if err != nil { return nil, err } - endTime := staker.EndTime() + + var ( + endTime time.Time + continuationPeriod time.Duration + ) + + switch tTx := staker.(type) { + case txs.FixedStaker: + endTime = tTx.EndTime() + continuationPeriod = 0 + + case txs.ContinuousStaker: + endTime = startTime.Add(tTx.PeriodDuration()) + continuationPeriod = tTx.PeriodDuration() + default: + return nil, fmt.Errorf("unexpected staker tx type: %T", staker) + } + return &Staker{ - TxID: txID, - NodeID: staker.NodeID(), - PublicKey: publicKey, - SubnetID: staker.SubnetID(), - Weight: staker.Weight(), - StartTime: startTime, - EndTime: endTime, - PotentialReward: potentialReward, - NextTime: endTime, - Priority: staker.CurrentPriority(), + TxID: txID, + NodeID: staker.NodeID(), + PublicKey: publicKey, + SubnetID: staker.SubnetID(), + Weight: staker.Weight(), + StartTime: startTime, + EndTime: endTime, + PotentialReward: potentialReward, + NextTime: endTime, + Priority: staker.CurrentPriority(), + ContinuationPeriod: continuationPeriod, }, nil } @@ -99,6 +134,7 @@ func NewPendingStaker(txID ids.ID, staker txs.ScheduledStaker) (*Staker, error) return nil, err } startTime := staker.StartTime() + return &Staker{ TxID: txID, NodeID: staker.NodeID(), @@ -111,3 +147,78 @@ func NewPendingStaker(txID ids.ID, staker txs.ScheduledStaker) (*Staker, error) Priority: staker.PendingPriority(), }, nil } + +// todo: test this +func (s *Staker) ValidMutation(ms Staker) error { + if s.ContinuationPeriod != ms.ContinuationPeriod && ms.ContinuationPeriod != 0 { + // Only transition allowed for continuation period is setting it to 0. + return errInvalidContinuationPeriod + } + + if s.Weight > ms.Weight { + // Weight can only increase (by accruing rewards from continuous staking). + return errDecreasedWeight + } + + if s.AccruedRewards > ms.AccruedRewards { + // AccruedRewards can only increase. + return errDecreasedAccruedRewards + } + + if s.AccruedDelegateeRewards > ms.AccruedDelegateeRewards { + // AccruedRewards can only increase. + return errDecreasedAccruedDelegateeRewards + } + + if !ms.StartTime.Equal(s.StartTime) && s.EndTime.After(ms.StartTime) { + // New [StartTime] should be AFTER the previous [EndTime]. + return errStartTimeTooEarly + } + + if !s.immutableFieldsAreUnmodified(ms) { + return errImmutableFieldsModified + } + + return nil +} + +// todo: test this +func (s *Staker) resetContinuationStakerCycle(startTime time.Time, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { + if s.ContinuationPeriod == 0 { + return fmt.Errorf("cannot reset a non-continuous validator") + } + + if totalAccruedRewards < s.AccruedRewards { + return fmt.Errorf("accrued rewards cannot be less than current value") + } + + if totalAccruedDelegateeRewards < s.AccruedDelegateeRewards { + return fmt.Errorf("accrued delegatee rewards cannot be less than current value") + } + + if weight < s.Weight { + return fmt.Errorf("weight cannot be less than current value") + } + + endTime := startTime.Add(s.ContinuationPeriod) + + s.StartTime = startTime + s.EndTime = endTime + s.PotentialReward = potentialReward + s.AccruedRewards = totalAccruedRewards + s.AccruedDelegateeRewards = totalAccruedDelegateeRewards + s.Weight = weight + + return nil +} + +// todo: test this +func (s Staker) immutableFieldsAreUnmodified(ms Staker) bool { + // Mutable fields: Weight, StartTime, EndTime, PotentialReward, AccruedRewards, ContinuationPeriod + return s.TxID == ms.TxID && + s.NodeID == ms.NodeID && + s.PublicKey.Equals(ms.PublicKey) && + s.SubnetID == ms.SubnetID && + s.NextTime.Equal(ms.NextTime) && + s.Priority == ms.Priority +} diff --git a/vms/platformvm/state/staker_status.go b/vms/platformvm/state/staker_status.go index 56288a8ef6a3..74ef63670ee5 100644 --- a/vms/platformvm/state/staker_status.go +++ b/vms/platformvm/state/staker_status.go @@ -7,6 +7,22 @@ const ( unmodified diffValidatorStatus = iota added deleted + modified ) type diffValidatorStatus uint8 + +func (s diffValidatorStatus) String() string { + switch s { + case unmodified: + return "unmodified" + case added: + return "added" + case deleted: + return "deleted" + case modified: + return "modified" + } + + return "invalid validator status" +} diff --git a/vms/platformvm/state/staker_test.go b/vms/platformvm/state/staker_test.go index dd26de411e53..7650c223cba3 100644 --- a/vms/platformvm/state/staker_test.go +++ b/vms/platformvm/state/staker_test.go @@ -200,6 +200,171 @@ func TestNewPendingStaker(t *testing.T) { require.ErrorIs(err, errCustom) } +func TestValidMutation(t *testing.T) { + sk, err := localsigner.New() + require.NoError(t, err) + + continuousStaker := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: sk.PublicKey(), + SubnetID: ids.GenerateTestID(), + Weight: 100, + StartTime: time.Unix(10, 0), + EndTime: time.Unix(20, 0), + PotentialReward: 50, + AccruedRewards: 20, + AccruedDelegateeRewards: 15, + NextTime: time.Unix(20, 0), + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + ContinuationPeriod: 15, + } + + tests := []struct { + name string + mutateFn func(Staker) *Staker + expectedErr error + }{ + { + name: "mutated tx id", + mutateFn: func(staker Staker) *Staker { + staker.TxID = ids.GenerateTestID() + + return &staker + }, + expectedErr: errImmutableFieldsModified, + }, + { + name: "mutated node id", + mutateFn: func(staker Staker) *Staker { + staker.NodeID = ids.GenerateTestNodeID() + + return &staker + }, + expectedErr: errImmutableFieldsModified, + }, + { + name: "mutated public key", + mutateFn: func(staker Staker) *Staker { + newSig, err := localsigner.New() + require.NoError(t, err) + + staker.PublicKey = newSig.PublicKey() + return &staker + }, + expectedErr: errImmutableFieldsModified, + }, + { + name: "mutated subnet id", + mutateFn: func(staker Staker) *Staker { + staker.SubnetID = ids.GenerateTestID() + return &staker + }, + expectedErr: errImmutableFieldsModified, + }, + { + name: "mutated next time", + mutateFn: func(staker Staker) *Staker { + staker.NextTime = time.Unix(10, 0) + return &staker + }, + expectedErr: errImmutableFieldsModified, + }, + { + name: "mutated priority", + mutateFn: func(staker Staker) *Staker { + staker.Priority = txs.Priority(255) + return &staker + }, + expectedErr: errImmutableFieldsModified, + }, + { + name: "start time too early", + mutateFn: func(staker Staker) *Staker { + staker.StartTime = staker.EndTime.Add(-1 * time.Second) + return &staker + }, + expectedErr: errStartTimeTooEarly, + }, + { + name: "decreased accrued rewards", + mutateFn: func(staker Staker) *Staker { + staker.AccruedRewards -= 1 + return &staker + }, + expectedErr: errDecreasedAccruedRewards, + }, + { + name: "decreased accrued delegatee rewards", + mutateFn: func(staker Staker) *Staker { + staker.AccruedDelegateeRewards -= 1 + return &staker + }, + expectedErr: errDecreasedAccruedDelegateeRewards, + }, + { + name: "decreased weight", + mutateFn: func(staker Staker) *Staker { + staker.Weight -= 1 + return &staker + }, + expectedErr: errDecreasedWeight, + }, + { + name: "invalid continuation period (continuous staker)", + mutateFn: func(staker Staker) *Staker { + staker.ContinuationPeriod = 10 + return &staker + }, + expectedErr: errInvalidContinuationPeriod, + }, + { + name: "valid mutation", + mutateFn: func(staker Staker) *Staker { + staker.Weight = 200 + staker.StartTime = time.Unix(30, 0) + staker.EndTime = time.Unix(40, 0) + staker.PotentialReward = 20 + staker.AccruedRewards = 30 + staker.AccruedDelegateeRewards = 25 + staker.ContinuationPeriod = 0 + return &staker + }, + expectedErr: nil, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + + require.ErrorIs( + test.expectedErr, + continuousStaker.ValidMutation( + *test.mutateFn(*continuousStaker), + ), + ) + }) + } + + // Test the invalid continuation period using a fixed staker. + fixedStaker := continuousStaker + fixedStaker.ContinuationPeriod = 0 + + mutateFn := func(staker Staker) *Staker { + staker.ContinuationPeriod = 5 + return &staker + } + + t.Run("invalid continuation period (fixed staker)", func(t *testing.T) { + require := require.New(t) + + require.ErrorIs( + errInvalidContinuationPeriod, + fixedStaker.ValidMutation(*mutateFn(*fixedStaker)), + ) + }) +} + func generateStakerTx(require *require.Assertions) *txs.AddPermissionlessValidatorTx { nodeID := ids.GenerateTestNodeID() sk, err := localsigner.New() diff --git a/vms/platformvm/state/stakers.go b/vms/platformvm/state/stakers.go index 5f6f1e09271e..b43047415ab7 100644 --- a/vms/platformvm/state/stakers.go +++ b/vms/platformvm/state/stakers.go @@ -6,6 +6,7 @@ package state import ( "errors" "fmt" + "time" "github.com/google/btree" @@ -19,6 +20,7 @@ var ErrAddingStakerAfterDeletion = errors.New("attempted to add a staker after d type Stakers interface { CurrentStakers PendingStakers + ContinuousStakers } type CurrentStakers interface { @@ -39,6 +41,10 @@ type CurrentStakers interface { // Invariant: [staker] is currently a CurrentValidator DeleteCurrentValidator(staker *Staker) + // UpdateCurrentValidator updates the [staker] describing a validator to the + // staker set. Only specific mutable fields can be updated. + UpdateCurrentValidator(staker *Staker) error + // SetDelegateeReward sets the accrued delegation rewards for [nodeID] on // [subnetID] to [amount]. SetDelegateeReward(subnetID ids.ID, nodeID ids.NodeID, amount uint64) error @@ -101,6 +107,21 @@ type PendingStakers interface { GetPendingStakerIterator() (iterator.Iterator[*Staker], error) } +type ContinuousStakers interface { + // StopContinuousValidator sets the continuation period to 0. + // It is used to stop the continuous staker at the end of the cycle. + StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) error + + // ResetContinuousValidatorCycle is updating the potentialReward and startTime for the new cycle. + ResetContinuousValidatorCycle( + subnetID ids.ID, + nodeID ids.NodeID, + startTime time.Time, + weight uint64, + potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64, + ) error +} + type baseStakers struct { // subnetID --> nodeID --> current state for the validator of the subnet validators map[ids.ID]map[ids.NodeID]*baseStaker @@ -160,6 +181,40 @@ func (v *baseStakers) DeleteValidator(staker *Staker) { v.stakers.Delete(staker) } +func (v *baseStakers) UpdateValidator( + subnetID ids.ID, + nodeID ids.NodeID, + getMutatedValidator func(Staker) (*Staker, error), +) error { + validator := v.getOrCreateValidator(subnetID, nodeID) + if validator.validator == nil { + return fmt.Errorf("validator %s does not exist", nodeID) + } + + mutatedValidator, err := getMutatedValidator(*validator.validator) + if err != nil { + return err + } + + if mutatedValidator == nil { + return fmt.Errorf("mutated validator cannot be nil") + } + + if err := validator.validator.ValidMutation(*mutatedValidator); err != nil { + return err + } + + validatorDiff := v.getOrCreateValidatorDiff(subnetID, nodeID) + validatorDiff.validatorStatus = modified + validatorDiff.validator = mutatedValidator + validatorDiff.oldValidator = validator.validator + + validator.validator = mutatedValidator + + v.stakers.ReplaceOrInsert(mutatedValidator) + return nil +} + func (v *baseStakers) GetDelegatorIterator(subnetID ids.ID, nodeID ids.NodeID) iterator.Iterator[*Staker] { subnetValidators, ok := v.validators[subnetID] if !ok { @@ -269,6 +324,7 @@ type diffValidator struct { // mean that diffValidator hasn't change, since delegators may have changed. validatorStatus diffValidatorStatus validator *Staker + oldValidator *Staker // this is set iff validatorStatus is modified addedDelegators *btree.BTreeG[*Staker] deletedDelegators map[ids.ID]*Staker @@ -280,6 +336,11 @@ func (d *diffValidator) WeightDiff() (ValidatorWeightDiff, error) { } if d.validatorStatus != unmodified { weightDiff.Amount = d.validator.Weight + + if d.validatorStatus == modified { + // if the validator is modified, we need to subtract the old weight in order to get the weight diff. + weightDiff.Amount -= d.oldValidator.Weight + } } for _, staker := range d.deletedDelegators { @@ -316,10 +377,12 @@ func (s *diffStakers) GetValidator(subnetID ids.ID, nodeID ids.NodeID) (*Staker, return nil, unmodified } - if validatorDiff.validatorStatus == added { - return validatorDiff.validator, added + switch validatorDiff.validatorStatus { + case added, modified: + return validatorDiff.validator, validatorDiff.validatorStatus + default: + return nil, validatorDiff.validatorStatus } - return nil, validatorDiff.validatorStatus } func (s *diffStakers) PutValidator(staker *Staker) error { @@ -358,6 +421,69 @@ func (s *diffStakers) DeleteValidator(staker *Staker) { } } +func (s *diffStakers) updateValidator( + state Chain, + subnetID ids.ID, + nodeID ids.NodeID, + getMutatedValidator func(Staker) (*Staker, error), +) error { + validatorDiff := s.getOrCreateDiff(subnetID, nodeID) + + switch validatorDiff.validatorStatus { + case deleted: + return fmt.Errorf("validator %s updated after deletion", nodeID) + + case added, modified: + if validatorDiff.validator == nil { + // This shouldn't happen. + return fmt.Errorf("validator %s is missing for update", nodeID) + } + + mutatedValidator, err := getMutatedValidator(*validatorDiff.validator) + if err != nil { + return err + } + + if err := validatorDiff.validator.ValidMutation(*mutatedValidator); err != nil { + return err + } + + // Keep the same validatorDiff.validatorStatus. + validatorDiff.validator = mutatedValidator + + if s.addedStakers == nil { + // This shouldn't happen, since the current validator was already added/modified. + s.addedStakers = btree.NewG(defaultTreeDegree, (*Staker).Less) + } + + s.addedStakers.ReplaceOrInsert(mutatedValidator) + + case unmodified: + validator, err := state.GetCurrentValidator(subnetID, nodeID) + if err != nil { + return err + } + + mutatedValidator, err := getMutatedValidator(*validator) + if err != nil { + return err + } + + if err := validator.ValidMutation(*mutatedValidator); err != nil { + return err + } + + validatorDiff.validator = mutatedValidator + validatorDiff.validatorStatus = modified + validatorDiff.oldValidator = validator + + default: + return fmt.Errorf("unknown validator status (%s) for %s", validatorDiff.validatorStatus, nodeID) + } + + return nil +} + func (s *diffStakers) GetDelegatorIterator( parentIterator iterator.Iterator[*Staker], subnetID ids.ID, diff --git a/vms/platformvm/state/state.go b/vms/platformvm/state/state.go index e05d35fae42e..46424745c601 100644 --- a/vms/platformvm/state/state.go +++ b/vms/platformvm/state/state.go @@ -231,7 +231,7 @@ type State interface { ReindexBlocks(lock sync.Locker, log logging.Logger) error // Commit changes to the base database. - Commit() error + Commit() error // todo: test commit with the new stuff added // Returns a batch of unwritten changes that, when written, will commit all // pending changes to the base database. @@ -958,6 +958,42 @@ func (s *state) PutCurrentValidator(staker *Staker) error { return nil } +// todo: add test for this +func (s *state) UpdateCurrentValidator(staker *Staker) error { + return s.currentStakers.UpdateValidator(staker.SubnetID, staker.NodeID, func(validator Staker) (*Staker, error) { + return staker, nil + }) +} + +// todo: add test for this +func (s *state) StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) error { + return s.currentStakers.UpdateValidator(subnetID, nodeID, func(validator Staker) (*Staker, error) { + if validator.ContinuationPeriod == 0 { + return nil, fmt.Errorf("validator %s is not a continuous staker", nodeID) + } + + validator.ContinuationPeriod = 0 + return &validator, nil + }) +} + +// todo: add test for this +func (s *state) ResetContinuousValidatorCycle( + subnetID ids.ID, + nodeID ids.NodeID, + startTime time.Time, + weight uint64, + potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64, +) error { + return s.currentStakers.UpdateValidator(subnetID, nodeID, func(validator Staker) (*Staker, error) { + if err := validator.resetContinuationStakerCycle(startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards); err != nil { + return nil, err + } + + return &validator, nil + }) +} + func (s *state) DeleteCurrentValidator(staker *Staker) { s.currentStakers.DeleteValidator(staker) } @@ -2646,9 +2682,10 @@ func (s *state) writeCurrentStakers(codecVersion uint16) error { txID: staker.TxID, lastUpdated: staker.StartTime, - UpDuration: 0, - LastUpdated: startTime, - StakerStartTime: startTime, + UpDuration: 0, + LastUpdated: startTime, + StakerStartTime: startTime, + //ContinuationPeriod: uint64(staker.ContinuationPeriod.Seconds()), PotentialReward: staker.PotentialReward, PotentialDelegateeReward: 0, } diff --git a/vms/platformvm/state/state_test.go b/vms/platformvm/state/state_test.go index 3bf069a93dc6..018492dcc0e3 100644 --- a/vms/platformvm/state/state_test.go +++ b/vms/platformvm/state/state_test.go @@ -2357,3 +2357,17 @@ func TestGetCurrentValidators(t *testing.T) { }) } } + +func TestContinuousValidatorsLifecycle(t *testing.T) { + // todo: implement + //validator, err := env.state.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + //require.NoError(err) + //require.Equal(time.Duration(0), validator.ContinuationPeriod) + // + //require.NoError(diff.Apply(env.state)) + //require.NoError(env.state.Commit()) + // + //validator, err = onCommitState.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + //require.NoError(err) + //require.Equal(time.Duration(0), validator.ContinuationPeriod) +} diff --git a/vms/platformvm/txs/add_continuous_validator_tx.go b/vms/platformvm/txs/add_continuous_validator_tx.go new file mode 100644 index 000000000000..f53802a2bcaf --- /dev/null +++ b/vms/platformvm/txs/add_continuous_validator_tx.go @@ -0,0 +1,189 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "errors" + "fmt" + "time" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/math" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/components/verify" + "github.com/ava-labs/avalanchego/vms/platformvm/fx" + "github.com/ava-labs/avalanchego/vms/platformvm/reward" + "github.com/ava-labs/avalanchego/vms/platformvm/signer" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" +) + +var ( + _ ValidatorTx = (*AddContinuousValidatorTx)(nil) + _ ContinuousStaker = (*AddContinuousValidatorTx)(nil) + + errMissingSigner = errors.New("missing signer") + errMissingPeriod = errors.New("missing period") +) + +type AddContinuousValidatorTx struct { + // Metadata, inputs and outputs + BaseTx `serialize:"true"` + + // Node ID of the validator + ValidatorNodeID ids.NodeID `serialize:"true" json:"nodeID"` + + // Period (in seconds) of the staking cycle. + Period uint64 `serialize:"true" json:"period"` + + // [Signer] is the BLS key for this validator. + Signer signer.Signer `serialize:"true" json:"signer"` + + // Where to send staked tokens when done validating + StakeOuts []*avax.TransferableOutput `serialize:"true" json:"stake"` + + // Where to send validation rewards when done validating + ValidatorRewardsOwner fx.Owner `serialize:"true" json:"validationRewardsOwner"` + + // Where to send delegation rewards when done validating + DelegatorRewardsOwner fx.Owner `serialize:"true" json:"delegationRewardsOwner"` + + // Fee this validator charges delegators as a percentage, times 10,000 + // For example, if this validator has DelegationShares=300,000 then they + // take 30% of rewards from delegators + DelegationShares uint32 `serialize:"true" json:"shares"` + + // Weight of this validator used when sampling + Wght uint64 `serialize:"true" json:"weight"` +} + +func (tx *AddContinuousValidatorTx) NodeID() ids.NodeID { + return tx.ValidatorNodeID +} + +// InitCtx sets the FxID fields in the inputs and outputs of this +// [AddContinuousValidatorTx]. Also sets the [ctx] to the given [vm.ctx] so +// that the addresses can be json marshalled into human readable format +func (tx *AddContinuousValidatorTx) InitCtx(ctx *snow.Context) { + tx.BaseTx.InitCtx(ctx) + for _, out := range tx.StakeOuts { + out.FxID = secp256k1fx.ID + out.InitCtx(ctx) + } + tx.ValidatorRewardsOwner.InitCtx(ctx) + tx.DelegatorRewardsOwner.InitCtx(ctx) +} + +func (tx *AddContinuousValidatorTx) SubnetID() ids.ID { + return constants.PrimaryNetworkID +} + +func (tx *AddContinuousValidatorTx) PublicKey() (*bls.PublicKey, bool, error) { + if err := tx.Signer.Verify(); err != nil { + return nil, false, err + } + key := tx.Signer.Key() + return key, key != nil, nil +} + +func (tx *AddContinuousValidatorTx) PeriodDuration() time.Duration { + return time.Duration(tx.Period) * time.Second +} + +func (tx *AddContinuousValidatorTx) Weight() uint64 { + return tx.Wght +} + +func (tx *AddContinuousValidatorTx) PendingPriority() Priority { + return PrimaryNetworkValidatorPendingPriority +} + +func (tx *AddContinuousValidatorTx) CurrentPriority() Priority { + return PrimaryNetworkValidatorCurrentPriority +} + +func (tx *AddContinuousValidatorTx) Stake() []*avax.TransferableOutput { + return tx.StakeOuts +} + +func (tx *AddContinuousValidatorTx) ValidationRewardsOwner() fx.Owner { + return tx.ValidatorRewardsOwner +} + +func (tx *AddContinuousValidatorTx) DelegationRewardsOwner() fx.Owner { + return tx.DelegatorRewardsOwner +} + +func (tx *AddContinuousValidatorTx) Shares() uint32 { + return tx.DelegationShares +} + +// SyntacticVerify returns nil iff [tx] is valid +func (tx *AddContinuousValidatorTx) SyntacticVerify(ctx *snow.Context) error { + switch { + case tx == nil: + return ErrNilTx + case tx.SyntacticallyVerified: // already passed syntactic verification + return nil + case tx.ValidatorNodeID == ids.EmptyNodeID: + return errEmptyNodeID + case len(tx.StakeOuts) == 0: + return errNoStake + case tx.DelegationShares > reward.PercentDenominator: + return errTooManyShares + case tx.Period == 0: + return errMissingPeriod + } + + if err := tx.BaseTx.SyntacticVerify(ctx); err != nil { + return fmt.Errorf("failed to verify BaseTx: %w", err) + } + + if err := verify.All(tx.Signer, tx.ValidatorRewardsOwner, tx.DelegatorRewardsOwner); err != nil { + return fmt.Errorf("failed to verify signer, or rewards owners: %w", err) + } + + if tx.Signer.Key() == nil { + return errMissingSigner + } + + for _, out := range tx.StakeOuts { + if err := out.Verify(); err != nil { + return fmt.Errorf("failed to verify output: %w", err) + } + } + + firstStakeOutput := tx.StakeOuts[0] + stakedAssetID := firstStakeOutput.AssetID() + totalStakeWeight := firstStakeOutput.Output().Amount() + for _, out := range tx.StakeOuts[1:] { + newWeight, err := math.Add(totalStakeWeight, out.Output().Amount()) + if err != nil { + return err + } + totalStakeWeight = newWeight + + assetID := out.AssetID() + if assetID != stakedAssetID { + return fmt.Errorf("%w: %q and %q", errMultipleStakedAssets, stakedAssetID, assetID) + } + } + + switch { + case !avax.IsSortedTransferableOutputs(tx.StakeOuts, Codec): + return errOutputsNotSorted + case totalStakeWeight != tx.Wght: + return fmt.Errorf("%w: weight %d != stake %d", errValidatorWeightMismatch, tx.Wght, totalStakeWeight) + } + + // cache that this is valid + tx.SyntacticallyVerified = true + return nil +} + +func (tx *AddContinuousValidatorTx) Visit(visitor Visitor) error { + return visitor.AddContinuousValidatorTx(tx) +} diff --git a/vms/platformvm/txs/add_continuous_validator_tx_test.go b/vms/platformvm/txs/add_continuous_validator_tx_test.go new file mode 100644 index 000000000000..dffacf0e5fa9 --- /dev/null +++ b/vms/platformvm/txs/add_continuous_validator_tx_test.go @@ -0,0 +1,508 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "errors" + "math" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/utils/crypto/bls/signer/localsigner" + safemath "github.com/ava-labs/avalanchego/utils/math" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/components/avax/avaxmock" + "github.com/ava-labs/avalanchego/vms/platformvm/fx/fxmock" + "github.com/ava-labs/avalanchego/vms/platformvm/reward" + "github.com/ava-labs/avalanchego/vms/platformvm/signer" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" +) + +func TestAddContinuousValidatorTxSyntacticVerify(t *testing.T) { + require := require.New(t) + + dummyErr := errors.New("dummy error") + + type test struct { + name string + txFunc func(*gomock.Controller) *AddContinuousValidatorTx + err error + } + + var ( + networkID = uint32(1337) + chainID = ids.GenerateTestID() + ) + + ctx := &snow.Context{ + ChainID: chainID, + NetworkID: networkID, + } + + // A BaseTx that already passed syntactic verification. + verifiedBaseTx := BaseTx{ + SyntacticallyVerified: true, + } + + // A BaseTx that passes syntactic verification. + validBaseTx := BaseTx{ + BaseTx: avax.BaseTx{ + NetworkID: networkID, + BlockchainID: chainID, + }, + } + + blsSK, err := localsigner.New() + require.NoError(err) + + blsPOP, err := signer.NewProofOfPossession(blsSK) + require.NoError(err) + + // A BaseTx that fails syntactic verification. + invalidBaseTx := BaseTx{} + + tests := []test{ + { + name: "nil tx", + txFunc: func(*gomock.Controller) *AddContinuousValidatorTx { + return nil + }, + err: ErrNilTx, + }, + { + name: "already verified", + txFunc: func(*gomock.Controller) *AddContinuousValidatorTx { + return &AddContinuousValidatorTx{ + BaseTx: verifiedBaseTx, + } + }, + err: nil, + }, + { + name: "empty nodeID", + txFunc: func(*gomock.Controller) *AddContinuousValidatorTx { + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.EmptyNodeID, + } + }, + err: errEmptyNodeID, + }, + { + name: "no provided stake", + txFunc: func(*gomock.Controller) *AddContinuousValidatorTx { + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + StakeOuts: nil, + } + }, + err: errNoStake, + }, + { + name: "missing period", + txFunc: func(*gomock.Controller) *AddContinuousValidatorTx { + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + DelegationShares: reward.PercentDenominator, + } + }, + err: errMissingPeriod, + }, + { + name: "too many shares", + txFunc: func(*gomock.Controller) *AddContinuousValidatorTx { + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + DelegationShares: reward.PercentDenominator + 1, + } + }, + err: errTooManyShares, + }, + { + name: "invalid BaseTx", + txFunc: func(*gomock.Controller) *AddContinuousValidatorTx { + return &AddContinuousValidatorTx{ + BaseTx: invalidBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + DelegationShares: reward.PercentDenominator, + } + }, + err: avax.ErrWrongNetworkID, + }, + { + name: "invalid validator rewards owner", + txFunc: func(ctrl *gomock.Controller) *AddContinuousValidatorTx { + invalidRewardsOwner := fxmock.NewOwner(ctrl) + invalidRewardsOwner.EXPECT().Verify().Return(dummyErr) + + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).MaxTimes(1) + + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: &signer.Empty{}, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: invalidRewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: dummyErr, + }, + { + name: "invalid delegator rewards owner", + txFunc: func(ctrl *gomock.Controller) *AddContinuousValidatorTx { + invalidRewardsOwner := fxmock.NewOwner(ctrl) + invalidRewardsOwner.EXPECT().Verify().Return(dummyErr) + + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil) + + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: &signer.Empty{}, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: invalidRewardsOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: dummyErr, + }, + { + name: "wrong signer", + txFunc: func(ctrl *gomock.Controller) *AddContinuousValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: &signer.Empty{}, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: errMissingSigner, + }, + { + name: "invalid stake output", + txFunc: func(ctrl *gomock.Controller) *AddContinuousValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + + stakeOut := avaxmock.NewTransferableOut(ctrl) + stakeOut.EXPECT().Verify().Return(dummyErr) + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: stakeOut, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: dummyErr, + }, + { + name: "stake overflow", + txFunc: func(ctrl *gomock.Controller) *AddContinuousValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + assetID := ids.GenerateTestID() + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: assetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: math.MaxUint64, + }, + }, + { + Asset: avax.Asset{ + ID: assetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 2, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: safemath.ErrOverflow, + }, + { + name: "multiple staked assets", + txFunc: func(ctrl *gomock.Controller) *AddContinuousValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: errMultipleStakedAssets, + }, + { + name: "stake not sorted", + txFunc: func(ctrl *gomock.Controller) *AddContinuousValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + assetID := ids.GenerateTestID() + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: assetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 2, + }, + }, + { + Asset: avax.Asset{ + ID: assetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: errOutputsNotSorted, + }, + { + name: "weight mismatch", + txFunc: func(ctrl *gomock.Controller) *AddContinuousValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + assetID := ids.GenerateTestID() + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: assetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + { + Asset: avax.Asset{ + ID: assetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: errValidatorWeightMismatch, + }, + { + name: "valid continuous validator", + txFunc: func(ctrl *gomock.Controller) *AddContinuousValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + assetID := ids.GenerateTestID() + return &AddContinuousValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 2, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: assetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + { + Asset: avax.Asset{ + ID: assetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + tx := tt.txFunc(ctrl) + err := tx.SyntacticVerify(ctx) + require.ErrorIs(err, tt.err) + + if tt.err == nil { + require.Equal(tt.err == nil, tx.SyntacticallyVerified) + } + }) + } +} + +func TestAddContinuousValidatorIsValidatorTx(t *testing.T) { + require := require.New(t) + + txIntf := any((*AddContinuousValidatorTx)(nil)) + _, ok := txIntf.(ValidatorTx) + require.True(ok) +} + +func TestAddContinuousValidatorIsContinuousStaker(t *testing.T) { + require := require.New(t) + + txIntf := any((*AddContinuousValidatorTx)(nil)) + _, ok := txIntf.(ContinuousStaker) + require.True(ok) +} diff --git a/vms/platformvm/txs/codec.go b/vms/platformvm/txs/codec.go index 04700a78067d..f879ab1d884e 100644 --- a/vms/platformvm/txs/codec.go +++ b/vms/platformvm/txs/codec.go @@ -127,5 +127,9 @@ func RegisterEtnaTypes(targetCodec linearcodec.Codec) error { targetCodec.RegisterType(&SetL1ValidatorWeightTx{}), targetCodec.RegisterType(&IncreaseL1ValidatorBalanceTx{}), targetCodec.RegisterType(&DisableL1ValidatorTx{}), + + // todo: mode + targetCodec.RegisterType(&AddContinuousValidatorTx{}), + targetCodec.RegisterType(&StopContinuousValidatorTx{}), ) } diff --git a/vms/platformvm/txs/executor/atomic_tx_executor.go b/vms/platformvm/txs/executor/atomic_tx_executor.go index b1682615feb5..574731e7903b 100644 --- a/vms/platformvm/txs/executor/atomic_tx_executor.go +++ b/vms/platformvm/txs/executor/atomic_tx_executor.go @@ -128,6 +128,14 @@ func (*atomicTxExecutor) DisableL1ValidatorTx(*txs.DisableL1ValidatorTx) error { return ErrWrongTxType } +func (e *atomicTxExecutor) AddContinuousValidatorTx(tx *txs.AddContinuousValidatorTx) error { + return ErrWrongTxType +} + +func (e *atomicTxExecutor) StopContinuousValidatorTx(tx *txs.StopContinuousValidatorTx) error { + return ErrWrongTxType +} + func (e *atomicTxExecutor) ImportTx(*txs.ImportTx) error { return e.atomicTx() } diff --git a/vms/platformvm/txs/executor/proposal_tx_executor.go b/vms/platformvm/txs/executor/proposal_tx_executor.go index 79288ef296b6..bdba7ffe4537 100644 --- a/vms/platformvm/txs/executor/proposal_tx_executor.go +++ b/vms/platformvm/txs/executor/proposal_tx_executor.go @@ -147,6 +147,14 @@ func (*proposalTxExecutor) DisableL1ValidatorTx(*txs.DisableL1ValidatorTx) error return ErrWrongTxType } +func (*proposalTxExecutor) AddContinuousValidatorTx(*txs.AddContinuousValidatorTx) error { + return ErrWrongTxType +} + +func (*proposalTxExecutor) StopContinuousValidatorTx(*txs.StopContinuousValidatorTx) error { + return ErrWrongTxType +} + func (e *proposalTxExecutor) AddValidatorTx(tx *txs.AddValidatorTx) error { // AddValidatorTx is a proposal transaction until the Banff fork // activation. Following the activation, AddValidatorTxs must be issued into @@ -378,6 +386,151 @@ func (e *proposalTxExecutor) RewardValidatorTx(tx *txs.RewardValidatorTx) error // [txs.ValidatorTx] interface. switch uStakerTx := stakerTx.Unsigned.(type) { case txs.ValidatorTx: + if continuousStaker, ok := uStakerTx.(txs.ContinuousStaker); ok { + if stakerToReward.ContinuationPeriod > 0 { + // todo: rewardvalidatorTX will have the same ID everytime for same staker + // Running continuous staker + rewards, err := GetRewardsCalculator(e.backend, e.onCommitState, continuousStaker.SubnetID()) + if err != nil { + return err + } + + currentSupply, err := e.onCommitState.GetCurrentSupply(continuousStaker.SubnetID()) + if err != nil { + return err + } + + newStartTime := currentChainTime + + { + // Set onAbortState. + currentSupply, err = math.Sub(currentSupply, stakerToReward.PotentialReward) + if err != nil { + return err + } + + onAbortPotentialReward := rewards.Calculate( + continuousStaker.PeriodDuration(), + stakerToReward.Weight, + currentSupply, + ) + + newCurrentSupply, err := math.Add(currentSupply, onAbortPotentialReward) + if err != nil { + return err + } + + e.onAbortState.SetCurrentSupply(stakerToReward.SubnetID, newCurrentSupply) + err = e.onAbortState.ResetContinuousValidatorCycle( + stakerToReward.SubnetID, + stakerToReward.NodeID, + newStartTime, + stakerToReward.Weight, + onAbortPotentialReward, + stakerToReward.AccruedRewards, + stakerToReward.AccruedDelegateeRewards, + ) + if err != nil { + return err + } + } + + { + // Set onCommitState. + delegateeReward, err := e.onCommitState.GetDelegateeReward( + stakerToReward.SubnetID, + stakerToReward.NodeID, + ) + if err != nil { + return fmt.Errorf("failed to fetch accrued delegatee rewards: %w", err) + } + + newAccruedRewards, err := math.Add(stakerToReward.AccruedRewards, stakerToReward.PotentialReward) + if err != nil { + return err + } + + newWeight, err := math.Add(stakerToReward.Weight, stakerToReward.PotentialReward) + if err != nil { + return err + } + + newAccruedDelegateeRewards := stakerToReward.AccruedDelegateeRewards + if delegateeReward > 0 { + newAccruedDelegateeRewards, err = math.Add(stakerToReward.AccruedDelegateeRewards, delegateeReward) + if err != nil { + return err + } + + newWeight, err = math.Add(newWeight, delegateeReward) + if err != nil { + return err + } + } + + // todo: can potentialrewards be 0 in any situation? + if newWeight > e.backend.Config.MaxValidatorStake { + utxosOffset, err := avax.GetNextOutputIndex(e.onCommitState, stakerTx.TxID) + if err != nil { + return err + } + + excessValidationRewards, excessDelegateeRewards, err := e.rewardExcessValidatorTx( + tx, + uStakerTx, + newWeight, + delegateeReward, + stakerToReward, + utxosOffset, + ) + if err != nil { + return err + } + + newAccruedRewards, err = math.Sub(newAccruedRewards, excessValidationRewards) + if err != nil { + return err + } + + newAccruedDelegateeRewards, err = math.Sub(newAccruedDelegateeRewards, excessDelegateeRewards) + if err != nil { + return err + } + + newWeight = e.backend.Config.MaxValidatorStake + } + + onCommitPotentialReward := rewards.Calculate( + continuousStaker.PeriodDuration(), + newWeight, + currentSupply, + ) + + newCurrentSupply, err := math.Add(currentSupply, onCommitPotentialReward) + if err != nil { + return err + } + + e.onCommitState.SetCurrentSupply(stakerToReward.SubnetID, newCurrentSupply) + err = e.onCommitState.ResetContinuousValidatorCycle( + stakerToReward.SubnetID, + stakerToReward.NodeID, + newStartTime, + newWeight, + onCommitPotentialReward, + newAccruedRewards, + newAccruedDelegateeRewards, + ) + if err != nil { + return err + } + } + + // Early return because we don't need to do anything else. + return nil + } + } + if err := e.rewardValidatorTx(uStakerTx, stakerToReward); err != nil { return err } @@ -423,25 +576,39 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val stakeAsset = stake[0].Asset ) + utxosOffset := uint32(len(outputs)) + if _, ok := uValidatorTx.(txs.ContinuousStaker); ok { + outputIndex, err := avax.GetNextOutputIndex(e.onCommitState, validator.TxID) + if err != nil { + return err + } + + utxosOffset = outputIndex + } + // Refund the stake only when validator is about to leave // the staking set - for i, out := range stake { + for _, out := range stake { utxo := &avax.UTXO{ UTXOID: avax.UTXOID{ TxID: txID, - OutputIndex: uint32(len(outputs) + i), + OutputIndex: utxosOffset, }, Asset: out.Asset, Out: out.Output(), } e.onCommitState.AddUTXO(utxo) e.onAbortState.AddUTXO(utxo) - } - utxosOffset := 0 + utxosOffset++ + } - // Provide the reward here + // Provide the potential reward here + accrued rewards for continuous stakers. reward := validator.PotentialReward + if _, ok := uValidatorTx.(txs.ContinuousStaker); ok { + reward += validator.AccruedRewards + } + if reward > 0 { validationRewardsOwner := uValidatorTx.ValidationRewardsOwner() outIntf, err := e.backend.Fx.CreateOutput(reward, validationRewardsOwner) @@ -456,7 +623,7 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val utxo := &avax.UTXO{ UTXOID: avax.UTXOID{ TxID: txID, - OutputIndex: uint32(len(outputs) + len(stake)), + OutputIndex: utxosOffset, }, Asset: stakeAsset, Out: out, @@ -493,7 +660,7 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val onCommitUtxo := &avax.UTXO{ UTXOID: avax.UTXOID{ TxID: txID, - OutputIndex: uint32(len(outputs) + len(stake) + utxosOffset), + OutputIndex: utxosOffset, }, Asset: stakeAsset, Out: out, @@ -501,12 +668,14 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val e.onCommitState.AddUTXO(onCommitUtxo) e.onCommitState.AddRewardUTXO(txID, onCommitUtxo) + utxosOffset++ + // Note: There is no [offset] if the RewardValidatorTx is // aborted, because the validator reward is not awarded. onAbortUtxo := &avax.UTXO{ UTXOID: avax.UTXOID{ TxID: txID, - OutputIndex: uint32(len(outputs) + len(stake)), + OutputIndex: utxosOffset, }, Asset: stakeAsset, Out: out, @@ -648,3 +817,90 @@ func (e *proposalTxExecutor) rewardDelegatorTx(uDelegatorTx txs.DelegatorTx, del } return nil } + +// Invariants: +// 1. [newWeight] > [e.backend.Config.MaxValidatorStake] +// 2. [newWeight] > [staker.Weight] +func (e *proposalTxExecutor) rewardExcessValidatorTx( + rewardValidatorTx *txs.RewardValidatorTx, + uValidatorTx txs.ValidatorTx, + newWeight uint64, + delegateeReward uint64, + staker *state.Staker, + utxoOffset uint32, +) (uint64, uint64, error) { + // todo: think about having any of them 0 + asset := uValidatorTx.Stake()[0].Asset + + // Invariant: newWeight > staker.Weight + restakingRewards, err := math.Sub(newWeight, staker.Weight) + if err != nil { + return 0, 0, err + } + + excess, err := math.Sub(newWeight, e.backend.Config.MaxValidatorStake) + if err != nil { + return 0, 0, err + } + + excessRatio := excess / restakingRewards + + excessValidationReward, err := math.Mul(excessRatio, staker.PotentialReward) + if err != nil { + return 0, 0, err + } + + excessDelegateeReward, err := math.Mul(excessRatio, delegateeReward) + if err != nil { + return 0, 0, err + } + + // Create UTXO for [excessDelegateeReward] + if excessDelegateeReward > 0 { + outIntf, err := e.backend.Fx.CreateOutput(excessDelegateeReward, uValidatorTx.DelegationRewardsOwner()) + if err != nil { + return 0, 0, fmt.Errorf("failed to create output: %w", err) + } + + out, ok := outIntf.(verify.State) + if !ok { + return 0, 0, ErrInvalidState + } + + excessDelegateeRewardUTXO := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: rewardValidatorTx.TxID, + OutputIndex: utxoOffset, + }, + Asset: asset, + Out: out, + } + e.onCommitState.AddUTXO(excessDelegateeRewardUTXO) + e.onCommitState.AddRewardUTXO(rewardValidatorTx.TxID, excessDelegateeRewardUTXO) + } + + // Create UTXO for [excessValidationReward] + outIntf, err := e.backend.Fx.CreateOutput(excessValidationReward, uValidatorTx.ValidationRewardsOwner()) + if err != nil { + return 0, 0, fmt.Errorf("failed to create output: %w", err) + } + + out, ok := outIntf.(verify.State) + if !ok { + return 0, 0, ErrInvalidState + } + + excessValidationRewardUTXO := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: rewardValidatorTx.TxID, + OutputIndex: utxoOffset + 1, + }, + Asset: asset, + Out: out, + } + + e.onCommitState.AddUTXO(excessValidationRewardUTXO) + e.onCommitState.AddRewardUTXO(rewardValidatorTx.TxID, excessValidationRewardUTXO) + + return excessDelegateeReward, excessValidationReward, nil +} diff --git a/vms/platformvm/txs/executor/reward_validator_test.go b/vms/platformvm/txs/executor/reward_validator_test.go index eb860780605b..6dcf3c575d84 100644 --- a/vms/platformvm/txs/executor/reward_validator_test.go +++ b/vms/platformvm/txs/executor/reward_validator_test.go @@ -14,11 +14,13 @@ import ( "github.com/ava-labs/avalanchego/snow/snowtest" "github.com/ava-labs/avalanchego/upgrade/upgradetest" "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/crypto/bls/signer/localsigner" "github.com/ava-labs/avalanchego/utils/math" "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/platformvm/genesis/genesistest" "github.com/ava-labs/avalanchego/vms/platformvm/reward" + "github.com/ava-labs/avalanchego/vms/platformvm/signer" "github.com/ava-labs/avalanchego/vms/platformvm/state" "github.com/ava-labs/avalanchego/vms/platformvm/status" "github.com/ava-labs/avalanchego/vms/platformvm/txs" @@ -878,3 +880,335 @@ func TestRewardDelegatorTxExecuteOnAbort(t *testing.T) { require.NoError(err) require.Equal(initialSupply-expectedReward, newSupply, "should have removed un-rewarded tokens from the potential supply") } + +func TestRewardContinuousValidatorTxExecute(t *testing.T) { + require := require.New(t) + env := newEnvironment(t, upgradetest.ApricotPhase5) + dummyHeight := uint64(1) + + wallet := newWallet(t, env, walletConfig{}) + sk, err := localsigner.New() + require.NoError(err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + vdrStartTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + vdrNodeID := ids.GenerateTestNodeID() + vdrPotentialReward := uint64(1000000) + vdrWeight := env.config.MinValidatorStake + vdrPeriod := defaultMinStakingDuration + + rewardOwners := &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + } + + vdrTx, err := wallet.IssueAddContinuousValidatorTx( + &txs.Validator{ + NodeID: vdrNodeID, + Wght: vdrWeight, + }, + pop, + env.ctx.AVAXAssetID, + rewardOwners, + rewardOwners, + reward.PercentDenominator, + vdrPeriod, + ) + require.NoError(err) + + addContValTx := vdrTx.Unsigned.(*txs.AddContinuousValidatorTx) + + vdrStaker, err := state.NewCurrentStaker( + vdrTx.ID(), + addContValTx, + vdrStartTime, + vdrPotentialReward, + ) + require.NoError(err) + + require.Equal(vdrPeriod, vdrStaker.ContinuationPeriod) + require.Equal(vdrStartTime.Add(vdrPeriod), vdrStaker.EndTime) + + require.NoError(env.state.PutCurrentValidator(vdrStaker)) + env.state.AddTx(vdrTx, status.Committed) + + env.state.SetTimestamp(vdrStaker.EndTime) + env.state.SetHeight(dummyHeight) + require.NoError(env.state.Commit()) + + tx, err := newRewardValidatorTx(t, vdrTx.ID()) + require.NoError(err) + + onCommitState, err := state.NewDiff(lastAcceptedID, env) + require.NoError(err) + + onAbortState, err := state.NewDiff(lastAcceptedID, env) + require.NoError(err) + + feeCalculator := state.PickFeeCalculator(env.config, onCommitState) + require.NoError(ProposalTx( + &env.backend, + feeCalculator, + tx, + onCommitState, + onAbortState, + )) + + // Check onAbortState + validator, err := onAbortState.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + require.NoError(err) + + require.Equal(vdrWeight, validator.Weight) + require.Equal(uint64(0), validator.AccruedRewards) + require.Equal(nil, validator.AccruedDelegateeRewards) // todo: + require.Equal(onAbortState.GetTimestamp(), validator.StartTime) + require.Equal(onAbortState.GetTimestamp().Add(vdrPeriod), validator.EndTime) + require.Equal(vdrPeriod, validator.ContinuationPeriod) + + // Check onCommitState + validator, err = onCommitState.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + require.NoError(err) + + expectedReward := validator.PotentialReward + validator.AccruedRewards // todo: add delegators + + require.Equal(vdrWeight+vdrPotentialReward, validator.Weight) + require.Equal(vdrPotentialReward, validator.AccruedRewards) + require.Equal(nil, validator.AccruedDelegateeRewards) // todo: + require.Equal(onAbortState.GetTimestamp(), validator.StartTime) + require.Equal(onAbortState.GetTimestamp().Add(vdrPeriod), validator.EndTime) + require.Equal(vdrPeriod, validator.ContinuationPeriod) + + // Move forward with onCommitState + require.NoError(onCommitState.Apply(env.state)) + require.NoError(env.state.Commit()) + + // Stop validator + err = env.state.StopContinuousValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + require.NoError(err) + + // Check after being stopped + env.state.SetTimestamp(validator.EndTime) + + tx, err = newRewardValidatorTx(t, vdrTx.ID()) + require.NoError(err) + + onCommitState, err = state.NewDiffOn(env.state) + require.NoError(err) + + onAbortState, err = state.NewDiffOn(env.state) + require.NoError(err) + + require.NoError(ProposalTx( + &env.backend, + feeCalculator, + tx, + onCommitState, + onAbortState, + )) + + // Check onAbortState + { + validator, err = onAbortState.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + require.ErrorIs(database.ErrNotFound, err) + + outputIndex := len(addContValTx.Outputs()) + + // Check stake UTXOs + for _, stake := range addContValTx.Stake() { + stakeUTXOID := &avax.UTXOID{ + TxID: vdrTx.ID(), + OutputIndex: uint32(outputIndex), + } + + stakeUTXO, err := onAbortState.GetUTXO(stakeUTXOID.InputID()) + require.NoError(err) + require.Equal(stake.AssetID(), stakeUTXO.Asset.AssetID()) + require.Equal(stake.Output().(*secp256k1fx.TransferOutput).Amount(), stakeUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(stake.Output().(*secp256k1fx.TransferOutput).OutputOwners.Equals(&stakeUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + outputIndex++ + } + + utxoID := &avax.UTXOID{ + TxID: vdrTx.ID(), + OutputIndex: uint32(outputIndex), + } + + _, err := onAbortState.GetUTXO(utxoID.InputID()) + require.Error(database.ErrNotFound, err) + } + + // Check onCommitState + { + + validator, err = onCommitState.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + require.ErrorIs(database.ErrNotFound, err) + + outputIndex := len(addContValTx.Outputs()) + + // Check stake UTXOs + for _, stake := range addContValTx.Stake() { + stakeUTXOID := &avax.UTXOID{ + TxID: vdrTx.ID(), + OutputIndex: uint32(outputIndex), + } + + stakeUTXO, err := onCommitState.GetUTXO(stakeUTXOID.InputID()) + require.NoError(err) + require.Equal(stake.AssetID(), stakeUTXO.Asset.AssetID()) + require.Equal(stake.Output().(*secp256k1fx.TransferOutput).Amount(), stakeUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(stake.Output().(*secp256k1fx.TransferOutput).OutputOwners.Equals(&stakeUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + outputIndex++ + } + + // Check Rewards UTXOs + rewardUTXOID := &avax.UTXOID{ + TxID: vdrTx.ID(), + OutputIndex: uint32(outputIndex), + } + + rewardUTXO, err := onCommitState.GetUTXO(rewardUTXOID.InputID()) + require.NoError(err) + require.Equal(env.ctx.AVAXAssetID, rewardUTXO.Asset.AssetID()) + require.Equal(expectedReward, rewardUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(rewardOwners.Equals(&rewardUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + { + // Check Delegating Rewards UTXOs + // todo: add delegators + } + + utxoID := &avax.UTXOID{ + TxID: vdrTx.ID(), + OutputIndex: uint32(outputIndex), + } + + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.Error(database.ErrNotFound, err) + } + + // todo IMPORTANT: test if withdrawal works correct with multiple excesses and then stop + + // todo: check balance of the staker (where stakeouts are going) + // todo: add a test for a staker with previous cycles ended + + // todo: add delegators in this flow so we are 100% is correct + // todo: extract correctness of state changing based on AddContinuousValidatorTx and StopContinuousValidatorTx + // this test should only check rewards stuff +} + +func TestRewardContinuousValidatorTxMaxStakeLimit(t *testing.T) { + require := require.New(t) + env := newEnvironment(t, upgradetest.ApricotPhase5) + + wallet := newWallet(t, env, walletConfig{}) + + sk, err := localsigner.New() + require.NoError(err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + vdrPotentialReward := uint64(1_000_000) + delPotentialReward := uint64(500_000) + vdrWeight := env.config.MaxValidatorStake - 500_000 + + vdrTx, err := wallet.IssueAddContinuousValidatorTx( + &txs.Validator{ + NodeID: ids.GenerateTestNodeID(), + Wght: vdrWeight, + }, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + reward.PercentDenominator, + defaultMinStakingDuration, + ) + require.NoError(err) + + uVdrTx := vdrTx.Unsigned.(*txs.AddContinuousValidatorTx) + + vdrStaker, err := state.NewCurrentStaker( + vdrTx.ID(), + uVdrTx, + time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0), + vdrPotentialReward, + ) + require.NoError(err) + + require.NoError(env.state.PutCurrentValidator(vdrStaker)) + env.state.AddTx(vdrTx, status.Committed) + + // Add delegator + delTx, err := wallet.IssueAddPermissionlessDelegatorTx( + &txs.SubnetValidator{ + Validator: txs.Validator{ + NodeID: vdrStaker.NodeID, + Start: uint64(vdrStaker.StartTime.Unix()), + End: uint64(vdrStaker.EndTime.Unix()), + Wght: delPotentialReward, + }, + Subnet: constants.PrimaryNetworkID, + }, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + ) + require.NoError(err) + + uDelTx := delTx.Unsigned.(*txs.AddDelegatorTx) + + delegator, err := state.NewCurrentStaker( + delTx.ID(), + uDelTx, + vdrStaker.StartTime, + 1000, + ) + require.NoError(err) + + require.NoError(env.state.PutCurrentValidator(delegator)) + env.state.AddTx(vdrTx, status.Committed) + + env.state.SetTimestamp(vdrStaker.EndTime) + require.NoError(env.state.Commit()) + + tx, err := newRewardValidatorTx(t, vdrTx.ID()) + require.NoError(err) + + onCommitState, err := state.NewDiffOn(env.state) + require.NoError(err) + + onAbortState, err := state.NewDiffOn(env.state) + require.NoError(err) + + require.NoError(ProposalTx( + &env.backend, + state.PickFeeCalculator(env.config, onCommitState), + tx, + onCommitState, + onAbortState, + )) + + // Check onCommitState + require.NoError(onCommitState.Apply(env.state)) + require.NoError(env.state.Commit()) + + validator, err := env.state.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + require.NoError(err) + + require.Equal(env.config.MaxValidatorStake, validator.Weight) + require.Equal(vdrPotentialReward, validator.AccruedRewards) + require.Equal(nil, validator.AccruedDelegateeRewards) // todo: +} diff --git a/vms/platformvm/txs/executor/staker_tx_verification.go b/vms/platformvm/txs/executor/staker_tx_verification.go index bdc78d63737d..b9bace826960 100644 --- a/vms/platformvm/txs/executor/staker_tx_verification.go +++ b/vms/platformvm/txs/executor/staker_tx_verification.go @@ -12,6 +12,7 @@ import ( "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/crypto/bls" "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/platformvm/state" "github.com/ava-labs/avalanchego/vms/platformvm/txs" @@ -41,6 +42,7 @@ var ( ErrDurangoUpgradeNotActive = errors.New("attempting to use a Durango-upgrade feature prior to activation") ErrAddValidatorTxPostDurango = errors.New("AddValidatorTx is not permitted post-Durango") ErrAddDelegatorTxPostDurango = errors.New("AddDelegatorTx is not permitted post-Durango") + ErrInvalidStakerTx = errors.New("invalid staker tx") ) // verifySubnetValidatorPrimaryNetworkRequirements verifies the primary @@ -807,6 +809,181 @@ func verifyTransferSubnetOwnershipTx( return nil } +// verifyAddContinuousValidatorTx carries out the validation for an AddContinuousValidatorTx. +func verifyAddContinuousValidatorTx( + backend *Backend, + feeCalculator fee.Calculator, + chainState state.Chain, + sTx *txs.Tx, + tx *txs.AddContinuousValidatorTx, +) error { + var ( + currentTimestamp = chainState.GetTimestamp() + upgrades = backend.Config.UpgradeConfig + ) + if !upgrades.IsDurangoActivated(currentTimestamp) { // todo: replace with proper upgrade + return ErrDurangoUpgradeNotActive + } + + // Verify the tx is well-formed + if err := sTx.SyntacticVerify(backend.Ctx); err != nil { + return err + } + + if err := avax.VerifyMemoFieldLength(tx.Memo, true /*=isDurangoActive*/); err != nil { + return err + } + + if !backend.Bootstrapped.Get() { + return nil + } + + validatorRules, err := getValidatorRules(backend, chainState, tx.SubnetID()) + if err != nil { + return err + } + + duration := tx.PeriodDuration() + + stakedAssetID := tx.StakeOuts[0].AssetID() + switch { + case tx.Weight() < validatorRules.minValidatorStake: + // Ensure validator is staking at least the minimum amount + return ErrWeightTooSmall + + case tx.Weight() > validatorRules.maxValidatorStake: + // Ensure validator isn't staking too much + return ErrWeightTooLarge + + case tx.DelegationShares < validatorRules.minDelegationFee: + // Ensure the validator fee is at least the minimum amount + return ErrInsufficientDelegationFee + + case duration < validatorRules.minStakeDuration: + // Ensure staking length is not too short + return ErrStakeTooShort + + case duration > validatorRules.maxStakeDuration: + // Ensure staking length is not too long + return ErrStakeTooLong + + case stakedAssetID != validatorRules.assetID: + // Wrong assetID used + return fmt.Errorf( + "%w: %s != %s", + ErrWrongStakedAssetID, + validatorRules.assetID, + stakedAssetID, + ) + } + + _, err = GetValidator(chainState, tx.SubnetID(), tx.NodeID()) + switch { + case err == nil: + return fmt.Errorf( + "%w: %s on %s", + ErrDuplicateValidator, + tx.NodeID(), + tx.SubnetID(), + ) + case errors.Is(err, database.ErrNotFound): + // OK: validator not found + + default: + return fmt.Errorf( + "failed to check if validator %s is on subnet %s: %w", + tx.NodeID(), + tx.SubnetID(), + err, + ) + } + + outs := make([]*avax.TransferableOutput, len(tx.Outs)+len(tx.StakeOuts)) + copy(outs, tx.Outs) + copy(outs[len(tx.Outs):], tx.StakeOuts) + + // Verify the flowcheck + fee, err := feeCalculator.CalculateFee(tx) + if err != nil { + return err + } + if err := backend.FlowChecker.VerifySpend( + tx, + chainState, + tx.Ins, + outs, + sTx.Creds, + map[ids.ID]uint64{ + backend.Ctx.AVAXAssetID: fee, + }, + ); err != nil { + return fmt.Errorf("%w: %w", ErrFlowCheckFailed, err) + } + + return nil +} + +// verifyStopContinuousValidatorTx carries out the validation for an StopContinuousValidatorTx. +func verifyStopContinuousValidatorTx( + backend *Backend, + chainState state.Chain, + tx *txs.StopContinuousValidatorTx, +) (*state.Staker, error) { + var ( + currentTimestamp = chainState.GetTimestamp() + upgrades = backend.Config.UpgradeConfig + ) + + if !upgrades.IsEtnaActivated(currentTimestamp) { // todo: replace with proper upgrade + return nil, errEtnaUpgradeNotActive + } + + if err := tx.SyntacticVerify(backend.Ctx); err != nil { + return nil, err + } + + if err := avax.VerifyMemoFieldLength(tx.Memo, true /*=isDurangoActive*/); err != nil { + return nil, err + } + + stakerTx, _, err := chainState.GetTx(tx.TxID) + if err != nil { + return nil, fmt.Errorf("failed to get staker tx: %w", err) + } + + continuousStakerTx, ok := stakerTx.Unsigned.(txs.ContinuousStaker) + if !ok { + return nil, fmt.Errorf("%w: different type %T", ErrInvalidStakerTx, stakerTx.Unsigned) + } + + validator, err := chainState.GetCurrentValidator(continuousStakerTx.SubnetID(), continuousStakerTx.NodeID()) + if err != nil { + return nil, fmt.Errorf("failed to get validator %s from state: %w", continuousStakerTx.NodeID(), err) + } + + if tx.TxID != validator.TxID { + // This can happen if a validator restaked with the same public key and node id. + // In this case, TxID should be the latest transaction for the continuous validator. + return nil, fmt.Errorf("%w: wrong tx id", ErrInvalidStakerTx) + } + + // Check stop signature + signature, err := bls.SignatureFromBytes(tx.StopSignature[:]) + if err != nil { + return nil, err + } + + if !bls.VerifyProofOfPossession(validator.PublicKey, signature, validator.TxID[:]) { + return nil, errUnauthorizedModification + } + + if validator.ContinuationPeriod == 0 { + return nil, fmt.Errorf("%w: %s", errContinuousValidatorAlreadyStopped, continuousStakerTx.NodeID()) + } + + return validator, nil +} + // Ensure the proposed validator starts after the current time func verifyStakerStartTime(isDurangoActive bool, chainTime, stakerTime time.Time) error { // Pre Durango activation, start time must be after current chain time. diff --git a/vms/platformvm/txs/executor/staker_tx_verification_test.go b/vms/platformvm/txs/executor/staker_tx_verification_test.go index a66adbbb2d99..ee745855d37e 100644 --- a/vms/platformvm/txs/executor/staker_tx_verification_test.go +++ b/vms/platformvm/txs/executor/staker_tx_verification_test.go @@ -542,6 +542,517 @@ func TestVerifyAddPermissionlessValidatorTx(t *testing.T) { } } +// todo: add test for old TX id for the same validator +//func TestVerifyAddContinuousValidatorTx(t *testing.T) { +// ctx := snowtest.Context(t, snowtest.PChainID) +// +// type test struct { +// name string +// backendF func(*gomock.Controller) *Backend +// stateF func(*gomock.Controller) state.Chain +// sTxF func() *txs.Tx +// txF func() *txs.AddContinuousValidatorTx +// expectedErr error +// } +// +// var ( +// // in the following tests we set the fork time for forks we want active +// // to activeForkTime, which is ensured to be before any other time related +// // quantity (based on now) +// activeForkTime = time.Unix(0, 0) +// now = time.Now().Truncate(time.Second) // after activeForkTime +// +// subnetID = ids.GenerateTestID() +// customAssetID = ids.GenerateTestID() +// unsignedTransformTx = &txs.TransformSubnetTx{ +// AssetID: customAssetID, +// MinValidatorStake: 1, +// MaxValidatorStake: 2, +// MinStakeDuration: 3, +// MaxStakeDuration: 4, +// MinDelegationFee: 5, +// } +// transformTx = txs.Tx{ +// Unsigned: unsignedTransformTx, +// Creds: []verify.Verifiable{}, +// } +// // This tx already passed syntactic verification. +// verifiedTx = txs.AddContinuousValidatorTx{ +// BaseTx: txs.BaseTx{ +// SyntacticallyVerified: true, +// BaseTx: avax.BaseTx{ +// NetworkID: ctx.NetworkID, +// BlockchainID: ctx.ChainID, +// Outs: []*avax.TransferableOutput{}, +// Ins: []*avax.TransferableInput{}, +// }, +// }, +// ValidatorNodeID: ids.GenerateTestNodeID(), +// // Note: [Start] is not set here as it will be ignored +// // Post-Durango in favor of the current chain time +// Wght: unsignedTransformTx.MinValidatorStake, +// Period: uint64(defaultMinStakingDuration.Seconds()), +// StakeOuts: []*avax.TransferableOutput{ +// { +// Asset: avax.Asset{ +// ID: customAssetID, +// }, +// }, +// }, +// ValidatorRewardsOwner: &secp256k1fx.OutputOwners{ +// Addrs: []ids.ShortID{ids.GenerateTestShortID()}, +// Threshold: 1, +// }, +// DelegatorRewardsOwner: &secp256k1fx.OutputOwners{ +// Addrs: []ids.ShortID{ids.GenerateTestShortID()}, +// Threshold: 1, +// }, +// DelegationShares: 20_000, +// } +// verifiedSignedTx = txs.Tx{ +// Unsigned: &verifiedTx, +// Creds: []verify.Verifiable{}, +// } +// ) +// verifiedSignedTx.SetBytes([]byte{1}, []byte{2}) +// +// tests := []test{ +// { +// name: "fail syntactic verification", +// backendF: func(*gomock.Controller) *Backend { +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// } +// }, +// +// stateF: func(ctrl *gomock.Controller) state.Chain { +// mockState := state.NewMockChain(ctrl) +// mockState.EXPECT().GetTimestamp().Return(now) +// return mockState +// }, +// sTxF: func() *txs.Tx { +// return nil +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// return nil +// }, +// expectedErr: txs.ErrNilSignedTx, +// }, +// { +// name: "not bootstrapped", +// backendF: func(*gomock.Controller) *Backend { +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Bootstrapped: &utils.Atomic[bool]{}, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// mockState := state.NewMockChain(ctrl) +// mockState.EXPECT().GetTimestamp().Return(now).Times(2) // chain time is after Durango fork activation since now.After(activeForkTime) +// return mockState +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// return &txs.AddPermissionlessValidatorTx{} +// }, +// expectedErr: nil, +// }, +// { +// name: "start time too early", +// backendF: func(*gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Cortina, activeForkTime), +// }, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// state := state.NewMockChain(ctrl) +// state.EXPECT().GetTimestamp().Return(verifiedTx.StartTime()).Times(2) +// return state +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// return &verifiedTx +// }, +// expectedErr: ErrTimestampNotBeforeStartTime, +// }, +// { +// name: "weight too low", +// backendF: func(*gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// state := state.NewMockChain(ctrl) +// state.EXPECT().GetTimestamp().Return(now).Times(2) // chain time is after latest fork activation since now.After(activeForkTime) +// state.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// return state +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// tx := verifiedTx // Note that this copies [verifiedTx] +// tx.Validator.Wght = unsignedTransformTx.MinValidatorStake - 1 +// return &tx +// }, +// expectedErr: ErrWeightTooSmall, +// }, +// { +// name: "weight too high", +// backendF: func(*gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// state := state.NewMockChain(ctrl) +// state.EXPECT().GetTimestamp().Return(now).Times(2) // chain time is after latest fork activation since now.After(activeForkTime) +// state.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// return state +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// tx := verifiedTx // Note that this copies [verifiedTx] +// tx.Validator.Wght = unsignedTransformTx.MaxValidatorStake + 1 +// return &tx +// }, +// expectedErr: ErrWeightTooLarge, +// }, +// { +// name: "insufficient delegation fee", +// backendF: func(*gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// state := state.NewMockChain(ctrl) +// state.EXPECT().GetTimestamp().Return(now).Times(2) // chain time is after latest fork activation since now.After(activeForkTime) +// state.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// return state +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// tx := verifiedTx // Note that this copies [verifiedTx] +// tx.Validator.Wght = unsignedTransformTx.MaxValidatorStake +// tx.DelegationShares = unsignedTransformTx.MinDelegationFee - 1 +// return &tx +// }, +// expectedErr: ErrInsufficientDelegationFee, +// }, +// { +// name: "duration too short", +// backendF: func(*gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// state := state.NewMockChain(ctrl) +// state.EXPECT().GetTimestamp().Return(now).Times(2) // chain time is after latest fork activation since now.After(activeForkTime) +// state.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// return state +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// tx := verifiedTx // Note that this copies [verifiedTx] +// tx.Validator.Wght = unsignedTransformTx.MaxValidatorStake +// tx.DelegationShares = unsignedTransformTx.MinDelegationFee +// +// // Note the duration is 1 less than the minimum +// tx.Validator.End = tx.Validator.Start + uint64(unsignedTransformTx.MinStakeDuration) - 1 +// return &tx +// }, +// expectedErr: ErrStakeTooShort, +// }, +// { +// name: "duration too long", +// backendF: func(*gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// state := state.NewMockChain(ctrl) +// state.EXPECT().GetTimestamp().Return(time.Unix(1, 0)).Times(2) // chain time is after fork activation since time.Unix(1, 0).After(activeForkTime) +// state.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// return state +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// tx := verifiedTx // Note that this copies [verifiedTx] +// tx.Validator.Wght = unsignedTransformTx.MaxValidatorStake +// tx.DelegationShares = unsignedTransformTx.MinDelegationFee +// +// // Note the duration is more than the maximum +// tx.Validator.End = uint64(unsignedTransformTx.MaxStakeDuration) + 2 +// return &tx +// }, +// expectedErr: ErrStakeTooLong, +// }, +// { +// name: "wrong assetID", +// backendF: func(*gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// mockState := state.NewMockChain(ctrl) +// mockState.EXPECT().GetTimestamp().Return(now).Times(2) // chain time is after latest fork activation since now.After(activeForkTime) +// mockState.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// return mockState +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// tx := verifiedTx // Note that this copies [verifiedTx] +// tx.StakeOuts = []*avax.TransferableOutput{ +// { +// Asset: avax.Asset{ +// ID: ids.GenerateTestID(), +// }, +// }, +// } +// return &tx +// }, +// expectedErr: ErrWrongStakedAssetID, +// }, +// { +// name: "duplicate validator", +// backendF: func(*gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// mockState := state.NewMockChain(ctrl) +// mockState.EXPECT().GetTimestamp().Return(now).Times(2) // chain time is after latest fork activation since now.After(activeForkTime) +// mockState.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// // State says validator exists +// mockState.EXPECT().GetCurrentValidator(subnetID, verifiedTx.NodeID()).Return(nil, nil) +// return mockState +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// return &verifiedTx +// }, +// expectedErr: ErrDuplicateValidator, +// }, +// { +// name: "validator not subset of primary network validator", +// backendF: func(*gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// return &Backend{ +// Ctx: ctx, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// mockState := state.NewMockChain(ctrl) +// mockState.EXPECT().GetTimestamp().Return(now).Times(3) // chain time is after latest fork activation since now.After(activeForkTime) +// mockState.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// mockState.EXPECT().GetCurrentValidator(subnetID, verifiedTx.NodeID()).Return(nil, database.ErrNotFound) +// mockState.EXPECT().GetPendingValidator(subnetID, verifiedTx.NodeID()).Return(nil, database.ErrNotFound) +// // Validator time isn't subset of primary network validator time +// primaryNetworkVdr := &state.Staker{ +// EndTime: verifiedTx.EndTime().Add(-1 * time.Second), +// } +// mockState.EXPECT().GetCurrentValidator(constants.PrimaryNetworkID, verifiedTx.NodeID()).Return(primaryNetworkVdr, nil) +// return mockState +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// return &verifiedTx +// }, +// expectedErr: ErrPeriodMismatch, +// }, +// { +// name: "flow check fails", +// backendF: func(ctrl *gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// +// flowChecker := utxomock.NewVerifier(ctrl) +// flowChecker.EXPECT().VerifySpend( +// gomock.Any(), +// gomock.Any(), +// gomock.Any(), +// gomock.Any(), +// gomock.Any(), +// gomock.Any(), +// ).Return(ErrFlowCheckFailed) +// +// return &Backend{ +// FlowChecker: flowChecker, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Ctx: ctx, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// mockState := state.NewMockChain(ctrl) +// mockState.EXPECT().GetTimestamp().Return(now).Times(3) // chain time is after latest fork activation since now.After(activeForkTime) +// mockState.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// mockState.EXPECT().GetCurrentValidator(subnetID, verifiedTx.NodeID()).Return(nil, database.ErrNotFound) +// mockState.EXPECT().GetPendingValidator(subnetID, verifiedTx.NodeID()).Return(nil, database.ErrNotFound) +// primaryNetworkVdr := &state.Staker{ +// EndTime: mockable.MaxTime, +// } +// mockState.EXPECT().GetCurrentValidator(constants.PrimaryNetworkID, verifiedTx.NodeID()).Return(primaryNetworkVdr, nil) +// return mockState +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// return &verifiedTx +// }, +// expectedErr: ErrFlowCheckFailed, +// }, +// { +// name: "success", +// backendF: func(ctrl *gomock.Controller) *Backend { +// bootstrapped := &utils.Atomic[bool]{} +// bootstrapped.Set(true) +// +// flowChecker := utxomock.NewVerifier(ctrl) +// flowChecker.EXPECT().VerifySpend( +// gomock.Any(), +// gomock.Any(), +// gomock.Any(), +// gomock.Any(), +// gomock.Any(), +// gomock.Any(), +// ).Return(nil) +// +// return &Backend{ +// FlowChecker: flowChecker, +// Config: &config.Internal{ +// UpgradeConfig: upgradetest.GetConfigWithUpgradeTime(upgradetest.Durango, activeForkTime), +// }, +// Ctx: ctx, +// Bootstrapped: bootstrapped, +// } +// }, +// stateF: func(ctrl *gomock.Controller) state.Chain { +// mockState := state.NewMockChain(ctrl) +// mockState.EXPECT().GetTimestamp().Return(now).Times(3) // chain time is after Durango fork activation since now.After(activeForkTime) +// mockState.EXPECT().GetSubnetTransformation(subnetID).Return(&transformTx, nil) +// mockState.EXPECT().GetCurrentValidator(subnetID, verifiedTx.NodeID()).Return(nil, database.ErrNotFound) +// mockState.EXPECT().GetPendingValidator(subnetID, verifiedTx.NodeID()).Return(nil, database.ErrNotFound) +// primaryNetworkVdr := &state.Staker{ +// EndTime: mockable.MaxTime, +// } +// mockState.EXPECT().GetCurrentValidator(constants.PrimaryNetworkID, verifiedTx.NodeID()).Return(primaryNetworkVdr, nil) +// return mockState +// }, +// sTxF: func() *txs.Tx { +// return &verifiedSignedTx +// }, +// txF: func() *txs.AddPermissionlessValidatorTx { +// return &verifiedTx +// }, +// expectedErr: nil, +// }, +// } +// +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// ctrl := gomock.NewController(t) +// +// var ( +// backend = tt.backendF(ctrl) +// chain = tt.stateF(ctrl) +// sTx = tt.sTxF() +// tx = tt.txF() +// ) +// +// feeCalculator := state.PickFeeCalculator(backend.Config, chain) +// err := verifyAddPermissionlessValidatorTx(backend, feeCalculator, chain, sTx, tx) +// require.ErrorIs(t, err, tt.expectedErr) +// }) +// } +//} + func TestGetValidatorRules(t *testing.T) { type test struct { name string diff --git a/vms/platformvm/txs/executor/standard_tx_executor.go b/vms/platformvm/txs/executor/standard_tx_executor.go index 392fa0c39839..9896f71432a4 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor.go +++ b/vms/platformvm/txs/executor/standard_tx_executor.go @@ -44,22 +44,23 @@ const ( var ( _ txs.Visitor = (*standardTxExecutor)(nil) - errEmptyNodeID = errors.New("validator nodeID cannot be empty") - errMaxStakeDurationTooLarge = errors.New("max stake duration must be less than or equal to the global max stake duration") - errMissingStartTimePreDurango = errors.New("staker transactions must have a StartTime pre-Durango") - errEtnaUpgradeNotActive = errors.New("attempting to use an Etna-upgrade feature prior to activation") - errTransformSubnetTxPostEtna = errors.New("TransformSubnetTx is not permitted post-Etna") - errMaxNumActiveValidators = errors.New("already at the max number of active validators") - errCouldNotLoadSubnetToL1Conversion = errors.New("could not load subnet conversion") - errWrongWarpMessageSourceChainID = errors.New("wrong warp message source chain ID") - errWrongWarpMessageSourceAddress = errors.New("wrong warp message source address") - errWarpMessageExpired = errors.New("warp message expired") - errWarpMessageNotYetAllowed = errors.New("warp message not yet allowed") - errWarpMessageAlreadyIssued = errors.New("warp message already issued") - errCouldNotLoadL1Validator = errors.New("could not load L1 validator") - errWarpMessageContainsStaleNonce = errors.New("warp message contains stale nonce") - errRemovingLastValidator = errors.New("attempting to remove the last L1 validator from a converted subnet") - errStateCorruption = errors.New("state corruption") + errEmptyNodeID = errors.New("validator nodeID cannot be empty") + errMaxStakeDurationTooLarge = errors.New("max stake duration must be less than or equal to the global max stake duration") + errMissingStartTimePreDurango = errors.New("staker transactions must have a StartTime pre-Durango") + errEtnaUpgradeNotActive = errors.New("attempting to use an Etna-upgrade feature prior to activation") + errTransformSubnetTxPostEtna = errors.New("TransformSubnetTx is not permitted post-Etna") + errMaxNumActiveValidators = errors.New("already at the max number of active validators") + errCouldNotLoadSubnetToL1Conversion = errors.New("could not load subnet conversion") + errWrongWarpMessageSourceChainID = errors.New("wrong warp message source chain ID") + errWrongWarpMessageSourceAddress = errors.New("wrong warp message source address") + errWarpMessageExpired = errors.New("warp message expired") + errWarpMessageNotYetAllowed = errors.New("warp message not yet allowed") + errWarpMessageAlreadyIssued = errors.New("warp message already issued") + errCouldNotLoadL1Validator = errors.New("could not load L1 validator") + errWarpMessageContainsStaleNonce = errors.New("warp message contains stale nonce") + errRemovingLastValidator = errors.New("attempting to remove the last L1 validator from a converted subnet") + errStateCorruption = errors.New("state corruption") + errContinuousValidatorAlreadyStopped = errors.New("continuous validator already stopped") ) // StandardTx executes the standard transaction [tx]. @@ -1273,6 +1274,51 @@ func (e *standardTxExecutor) DisableL1ValidatorTx(tx *txs.DisableL1ValidatorTx) return e.state.PutL1Validator(l1Validator) } +func (e *standardTxExecutor) AddContinuousValidatorTx(tx *txs.AddContinuousValidatorTx) error { + if err := verifyAddContinuousValidatorTx( + e.backend, + e.feeCalculator, + e.state, + e.tx, + tx, + ); err != nil { + return err + } + + if err := e.putStaker(tx); err != nil { + return err + } + + txID := e.tx.ID() + avax.Consume(e.state, tx.Ins) + avax.Produce(e.state, txID, tx.Outs) + + if e.backend.Config.PartialSyncPrimaryNetwork && + tx.ValidatorNodeID == e.backend.Ctx.NodeID { + e.backend.Ctx.Log.Warn("verified transaction that would cause this node to become unhealthy", + zap.String("reason", "primary network is not being fully synced"), + zap.Stringer("txID", txID), + zap.String("txType", "addContinuousValidatorTx"), + zap.Stringer("nodeID", tx.ValidatorNodeID), + ) + } + + return nil +} + +func (e *standardTxExecutor) StopContinuousValidatorTx(tx *txs.StopContinuousValidatorTx) error { + validator, err := verifyStopContinuousValidatorTx(e.backend, e.state, tx) + if err != nil { + return err + } + + if err := e.state.StopContinuousValidator(validator.SubnetID, validator.NodeID); err != nil { + return err + } + + return nil +} + // Creates the staker as defined in [stakerTx] and adds it to [e.State]. func (e *standardTxExecutor) putStaker(stakerTx txs.Staker) error { var ( @@ -1310,7 +1356,17 @@ func (e *standardTxExecutor) putStaker(stakerTx txs.Staker) error { // Post-Durango, stakers are immediately added to the current staker // set. Their [StartTime] is the current chain time. - stakeDuration := stakerTx.EndTime().Sub(chainTime) + var stakeDuration time.Duration + + switch tTx := stakerTx.(type) { + case txs.FixedStaker: + stakeDuration = tTx.EndTime().Sub(chainTime) + case txs.ContinuousStaker: + stakeDuration = tTx.PeriodDuration() + default: + return fmt.Errorf("unexpected staker tx type: %T", stakerTx) + } + potentialReward = rewards.Calculate( stakeDuration, stakerTx.Weight(), diff --git a/vms/platformvm/txs/executor/standard_tx_executor_test.go b/vms/platformvm/txs/executor/standard_tx_executor_test.go index 894e7967d32c..73646d848c46 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor_test.go +++ b/vms/platformvm/txs/executor/standard_tx_executor_test.go @@ -4289,6 +4289,336 @@ func TestStandardExecutorDisableL1ValidatorTx(t *testing.T) { } } +func TestStandardExecutorAddContinuousValidatorTx(t *testing.T) { + // todo: test for invalid upgrade? + + require := require.New(t) + + var ( + env = newEnvironment(t, upgradetest.Latest) + wallet = newWallet(t, env, walletConfig{}) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + ) + + diff, err := state.NewDiffOn(env.state) + require.NoError(err) + + sk, err := localsigner.New() + require.NoError(err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + nodeID := ids.GenerateTestNodeID() + continuationPeriod := 2 * defaultMinStakingDuration + weight := 2 * defaultMinValidatorStake + + addContVdrTx, err := wallet.IssueAddContinuousValidatorTx( + &txs.Validator{ + NodeID: nodeID, + Wght: weight, + }, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + reward.PercentDenominator, + continuationPeriod, + ) + require.NoError(err) + + currentSupply, err := env.state.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(err) + + expectedPotentialReward := env.backend.Rewards.Calculate( + continuationPeriod, + weight, + currentSupply, + ) + + _, _, _, err = StandardTx( + &env.backend, + feeCalculator, + addContVdrTx, + diff, + ) + require.NoError(err) + require.True(addContVdrTx.Unsigned.(*txs.AddContinuousValidatorTx).BaseTx.SyntacticallyVerified) + require.NoError(diff.Apply(env.state)) + require.NoError(env.state.Commit()) + + validator, err := env.state.GetCurrentValidator(constants.PrimaryNetworkID, nodeID) + require.NoError(err) + + expectedValidator := &state.Staker{ + TxID: addContVdrTx.TxID, + NodeID: nodeID, + PublicKey: sk.PublicKey(), + SubnetID: constants.PrimaryNetworkID, + Weight: weight, + StartTime: diff.GetTimestamp(), + EndTime: diff.GetTimestamp().Add(continuationPeriod), + PotentialReward: expectedPotentialReward, + AccruedRewards: 0, + AccruedDelegateeRewards: 0, + NextTime: diff.GetTimestamp().Add(continuationPeriod), + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + ContinuationPeriod: continuationPeriod, + } + require.Equal(expectedValidator, validator) + + for utxoID := range addContVdrTx.InputIDs() { + _, err := diff.GetUTXO(utxoID) + require.ErrorIs(err, database.ErrNotFound) + } + + baseTxOutputUTXOs := addContVdrTx.UTXOs() + for _, expectedUTXO := range baseTxOutputUTXOs { + utxoID := expectedUTXO.InputID() + utxo, err := diff.GetUTXO(utxoID) + require.NoError(err) + require.Equal(expectedUTXO, utxo) + } +} + +func TestStandardExecutorStopContinuousValidatorTx(t *testing.T) { + nodeID := ids.GenerateTestNodeID() + sk, err := localsigner.New() + require.NoError(t, err) + + tests := []struct { + name string + modifyTx func(*require.Assertions, *txs.Tx) + updateState func(require *require.Assertions, state state.Chain) + expectedErr error + }{ + // todo: add upgradeNotActive test + { + name: "invalid tx id", + modifyTx: func(require *require.Assertions, tx *txs.Tx) { + stopContValidatorTx := tx.Unsigned.(*txs.StopContinuousValidatorTx) + stopContValidatorTx.TxID = ids.GenerateTestID() + }, + expectedErr: database.ErrNotFound, + }, + { + name: "invalid signature", + modifyTx: func(require *require.Assertions, tx *txs.Tx) { + wrongTxID := ids.GenerateTestID() + wrongSig, err := sk.SignProofOfPossession(wrongTxID[:]) + require.NoError(err) + + wrongBlsSig := [bls.SignatureLen]byte{} + copy(wrongBlsSig[:], bls.SignatureToBytes(wrongSig)) + + stopContValidatorTx := tx.Unsigned.(*txs.StopContinuousValidatorTx) + stopContValidatorTx.StopSignature = wrongBlsSig + }, + expectedErr: errUnauthorizedModification, + }, + { + name: "removed validator", + updateState: func(require *require.Assertions, state state.Chain) { + validator, err := state.GetCurrentValidator(constants.PrimaryNetworkID, nodeID) + require.NoError(err) + + state.DeleteCurrentValidator(validator) + }, + expectedErr: database.ErrNotFound, + }, + { + name: "already stopped validator", + updateState: func(require *require.Assertions, state state.Chain) { + validator, err := state.GetCurrentValidator(constants.PrimaryNetworkID, nodeID) + require.NoError(err) + + validator.ContinuationPeriod = 0 + }, + expectedErr: errContinuousValidatorAlreadyStopped, + }, + { + name: "happy path", + modifyTx: func(*require.Assertions, *txs.Tx) {}, + expectedErr: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + + env := newEnvironment(t, upgradetest.Latest) + wallet := newWallet(t, env, walletConfig{}) + feeCalculator := state.PickFeeCalculator(env.config, env.state) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + continuationPeriod := 2 * defaultMinStakingDuration + weight := 2 * defaultMinValidatorStake + + // Add continuous validator + addContVdrTx, err := wallet.IssueAddContinuousValidatorTx( + &txs.Validator{ + NodeID: nodeID, + Wght: weight, + }, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + reward.PercentDenominator, + continuationPeriod, + ) + require.NoError(err) + + diff, err := state.NewDiffOn(env.state) + require.NoError(err) + + _, _, _, err = StandardTx( + &env.backend, + feeCalculator, + addContVdrTx, + diff, + ) + require.NoError(err) + diff.AddTx(addContVdrTx, status.Committed) + require.NoError(diff.Apply(env.state)) + require.NoError(env.state.Commit()) + + validator, err := env.state.GetCurrentValidator(constants.PrimaryNetworkID, nodeID) + require.NoError(err) + + signature, err := sk.SignProofOfPossession(validator.TxID[:]) + require.NoError(err) + + blsSig := [bls.SignatureLen]byte{} + copy(blsSig[:], bls.SignatureToBytes(signature)) + + stopContVdrTx, err := wallet.IssueStopContinuousValidatorTx( + validator.TxID, + blsSig, + ) + require.NoError(err) + + if test.modifyTx != nil { + test.modifyTx(require, stopContVdrTx) + } + + diff, err = state.NewDiffOn(env.state) + require.NoError(err) + + standarxTxEx := &standardTxExecutor{ + backend: &env.backend, + feeCalculator: feeCalculator, + tx: stopContVdrTx, + state: diff, + } + + if test.updateState != nil { + test.updateState(require, standarxTxEx.state) + } + + require.ErrorIs(stopContVdrTx.Unsigned.Visit(standarxTxEx), test.expectedErr) + require.True(stopContVdrTx.Unsigned.(*txs.StopContinuousValidatorTx).BaseTx.SyntacticallyVerified) + require.NoError(diff.Apply(env.state)) + require.NoError(env.state.Commit()) + }) + } +} + +func TestStandardExecutorStopContinuousValidatorTxInvalidStaker(t *testing.T) { + env := newEnvironment(t, upgradetest.Latest) + wallet := newWallet(t, env, walletConfig{}) + feeCalculator := state.PickFeeCalculator(env.config, env.state) + + require := require.New(t) + + sk, err := localsigner.New() + require.NoError(err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + nodeID := ids.GenerateTestNodeID() + + addValTx, err := wallet.IssueAddPermissionlessValidatorTx( + &txs.SubnetValidator{ + Validator: txs.Validator{ + NodeID: nodeID, + End: uint64(genesistest.DefaultValidatorStartTime.Add(2 * defaultMinStakingDuration).Unix()), + Wght: env.config.MinValidatorStake, + }, + Subnet: constants.PrimaryNetworkID, + }, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + reward.PercentDenominator, + ) + require.NoError(err) + + diff, err := state.NewDiffOn(env.state) + require.NoError(err) + + _, _, _, err = StandardTx( + &env.backend, + feeCalculator, + addValTx, + diff, + ) + require.NoError(err) + diff.AddTx(addValTx, status.Committed) + require.NoError(diff.Apply(env.state)) + require.NoError(env.state.Commit()) + + validator, err := env.state.GetCurrentValidator(constants.PrimaryNetworkID, nodeID) + require.NoError(err) + + signature, err := sk.SignProofOfPossession(validator.TxID[:]) + require.NoError(err) + + blsSig := [bls.SignatureLen]byte{} + copy(blsSig[:], bls.SignatureToBytes(signature)) + + stopContVdrTx, err := wallet.IssueStopContinuousValidatorTx( + validator.TxID, + blsSig, + ) + require.NoError(err) + + diff, err = state.NewDiffOn(env.state) + require.NoError(err) + + _, _, _, err = StandardTx( + &env.backend, + feeCalculator, + stopContVdrTx, + diff, + ) + + require.ErrorIs(err, ErrInvalidStakerTx) +} + func must[T any](t require.TestingT) func(T, error) T { return func(val T, err error) T { require.NoError(t, err) diff --git a/vms/platformvm/txs/executor/warp_verifier.go b/vms/platformvm/txs/executor/warp_verifier.go index d3c063ee4d84..aa4d668cd148 100644 --- a/vms/platformvm/txs/executor/warp_verifier.go +++ b/vms/platformvm/txs/executor/warp_verifier.go @@ -122,6 +122,14 @@ func (w *warpVerifier) SetL1ValidatorWeightTx(tx *txs.SetL1ValidatorWeightTx) er return w.verify(tx.Message) } +func (w *warpVerifier) AddContinuousValidatorTx(tx *txs.AddContinuousValidatorTx) error { + return nil +} + +func (w *warpVerifier) StopContinuousValidatorTx(tx *txs.StopContinuousValidatorTx) error { + return nil +} + func (w *warpVerifier) verify(message []byte) error { msg, err := warp.ParseMessage(message) if err != nil { diff --git a/vms/platformvm/txs/fee/complexity.go b/vms/platformvm/txs/fee/complexity.go index 39e3db31fe2e..9cc8d8e3d966 100644 --- a/vms/platformvm/txs/fee/complexity.go +++ b/vms/platformvm/txs/fee/complexity.go @@ -223,6 +223,27 @@ var ( gas.DBWrite: 6, // write remaining balance utxo + weight diff + deactivated weight diff + public key diff + delete staker + write staker } + IntrinsicAddContinuousValidatorTxComplexities = gas.Dimensions{ + gas.Bandwidth: IntrinsicBaseTxComplexities[gas.Bandwidth] + + ids.NodeIDLen + // nodeID + wrappers.LongLen + // period + wrappers.IntLen + // signer typeID + wrappers.IntLen + // num stake outs + wrappers.IntLen + // validator rewards typeID + wrappers.IntLen + // delegator rewards typeID + wrappers.IntLen, // delegation shares + gas.DBRead: 1, // get staking config // todo @razvan: + gas.DBWrite: 3, // put current staker + write weight diff + write pk diff // todo @razvan: + } + + IntrinsicStopContinuousValidatorTxComplexities = gas.Dimensions{ + gas.Bandwidth: IntrinsicBaseTxComplexities[gas.Bandwidth] + + ids.IDLen + // txID + bls.SignatureLen, // signature + gas.DBRead: 1, // read staker // todo @razvan: + gas.DBWrite: 1, // write staker // todo @razvan: + } + errUnsupportedOutput = errors.New("unsupported output type") errUnsupportedInput = errors.New("unsupported input type") errUnsupportedOwner = errors.New("unsupported owner type") @@ -795,6 +816,46 @@ func (c *complexityVisitor) DisableL1ValidatorTx(tx *txs.DisableL1ValidatorTx) e return err } +func (c *complexityVisitor) AddContinuousValidatorTx(tx *txs.AddContinuousValidatorTx) error { + baseTxComplexity, err := baseTxComplexity(&tx.BaseTx) + if err != nil { + return err + } + signerComplexity, err := SignerComplexity(tx.Signer) + if err != nil { + return err + } + outputsComplexity, err := OutputComplexity(tx.StakeOuts...) + if err != nil { + return err + } + validatorOwnerComplexity, err := OwnerComplexity(tx.ValidatorRewardsOwner) + if err != nil { + return err + } + delegatorOwnerComplexity, err := OwnerComplexity(tx.DelegatorRewardsOwner) + if err != nil { + return err + } + c.output, err = IntrinsicAddContinuousValidatorTxComplexities.Add( + &baseTxComplexity, + &signerComplexity, + &outputsComplexity, + &validatorOwnerComplexity, + &delegatorOwnerComplexity, + ) + return err +} + +func (c *complexityVisitor) StopContinuousValidatorTx(tx *txs.StopContinuousValidatorTx) error { + baseTxComplexity, err := baseTxComplexity(&tx.BaseTx) + if err != nil { + return err + } + c.output, err = IntrinsicStopContinuousValidatorTxComplexities.Add(&baseTxComplexity) + return err +} + func baseTxComplexity(tx *txs.BaseTx) (gas.Dimensions, error) { outputsComplexity, err := OutputComplexity(tx.Outs...) if err != nil { diff --git a/vms/platformvm/txs/staker_tx.go b/vms/platformvm/txs/staker_tx.go index 0ea241828ae5..33d4dcd82f37 100644 --- a/vms/platformvm/txs/staker_tx.go +++ b/vms/platformvm/txs/staker_tx.go @@ -48,13 +48,22 @@ type Staker interface { // PublicKey returns the BLS public key registered by this transaction. If // there was no key registered by this transaction, it will return false. PublicKey() (*bls.PublicKey, bool, error) - EndTime() time.Time Weight() uint64 CurrentPriority() Priority } -type ScheduledStaker interface { +type FixedStaker interface { Staker + EndTime() time.Time +} + +type ContinuousStaker interface { + Staker + PeriodDuration() time.Duration +} + +type ScheduledStaker interface { + FixedStaker StartTime() time.Time PendingPriority() Priority } diff --git a/vms/platformvm/txs/stop_continuous_validator_tx.go b/vms/platformvm/txs/stop_continuous_validator_tx.go new file mode 100644 index 000000000000..3e1732d0ac0f --- /dev/null +++ b/vms/platformvm/txs/stop_continuous_validator_tx.go @@ -0,0 +1,56 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "bytes" + "errors" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/utils/crypto/bls" +) + +var ( + errMissingTxID = errors.New("missing tx id") + errMissingStopSignature = errors.New("missing stop signature") +) + +type StopContinuousValidatorTx struct { + // Metadata, inputs and outputs + BaseTx `serialize:"true"` + + // ID of the tx that created the continuous validator. + TxID ids.ID `serialize:"true" json:"txID"` + + // Authorizes this validator to be stopped. + // It is a BLS Proof of Possession signature using validator key of the TxID. + + StopSignature [bls.SignatureLen]byte `serialize:"true" json:"stopSignature"` +} + +func (tx *StopContinuousValidatorTx) SyntacticVerify(ctx *snow.Context) error { + switch { + case tx == nil: + return ErrNilTx + case tx.SyntacticallyVerified: + // already passed syntactic verification + return nil + case tx.TxID == ids.Empty: + return errMissingTxID + case bytes.Equal(tx.StopSignature[:], make([]byte, bls.SignatureLen)): + return errMissingStopSignature + } + + if err := tx.BaseTx.SyntacticVerify(ctx); err != nil { + return err + } + + tx.SyntacticallyVerified = true + return nil +} + +func (tx *StopContinuousValidatorTx) Visit(visitor Visitor) error { + return visitor.StopContinuousValidatorTx(tx) +} diff --git a/vms/platformvm/txs/tx.go b/vms/platformvm/txs/tx.go index 77722463eaf8..b0b753fe3b5f 100644 --- a/vms/platformvm/txs/tx.go +++ b/vms/platformvm/txs/tx.go @@ -163,6 +163,8 @@ func (tx *Tx) Sign(c codec.Manager, signers [][]*secp256k1.PrivateKey) error { tx.Creds = append(tx.Creds, cred) // Attach credential } + tx.UTXOs() + signedBytes, err := c.Marshal(CodecVersion, tx) if err != nil { return fmt.Errorf("couldn't marshal ProposalTx: %w", err) diff --git a/vms/platformvm/txs/txheap/by_end_time.go b/vms/platformvm/txs/txheap/by_end_time.go index d1e9776fad11..87108d3beec3 100644 --- a/vms/platformvm/txs/txheap/by_end_time.go +++ b/vms/platformvm/txs/txheap/by_end_time.go @@ -27,14 +27,15 @@ func NewByEndTime() TimedHeap { return &byEndTime{ txHeap: txHeap{ heap: heap.NewMap[ids.ID, *txs.Tx](func(a, b *txs.Tx) bool { - aTime := a.Unsigned.(txs.Staker).EndTime() - bTime := b.Unsigned.(txs.Staker).EndTime() - return aTime.Before(bTime) + //aTime := a.Unsigned.(txs.Staker).EndTime() + //bTime := b.Unsigned.(txs.Staker).EndTime() + return true }), }, } } func (h *byEndTime) Timestamp() time.Time { - return h.Peek().Unsigned.(txs.Staker).EndTime() + //return h.Peek().Unsigned.(txs.Staker).EndTime() + return time.Time{} } diff --git a/vms/platformvm/txs/visitor.go b/vms/platformvm/txs/visitor.go index 3a8399fbdd90..4e8dc34d6791 100644 --- a/vms/platformvm/txs/visitor.go +++ b/vms/platformvm/txs/visitor.go @@ -32,4 +32,8 @@ type Visitor interface { SetL1ValidatorWeightTx(*SetL1ValidatorWeightTx) error IncreaseL1ValidatorBalanceTx(*IncreaseL1ValidatorBalanceTx) error DisableL1ValidatorTx(*DisableL1ValidatorTx) error + + // ? Transactions: + AddContinuousValidatorTx(tx *AddContinuousValidatorTx) error + StopContinuousValidatorTx(tx *StopContinuousValidatorTx) error } diff --git a/wallet/chain/p/builder/builder.go b/wallet/chain/p/builder/builder.go index 61d38746a6c2..c0fd8f52ae3f 100644 --- a/wallet/chain/p/builder/builder.go +++ b/wallet/chain/p/builder/builder.go @@ -319,6 +319,23 @@ type Builder interface { rewardsOwner *secp256k1fx.OutputOwners, options ...common.Option, ) (*txs.AddPermissionlessDelegatorTx, error) + + NewAddContinuousValidatorTx( + vdr *txs.Validator, + signer signer.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + shares uint32, + period time.Duration, + options ...common.Option, + ) (*txs.AddContinuousValidatorTx, error) + + NewStopContinuousValidatorTx( + txID ids.ID, + signature [bls.SignatureLen]byte, + options ...common.Option, + ) (*txs.StopContinuousValidatorTx, error) } type Backend interface { @@ -1493,6 +1510,132 @@ func (b *builder) NewAddPermissionlessDelegatorTx( return tx, b.initCtx(tx) } +func (b *builder) NewAddContinuousValidatorTx( + vdr *txs.Validator, + signer signer.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + shares uint32, + period time.Duration, + options ...common.Option, +) (*txs.AddContinuousValidatorTx, error) { + toBurn := map[ids.ID]uint64{} + toStake := map[ids.ID]uint64{ + assetID: vdr.Wght, + } + + ops := common.NewOptions(options) + memo := ops.Memo() + memoComplexity := gas.Dimensions{ + gas.Bandwidth: uint64(len(memo)), + } + signerComplexity, err := fee.SignerComplexity(signer) + if err != nil { + return nil, err + } + validatorOwnerComplexity, err := fee.OwnerComplexity(validationRewardsOwner) + if err != nil { + return nil, err + } + delegatorOwnerComplexity, err := fee.OwnerComplexity(delegationRewardsOwner) + if err != nil { + return nil, err + } + complexity, err := fee.IntrinsicAddContinuousValidatorTxComplexities.Add( + &memoComplexity, + &signerComplexity, + &validatorOwnerComplexity, + &delegatorOwnerComplexity, + ) + if err != nil { + return nil, err + } + + inputs, baseOutputs, stakeOutputs, err := b.spend( + toBurn, + toStake, + 0, + complexity, + nil, + ops, + ) + if err != nil { + return nil, err + } + + utils.Sort(validationRewardsOwner.Addrs) + utils.Sort(delegationRewardsOwner.Addrs) + tx := &txs.AddContinuousValidatorTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: b.context.NetworkID, + BlockchainID: constants.PlatformChainID, + Ins: inputs, + Outs: baseOutputs, + Memo: memo, + }}, + ValidatorNodeID: vdr.NodeID, + Period: uint64(period.Seconds()), + Signer: signer, + StakeOuts: stakeOutputs, + ValidatorRewardsOwner: validationRewardsOwner, + DelegatorRewardsOwner: delegationRewardsOwner, + DelegationShares: shares, + Wght: vdr.Wght, + } + + return tx, b.initCtx(tx) +} + +func (b *builder) NewStopContinuousValidatorTx( + txID ids.ID, + signature [bls.SignatureLen]byte, + options ...common.Option, +) (*txs.StopContinuousValidatorTx, error) { + var ( + toBurn = map[ids.ID]uint64{} + toStake = map[ids.ID]uint64{} + ops = common.NewOptions(options) + ) + + memo := ops.Memo() + memoComplexity := gas.Dimensions{ + gas.Bandwidth: uint64(len(memo)), + } + + complexity, err := fee.IntrinsicStopContinuousValidatorTxComplexities.Add( + &memoComplexity, + ) + if err != nil { + return nil, err + } + + inputs, outputs, _, err := b.spend( + toBurn, + toStake, + 0, + complexity, + nil, + ops, + ) + if err != nil { + return nil, err + } + + tx := &txs.StopContinuousValidatorTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: b.context.NetworkID, + BlockchainID: constants.PlatformChainID, + Ins: inputs, + Outs: outputs, + Memo: memo, + }}, + TxID: txID, + StopSignature: signature, + } + return tx, b.initCtx(tx) +} + func (b *builder) getBalance( chainID ids.ID, options *common.Options, diff --git a/wallet/chain/p/builder/with_options.go b/wallet/chain/p/builder/with_options.go index b77618dff11a..a288e69a001b 100644 --- a/wallet/chain/p/builder/with_options.go +++ b/wallet/chain/p/builder/with_options.go @@ -310,3 +310,36 @@ func (w *withOptions) NewAddPermissionlessDelegatorTx( common.UnionOptions(w.options, options)..., ) } + +func (w *withOptions) NewAddContinuousValidatorTx( + vdr *txs.Validator, + signer signer.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + shares uint32, + period time.Duration, + options ...common.Option, +) (*txs.AddContinuousValidatorTx, error) { + return w.builder.NewAddContinuousValidatorTx( + vdr, + signer, + assetID, + validationRewardsOwner, + delegationRewardsOwner, + shares, period, + common.UnionOptions(w.options, options)..., + ) +} + +func (w *withOptions) NewStopContinuousValidatorTx( + txID ids.ID, + signature [bls.SignatureLen]byte, + options ...common.Option, +) (*txs.StopContinuousValidatorTx, error) { + return w.builder.NewStopContinuousValidatorTx( + txID, + signature, + common.UnionOptions(w.options, options)..., + ) +} diff --git a/wallet/chain/p/signer/visitor.go b/wallet/chain/p/signer/visitor.go index d2162d08e207..baa0ebdf9803 100644 --- a/wallet/chain/p/signer/visitor.go +++ b/wallet/chain/p/signer/visitor.go @@ -235,6 +235,23 @@ func (s *visitor) DisableL1ValidatorTx(tx *txs.DisableL1ValidatorTx) error { return sign(s.tx, true, txSigners) } +func (s *visitor) AddContinuousValidatorTx(tx *txs.AddContinuousValidatorTx) error { + txSigners, err := s.getSigners(constants.PlatformChainID, tx.Ins) + if err != nil { + return err + } + return sign(s.tx, true, txSigners) +} + +func (s *visitor) StopContinuousValidatorTx(tx *txs.StopContinuousValidatorTx) error { + txSigners, err := s.getSigners(constants.PlatformChainID, tx.Ins) + if err != nil { + return err + } + + return sign(s.tx, true, txSigners) +} + func (s *visitor) getSigners(sourceChainID ids.ID, ins []*avax.TransferableInput) ([][]keychain.Signer, error) { txSigners := make([][]keychain.Signer, len(ins)) for credIndex, transferInput := range ins { diff --git a/wallet/chain/p/wallet/backend_visitor.go b/wallet/chain/p/wallet/backend_visitor.go index c3bd795f685c..c516a092c366 100644 --- a/wallet/chain/p/wallet/backend_visitor.go +++ b/wallet/chain/p/wallet/backend_visitor.go @@ -59,6 +59,7 @@ func (b *backendVisitor) CreateSubnetTx(tx *txs.CreateSubnetTx) error { b.txID, tx.Owner, ) + return b.baseTx(&tx.BaseTx) } @@ -172,6 +173,14 @@ func (b *backendVisitor) DisableL1ValidatorTx(tx *txs.DisableL1ValidatorTx) erro return b.baseTx(&tx.BaseTx) } +func (b *backendVisitor) AddContinuousValidatorTx(tx *txs.AddContinuousValidatorTx) error { + return b.baseTx(&tx.BaseTx) +} + +func (b *backendVisitor) StopContinuousValidatorTx(tx *txs.StopContinuousValidatorTx) error { + return b.baseTx(&tx.BaseTx) +} + func (b *backendVisitor) baseTx(tx *txs.BaseTx) error { return b.b.removeUTXOs( b.ctx, diff --git a/wallet/chain/p/wallet/wallet.go b/wallet/chain/p/wallet/wallet.go index db04731025ac..f81dbd19f036 100644 --- a/wallet/chain/p/wallet/wallet.go +++ b/wallet/chain/p/wallet/wallet.go @@ -308,6 +308,23 @@ type Wallet interface { options ...common.Option, ) (*txs.Tx, error) + IssueAddContinuousValidatorTx( + vdr *txs.Validator, + signer vmsigner.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + shares uint32, + period time.Duration, + options ...common.Option, + ) (*txs.Tx, error) + + IssueStopContinuousValidatorTx( + txID ids.ID, + signature [bls.SignatureLen]byte, + options ...common.Option, + ) (*txs.Tx, error) + // IssueUnsignedTx signs and issues the unsigned tx. IssueUnsignedTx( utx txs.UnsignedTx, @@ -605,6 +622,48 @@ func (w *wallet) IssueAddPermissionlessDelegatorTx( return w.IssueUnsignedTx(utx, options...) } +func (w *wallet) IssueAddContinuousValidatorTx( + vdr *txs.Validator, + signer vmsigner.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + shares uint32, + period time.Duration, + options ...common.Option, +) (*txs.Tx, error) { + utx, err := w.builder.NewAddContinuousValidatorTx( + vdr, + signer, + assetID, + validationRewardsOwner, + delegationRewardsOwner, + shares, + period, + options..., + ) + if err != nil { + return nil, err + } + return w.IssueUnsignedTx(utx, options...) +} + +func (w *wallet) IssueStopContinuousValidatorTx( + txID ids.ID, + signature [bls.SignatureLen]byte, + options ...common.Option, +) (*txs.Tx, error) { + utx, err := w.builder.NewStopContinuousValidatorTx( + txID, + signature, + options..., + ) + if err != nil { + return nil, err + } + return w.IssueUnsignedTx(utx, options...) +} + func (w *wallet) IssueUnsignedTx( utx txs.UnsignedTx, options ...common.Option, diff --git a/wallet/chain/p/wallet/with_options.go b/wallet/chain/p/wallet/with_options.go index f1a80e42de1f..94a64ea8787a 100644 --- a/wallet/chain/p/wallet/with_options.go +++ b/wallet/chain/p/wallet/with_options.go @@ -300,6 +300,40 @@ func (w *withOptions) IssueAddPermissionlessDelegatorTx( ) } +func (w *withOptions) IssueAddContinuousValidatorTx( + vdr *txs.Validator, + signer vmsigner.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + shares uint32, + period time.Duration, + options ...common.Option, +) (*txs.Tx, error) { + return w.wallet.IssueAddContinuousValidatorTx( + vdr, + signer, + assetID, + validationRewardsOwner, + delegationRewardsOwner, + shares, + period, + common.UnionOptions(w.options, options)..., + ) +} + +func (w *withOptions) IssueStopContinuousValidatorTx( + txID ids.ID, + signature [bls.SignatureLen]byte, + options ...common.Option, +) (*txs.Tx, error) { + return w.wallet.IssueStopContinuousValidatorTx( + txID, + signature, + common.UnionOptions(w.options, options)..., + ) +} + func (w *withOptions) IssueUnsignedTx( utx txs.UnsignedTx, options ...common.Option, From 796209809a023d161d5dd879ec51440f5b231abf Mon Sep 17 00:00:00 2001 From: "razvan.angheluta" Date: Fri, 26 Sep 2025 12:12:22 +0300 Subject: [PATCH 2/3] Implement RewardContinuousValidatorTx --- vms/components/avax/utxo_fetching.go | 38 +- vms/components/avax/utxo_fetching_test.go | 41 +- vms/platformvm/block/builder/builder.go | 27 +- vms/platformvm/metrics/tx_metrics.go | 7 + vms/platformvm/txs/codec.go | 3 +- .../txs/executor/atomic_tx_executor.go | 4 + .../txs/executor/atomic_tx_executor_test.go | 60 ++ .../txs/executor/proposal_tx_executor.go | 610 ++++++++++++------ .../txs/executor/reward_validator_test.go | 453 +++++++++++-- .../txs/executor/standard_tx_executor.go | 4 + .../txs/executor/standard_tx_executor_test.go | 25 +- vms/platformvm/txs/executor/warp_verifier.go | 4 + vms/platformvm/txs/fee/complexity.go | 4 + .../txs/reward_continuous_validator_tx.go | 67 ++ .../reward_continuous_validator_tx_test.go | 86 +++ .../txs/stop_continuous_validator_tx_test.go | 6 + vms/platformvm/txs/tx.go | 2 - vms/platformvm/txs/visitor.go | 1 + wallet/chain/p/signer/visitor.go | 4 + wallet/chain/p/wallet/backend_visitor.go | 4 + 20 files changed, 1151 insertions(+), 299 deletions(-) create mode 100644 vms/platformvm/txs/executor/atomic_tx_executor_test.go create mode 100644 vms/platformvm/txs/reward_continuous_validator_tx.go create mode 100644 vms/platformvm/txs/reward_continuous_validator_tx_test.go create mode 100644 vms/platformvm/txs/stop_continuous_validator_tx_test.go diff --git a/vms/components/avax/utxo_fetching.go b/vms/components/avax/utxo_fetching.go index 64120c29a3b9..85842a4170cd 100644 --- a/vms/components/avax/utxo_fetching.go +++ b/vms/components/avax/utxo_fetching.go @@ -5,11 +5,9 @@ package avax import ( "bytes" - "errors" "fmt" "math" - "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils" "github.com/ava-labs/avalanchego/utils/set" @@ -46,24 +44,24 @@ func GetAllUTXOs(db UTXOReader, addrs set.Set[ids.ShortID]) ([]*UTXO, error) { return utxos, err } -func GetNextOutputIndex(utxos UTXOGetter, txID ids.ID) (uint32, error) { - for i := uint32(0); i < math.MaxUint32; i++ { - utxoID := UTXOID{ - TxID: txID, - OutputIndex: i, - } - - _, err := utxos.GetUTXO(utxoID.InputID()) - switch { - case errors.Is(err, database.ErrNotFound): - return i, nil - case err != nil: - return 0, err - } - } - - panic("output index out of range") -} +//func GetNextOutputIndex(utxos UTXOGetter, txID ids.ID) (uint32, error) { +// for i := uint32(0); i < math.MaxUint32; i++ { +// utxoID := UTXOID{ +// TxID: txID, +// OutputIndex: i, +// } +// +// _, err := utxos.GetUTXO(utxoID.InputID()) +// switch { +// case errors.Is(err, database.ErrNotFound): +// return i, nil +// case err != nil: +// return 0, err +// } +// } +// +// panic("output index out of range") +//} // GetPaginatedUTXOs returns UTXOs such that at least one of the addresses in // [addrs] is referenced. diff --git a/vms/components/avax/utxo_fetching_test.go b/vms/components/avax/utxo_fetching_test.go index 5c55fc1331ef..c8aa7eb93af2 100644 --- a/vms/components/avax/utxo_fetching_test.go +++ b/vms/components/avax/utxo_fetching_test.go @@ -162,35 +162,34 @@ func TestGetPaginatedUTXOs(t *testing.T) { func TestGetNextOutputIndex(t *testing.T) { require := require.New(t) + c := linearcodec.NewDefault() + manager := codec.NewDefaultManager() + + require.NoError(c.RegisterType(&secp256k1fx.TransferOutput{})) + require.NoError(manager.RegisterCodec(codecVersion, c)) + + db := memdb.New() + s, err := NewUTXOState(db, manager, trackChecksum) + require.NoError(err) + txID := ids.GenerateTestID() - assetID := ids.GenerateTestID() - addr := ids.GenerateTestShortID() + utxo := &UTXO{ - UTXOID: UTXOID{ - TxID: txID, - OutputIndex: 0, - }, - Asset: Asset{ID: assetID}, + Asset: Asset{ID: ids.GenerateTestID()}, Out: &secp256k1fx.TransferOutput{ Amt: 12345, OutputOwners: secp256k1fx.OutputOwners{ Locktime: 54321, Threshold: 1, - Addrs: []ids.ShortID{addr}, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, }, }, } - c := linearcodec.NewDefault() - manager := codec.NewDefaultManager() - - require.NoError(c.RegisterType(&secp256k1fx.TransferOutput{})) - require.NoError(manager.RegisterCodec(codecVersion, c)) - - db := memdb.New() - s, err := NewUTXOState(db, manager, trackChecksum) - require.NoError(err) - + utxo.UTXOID = UTXOID{ + TxID: txID, + OutputIndex: 0, + } require.NoError(s.PutUTXO(utxo)) utxo.UTXOID = UTXOID{ @@ -208,4 +207,10 @@ func TestGetNextOutputIndex(t *testing.T) { nextOutputIndex, err := GetNextOutputIndex(s, txID) require.NoError(err) require.Equal(uint32(3), nextOutputIndex) + + require.NoError(s.DeleteUTXO(utxo.InputID())) + + nextOutputIndex, err = GetNextOutputIndex(s, txID) + require.NoError(err) + require.Equal(uint32(2), nextOutputIndex) } diff --git a/vms/platformvm/block/builder/builder.go b/vms/platformvm/block/builder/builder.go index 09a9d5020441..e999d062bf8c 100644 --- a/vms/platformvm/block/builder/builder.go +++ b/vms/platformvm/block/builder/builder.go @@ -315,9 +315,23 @@ func buildBlock( return nil, fmt.Errorf("could not find next staker to reward: %w", err) } if shouldReward { - rewardValidatorTx, err := NewRewardValidatorTx(builder.txExecutorBackend.Ctx, stakerTxID) + var rewardValidatorTx *txs.Tx + + stakerTx, _, err := parentState.GetTx(stakerTxID) if err != nil { - return nil, fmt.Errorf("could not build tx to reward staker: %w", err) + return nil, err + } + + if _, ok := stakerTx.Unsigned.(txs.ContinuousStaker); ok { + rewardValidatorTx, err = NewRewardContinuousValidatorTx(builder.txExecutorBackend.Ctx, stakerTxID, uint64(timestamp.Unix())) + if err != nil { + return nil, fmt.Errorf("could not build tx to reward staker: %w", err) + } + } else { + rewardValidatorTx, err = NewRewardValidatorTx(builder.txExecutorBackend.Ctx, stakerTxID) + if err != nil { + return nil, fmt.Errorf("could not build tx to reward staker: %w", err) + } } return block.NewBanffProposalBlock( @@ -633,3 +647,12 @@ func NewRewardValidatorTx(ctx *snow.Context, txID ids.ID) (*txs.Tx, error) { } return tx, tx.SyntacticVerify(ctx) } + +func NewRewardContinuousValidatorTx(ctx *snow.Context, txID ids.ID, timestamp uint64) (*txs.Tx, error) { + utx := &txs.RewardContinuousValidatorTx{TxID: txID, Timestamp: timestamp} + tx, err := txs.NewSigned(utx, txs.Codec, nil) + if err != nil { + return nil, err + } + return tx, tx.SyntacticVerify(ctx) +} diff --git a/vms/platformvm/metrics/tx_metrics.go b/vms/platformvm/metrics/tx_metrics.go index 9916ce7318b3..566f9c88cc21 100644 --- a/vms/platformvm/metrics/tx_metrics.go +++ b/vms/platformvm/metrics/tx_metrics.go @@ -97,6 +97,13 @@ func (m *txMetrics) RewardValidatorTx(*txs.RewardValidatorTx) error { return nil } +func (m *txMetrics) RewardContinuousValidatorTx(tx *txs.RewardContinuousValidatorTx) error { + m.numTxs.With(prometheus.Labels{ + txLabel: "reward_continuous_validator", + }).Inc() + return nil +} + func (m *txMetrics) RemoveSubnetValidatorTx(*txs.RemoveSubnetValidatorTx) error { m.numTxs.With(prometheus.Labels{ txLabel: "remove_subnet_validator", diff --git a/vms/platformvm/txs/codec.go b/vms/platformvm/txs/codec.go index f879ab1d884e..658acf2bc09c 100644 --- a/vms/platformvm/txs/codec.go +++ b/vms/platformvm/txs/codec.go @@ -128,8 +128,9 @@ func RegisterEtnaTypes(targetCodec linearcodec.Codec) error { targetCodec.RegisterType(&IncreaseL1ValidatorBalanceTx{}), targetCodec.RegisterType(&DisableL1ValidatorTx{}), - // todo: mode + // todo: move targetCodec.RegisterType(&AddContinuousValidatorTx{}), targetCodec.RegisterType(&StopContinuousValidatorTx{}), + targetCodec.RegisterType(&RewardContinuousValidatorTx{}), ) } diff --git a/vms/platformvm/txs/executor/atomic_tx_executor.go b/vms/platformvm/txs/executor/atomic_tx_executor.go index 574731e7903b..d76839615dad 100644 --- a/vms/platformvm/txs/executor/atomic_tx_executor.go +++ b/vms/platformvm/txs/executor/atomic_tx_executor.go @@ -84,6 +84,10 @@ func (*atomicTxExecutor) RewardValidatorTx(*txs.RewardValidatorTx) error { return ErrWrongTxType } +func (*atomicTxExecutor) RewardContinuousValidatorTx(tx *txs.RewardContinuousValidatorTx) error { + return ErrWrongTxType +} + func (*atomicTxExecutor) RemoveSubnetValidatorTx(*txs.RemoveSubnetValidatorTx) error { return ErrWrongTxType } diff --git a/vms/platformvm/txs/executor/atomic_tx_executor_test.go b/vms/platformvm/txs/executor/atomic_tx_executor_test.go new file mode 100644 index 000000000000..826c834c998b --- /dev/null +++ b/vms/platformvm/txs/executor/atomic_tx_executor_test.go @@ -0,0 +1,60 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package executor + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/upgrade/upgradetest" + "github.com/ava-labs/avalanchego/vms/platformvm/state" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" +) + +func TestAtomicExecutorWrongTxTypes(t *testing.T) { + require := require.New(t) + + env := newEnvironment(t, upgradetest.Latest) + + utxs := []txs.UnsignedTx{ + &txs.AddValidatorTx{}, + &txs.AddSubnetValidatorTx{}, + &txs.AddDelegatorTx{}, + &txs.CreateChainTx{}, + &txs.CreateSubnetTx{}, + &txs.AdvanceTimeTx{}, + &txs.RewardValidatorTx{}, + &txs.RemoveSubnetValidatorTx{}, + &txs.TransformSubnetTx{}, + &txs.AddPermissionlessValidatorTx{}, + &txs.AddPermissionlessDelegatorTx{}, + &txs.TransferSubnetOwnershipTx{}, + &txs.BaseTx{}, + &txs.ConvertSubnetToL1Tx{}, + &txs.RegisterL1ValidatorTx{}, + &txs.SetL1ValidatorWeightTx{}, + &txs.IncreaseL1ValidatorBalanceTx{}, + &txs.DisableL1ValidatorTx{}, + &txs.StopContinuousValidatorTx{}, + &txs.AddContinuousValidatorTx{}, + &txs.RewardContinuousValidatorTx{}, + } + + for _, utx := range utxs { + name := fmt.Sprintf("wrong tx type %T", utx) + t.Run(name, func(t *testing.T) { + _, _, _, err := AtomicTx( + &env.backend, + state.PickFeeCalculator(env.config, env.state), + ids.GenerateTestID(), + env, + &txs.Tx{Unsigned: utx}, + ) + require.ErrorIs(err, ErrWrongTxType) + }) + } +} diff --git a/vms/platformvm/txs/executor/proposal_tx_executor.go b/vms/platformvm/txs/executor/proposal_tx_executor.go index bdba7ffe4537..a929afab9775 100644 --- a/vms/platformvm/txs/executor/proposal_tx_executor.go +++ b/vms/platformvm/txs/executor/proposal_tx_executor.go @@ -6,6 +6,7 @@ package executor import ( "errors" "fmt" + math2 "math" "time" "github.com/ava-labs/avalanchego/database" @@ -36,6 +37,9 @@ var ( ErrRemoveWrongStaker = errors.New("attempting to remove wrong staker") ErrInvalidState = errors.New("generated output isn't valid state") ErrShouldBePermissionlessStaker = errors.New("expected permissionless staker") + ErrShouldBeContinuousStaker = errors.New("expected continuous staker") + ErrShouldBeFixedStaker = errors.New("expected fixed staker") + ErrInvalidTimestamp = errors.New("invalid timestamp") ErrWrongTxType = errors.New("wrong transaction type") ErrInvalidID = errors.New("invalid ID") ErrProposedAddStakerTxAfterBanff = errors.New("staker transaction proposed after Banff") @@ -382,187 +386,334 @@ func (e *proposalTxExecutor) RewardValidatorTx(tx *txs.RewardValidatorTx) error return fmt.Errorf("failed to get next removed staker tx: %w", err) } + if _, ok := stakerTx.Unsigned.(txs.FixedStaker); !ok { + return ErrShouldBeFixedStaker + } + // Invariant: A [txs.DelegatorTx] does not also implement the // [txs.ValidatorTx] interface. switch uStakerTx := stakerTx.Unsigned.(type) { case txs.ValidatorTx: - if continuousStaker, ok := uStakerTx.(txs.ContinuousStaker); ok { - if stakerToReward.ContinuationPeriod > 0 { - // todo: rewardvalidatorTX will have the same ID everytime for same staker - // Running continuous staker - rewards, err := GetRewardsCalculator(e.backend, e.onCommitState, continuousStaker.SubnetID()) + if err := e.rewardValidatorTx(uStakerTx, stakerToReward); err != nil { + return err + } + + // Handle staker lifecycle. + e.onCommitState.DeleteCurrentValidator(stakerToReward) + e.onAbortState.DeleteCurrentValidator(stakerToReward) + case txs.DelegatorTx: + if err := e.rewardDelegatorTx(uStakerTx, stakerToReward); err != nil { + return err + } + + // Handle staker lifecycle. + e.onCommitState.DeleteCurrentDelegator(stakerToReward) + e.onAbortState.DeleteCurrentDelegator(stakerToReward) + default: + // Invariant: Permissioned stakers are removed by the advancement of + // time and the current chain timestamp is == this staker's + // EndTime. This means only permissionless stakers should be + // left in the staker set. + return ErrShouldBePermissionlessStaker + } + + // If the reward is aborted, then the current supply should be decreased. + currentSupply, err := e.onAbortState.GetCurrentSupply(stakerToReward.SubnetID) + if err != nil { + return err + } + newSupply, err := math.Sub(currentSupply, stakerToReward.PotentialReward) + if err != nil { + return err + } + e.onAbortState.SetCurrentSupply(stakerToReward.SubnetID, newSupply) + return nil +} + +func (e *proposalTxExecutor) RewardContinuousValidatorTx(tx *txs.RewardContinuousValidatorTx) error { + currentChainTime := e.onCommitState.GetTimestamp() + if !time.Unix(int64(tx.Timestamp), 0).Equal(e.onCommitState.GetTimestamp()) { + return ErrInvalidTimestamp + } + + currentStakerIterator, err := e.onCommitState.GetCurrentStakerIterator() + if err != nil { + return err + } + defer currentStakerIterator.Release() + + if !currentStakerIterator.Next() { + return fmt.Errorf("failed to get next staker to remove: %w", database.ErrNotFound) + } + + stakerToReward := currentStakerIterator.Value() + if stakerToReward.TxID != tx.TxID { + return fmt.Errorf( + "%w: %s != %s", + ErrRemoveWrongStaker, + stakerToReward.TxID, + tx.TxID, + ) + } + + // Verify that the chain's timestamp is the validator's end time + if !stakerToReward.EndTime.Equal(currentChainTime) { + return fmt.Errorf( + "%w: TxID = %s with %s < %s", + ErrRemoveStakerTooEarly, + tx.TxID, + currentChainTime, + stakerToReward.EndTime, + ) + } + + stakerTx, _, err := e.onCommitState.GetTx(stakerToReward.TxID) + if err != nil { + return fmt.Errorf("failed to get next removed staker tx: %w", err) + } + + validatorTx, ok := stakerTx.Unsigned.(txs.ValidatorTx) + if !ok { + return ErrShouldBePermissionlessStaker + } + + continuousStaker, ok := stakerTx.Unsigned.(txs.ContinuousStaker) + if !ok { + return ErrShouldBeContinuousStaker + } + + if stakerToReward.ContinuationPeriod > 0 { + // Running continuous staker + rewards, err := GetRewardsCalculator(e.backend, e.onCommitState, continuousStaker.SubnetID()) + if err != nil { + return err + } + + currentSupply, err := e.onCommitState.GetCurrentSupply(continuousStaker.SubnetID()) + if err != nil { + return err + } + + newStartTime := currentChainTime + + { + // Set onAbortState. + delegateeReward, err := e.onCommitState.GetDelegateeReward( + stakerToReward.SubnetID, + stakerToReward.NodeID, + ) + if err != nil { + return fmt.Errorf("failed to fetch delegatee rewards: %w", err) + } + + currentSupply, err = math.Sub(currentSupply, stakerToReward.PotentialReward) + if err != nil { + return err + } + + newAccruedDelegateeRewards := stakerToReward.AccruedDelegateeRewards + newWeight := stakerToReward.Weight + + if delegateeReward > 0 { + // todo: test this flow + newAccruedDelegateeRewards, err = math.Add(stakerToReward.AccruedDelegateeRewards, delegateeReward) if err != nil { return err } - currentSupply, err := e.onCommitState.GetCurrentSupply(continuousStaker.SubnetID()) + newWeight, err = math.Add(stakerToReward.Weight, delegateeReward) if err != nil { return err } - newStartTime := currentChainTime + if newWeight > e.backend.Config.MaxValidatorStake { + // Create UTXO for [excessDelegateeReward] + // todo: maybe extract this?! + asset := validatorTx.Stake()[0].Asset - { - // Set onAbortState. - currentSupply, err = math.Sub(currentSupply, stakerToReward.PotentialReward) + excessDelegateeReward, err := math.Sub(newWeight, e.backend.Config.MaxValidatorStake) if err != nil { return err } - onAbortPotentialReward := rewards.Calculate( - continuousStaker.PeriodDuration(), - stakerToReward.Weight, - currentSupply, - ) - - newCurrentSupply, err := math.Add(currentSupply, onAbortPotentialReward) + outIntf, err := e.backend.Fx.CreateOutput(excessDelegateeReward, validatorTx.DelegationRewardsOwner()) if err != nil { - return err + return fmt.Errorf("failed to create output: %w", err) } - e.onAbortState.SetCurrentSupply(stakerToReward.SubnetID, newCurrentSupply) - err = e.onAbortState.ResetContinuousValidatorCycle( - stakerToReward.SubnetID, - stakerToReward.NodeID, - newStartTime, - stakerToReward.Weight, - onAbortPotentialReward, - stakerToReward.AccruedRewards, - stakerToReward.AccruedDelegateeRewards, - ) - if err != nil { - return err + out, ok := outIntf.(verify.State) + if !ok { + return ErrInvalidState } - } - { - // Set onCommitState. - delegateeReward, err := e.onCommitState.GetDelegateeReward( - stakerToReward.SubnetID, - stakerToReward.NodeID, - ) - if err != nil { - return fmt.Errorf("failed to fetch accrued delegatee rewards: %w", err) + excessDelegateeRewardUTXO := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: e.tx.ID(), + OutputIndex: 0, + }, + Asset: asset, + Out: out, } + e.onAbortState.AddUTXO(excessDelegateeRewardUTXO) + e.onAbortState.AddRewardUTXO(e.tx.ID(), excessDelegateeRewardUTXO) - newAccruedRewards, err := math.Add(stakerToReward.AccruedRewards, stakerToReward.PotentialReward) + newAccruedDelegateeRewards, err = math.Sub(newAccruedDelegateeRewards, excessDelegateeReward) if err != nil { return err } - newWeight, err := math.Add(stakerToReward.Weight, stakerToReward.PotentialReward) + newWeight, err = math.Sub(newWeight, excessDelegateeReward) if err != nil { return err } - newAccruedDelegateeRewards := stakerToReward.AccruedDelegateeRewards - if delegateeReward > 0 { - newAccruedDelegateeRewards, err = math.Add(stakerToReward.AccruedDelegateeRewards, delegateeReward) - if err != nil { - return err - } - - newWeight, err = math.Add(newWeight, delegateeReward) - if err != nil { - return err - } - } + // [newWeight] is equal to [e.backend.Config.MaxValidatorStake]. + } + } - // todo: can potentialrewards be 0 in any situation? - if newWeight > e.backend.Config.MaxValidatorStake { - utxosOffset, err := avax.GetNextOutputIndex(e.onCommitState, stakerTx.TxID) - if err != nil { - return err - } - - excessValidationRewards, excessDelegateeRewards, err := e.rewardExcessValidatorTx( - tx, - uStakerTx, - newWeight, - delegateeReward, - stakerToReward, - utxosOffset, - ) - if err != nil { - return err - } - - newAccruedRewards, err = math.Sub(newAccruedRewards, excessValidationRewards) - if err != nil { - return err - } - - newAccruedDelegateeRewards, err = math.Sub(newAccruedDelegateeRewards, excessDelegateeRewards) - if err != nil { - return err - } - - newWeight = e.backend.Config.MaxValidatorStake - } + onAbortPotentialReward := rewards.Calculate( + continuousStaker.PeriodDuration(), + newWeight, + currentSupply, + ) - onCommitPotentialReward := rewards.Calculate( - continuousStaker.PeriodDuration(), - newWeight, - currentSupply, - ) + newCurrentSupply, err := math.Add(currentSupply, onAbortPotentialReward) + if err != nil { + return err + } - newCurrentSupply, err := math.Add(currentSupply, onCommitPotentialReward) - if err != nil { - return err - } + e.onAbortState.SetCurrentSupply(stakerToReward.SubnetID, newCurrentSupply) + err = e.onAbortState.ResetContinuousValidatorCycle( + stakerToReward.SubnetID, + stakerToReward.NodeID, + newStartTime, + newWeight, + onAbortPotentialReward, + stakerToReward.AccruedRewards, + newAccruedDelegateeRewards, + ) + if err != nil { + return err + } + } - e.onCommitState.SetCurrentSupply(stakerToReward.SubnetID, newCurrentSupply) - err = e.onCommitState.ResetContinuousValidatorCycle( - stakerToReward.SubnetID, - stakerToReward.NodeID, - newStartTime, - newWeight, - onCommitPotentialReward, - newAccruedRewards, - newAccruedDelegateeRewards, - ) - if err != nil { - return err - } + { + // Set onCommitState. + delegateeReward, err := e.onCommitState.GetDelegateeReward( + stakerToReward.SubnetID, + stakerToReward.NodeID, + ) + if err != nil { + return fmt.Errorf("failed to fetch delegatee rewards: %w", err) + } + + newAccruedRewards, err := math.Add(stakerToReward.AccruedRewards, stakerToReward.PotentialReward) + if err != nil { + return err + } + + newWeight, err := math.Add(stakerToReward.Weight, stakerToReward.PotentialReward) + if err != nil { + return err + } + + newAccruedDelegateeRewards := stakerToReward.AccruedDelegateeRewards + if delegateeReward > 0 { + newAccruedDelegateeRewards, err = math.Add(stakerToReward.AccruedDelegateeRewards, delegateeReward) + if err != nil { + return err } - // Early return because we don't need to do anything else. - return nil + newWeight, err = math.Add(newWeight, delegateeReward) + if err != nil { + return err + } } - } - if err := e.rewardValidatorTx(uStakerTx, stakerToReward); err != nil { - return err - } + // todo: can potentialrewards be 0 in any situation? + if newWeight > e.backend.Config.MaxValidatorStake { + excessValidationRewards, excessDelegateeRewards, err := e.rewardExcessContinuousValidatorTx( + validatorTx, + newWeight, + delegateeReward, + stakerToReward, + ) + if err != nil { + return err + } - // Handle staker lifecycle. - e.onCommitState.DeleteCurrentValidator(stakerToReward) - e.onAbortState.DeleteCurrentValidator(stakerToReward) - case txs.DelegatorTx: - if err := e.rewardDelegatorTx(uStakerTx, stakerToReward); err != nil { - return err + newAccruedRewards, err = math.Sub(newAccruedRewards, excessValidationRewards) + if err != nil { + return err + } + + newAccruedDelegateeRewards, err = math.Sub(newAccruedDelegateeRewards, excessDelegateeRewards) + if err != nil { + return err + } + + newWeight, err = math.Sub(newWeight, excessValidationRewards) + if err != nil { + return err + } + + newWeight, err = math.Sub(newWeight, excessDelegateeRewards) + if err != nil { + return err + } + + // [newWeight] is equal to [e.backend.Config.MaxValidatorStake]. + } + + onCommitPotentialReward := rewards.Calculate( + continuousStaker.PeriodDuration(), + newWeight, + currentSupply, + ) + + newCurrentSupply, err := math.Add(currentSupply, onCommitPotentialReward) + if err != nil { + return err + } + + e.onCommitState.SetCurrentSupply(stakerToReward.SubnetID, newCurrentSupply) + err = e.onCommitState.ResetContinuousValidatorCycle( + stakerToReward.SubnetID, + stakerToReward.NodeID, + newStartTime, + newWeight, + onCommitPotentialReward, + newAccruedRewards, + newAccruedDelegateeRewards, + ) + if err != nil { + return err + } } - // Handle staker lifecycle. - e.onCommitState.DeleteCurrentDelegator(stakerToReward) - e.onAbortState.DeleteCurrentDelegator(stakerToReward) - default: - // Invariant: Permissioned stakers are removed by the advancement of - // time and the current chain timestamp is == this staker's - // EndTime. This means only permissionless stakers should be - // left in the staker set. - return ErrShouldBePermissionlessStaker + // Early return because we don't need to do anything else. + return nil + } + + if err := e.rewardContinuousValidatorTx(continuousStaker.(txs.ValidatorTx), stakerToReward); err != nil { + return err } + // Handle staker lifecycle. + e.onCommitState.DeleteCurrentValidator(stakerToReward) + e.onAbortState.DeleteCurrentValidator(stakerToReward) + // If the reward is aborted, then the current supply should be decreased. currentSupply, err := e.onAbortState.GetCurrentSupply(stakerToReward.SubnetID) if err != nil { return err } + newSupply, err := math.Sub(currentSupply, stakerToReward.PotentialReward) if err != nil { return err } + e.onAbortState.SetCurrentSupply(stakerToReward.SubnetID, newSupply) return nil } @@ -576,39 +727,25 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val stakeAsset = stake[0].Asset ) - utxosOffset := uint32(len(outputs)) - if _, ok := uValidatorTx.(txs.ContinuousStaker); ok { - outputIndex, err := avax.GetNextOutputIndex(e.onCommitState, validator.TxID) - if err != nil { - return err - } - - utxosOffset = outputIndex - } - // Refund the stake only when validator is about to leave // the staking set - for _, out := range stake { + for i, out := range stake { utxo := &avax.UTXO{ UTXOID: avax.UTXOID{ TxID: txID, - OutputIndex: utxosOffset, + OutputIndex: uint32(len(outputs) + i), }, Asset: out.Asset, Out: out.Output(), } e.onCommitState.AddUTXO(utxo) e.onAbortState.AddUTXO(utxo) - - utxosOffset++ } - // Provide the potential reward here + accrued rewards for continuous stakers. - reward := validator.PotentialReward - if _, ok := uValidatorTx.(txs.ContinuousStaker); ok { - reward += validator.AccruedRewards - } + utxosOffset := 0 + // Provide the reward here + reward := validator.PotentialReward if reward > 0 { validationRewardsOwner := uValidatorTx.ValidationRewardsOwner() outIntf, err := e.backend.Fx.CreateOutput(reward, validationRewardsOwner) @@ -623,7 +760,7 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val utxo := &avax.UTXO{ UTXOID: avax.UTXOID{ TxID: txID, - OutputIndex: utxosOffset, + OutputIndex: uint32(len(outputs) + len(stake)), }, Asset: stakeAsset, Out: out, @@ -660,7 +797,7 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val onCommitUtxo := &avax.UTXO{ UTXOID: avax.UTXOID{ TxID: txID, - OutputIndex: utxosOffset, + OutputIndex: uint32(len(outputs) + len(stake) + utxosOffset), }, Asset: stakeAsset, Out: out, @@ -668,14 +805,12 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val e.onCommitState.AddUTXO(onCommitUtxo) e.onCommitState.AddRewardUTXO(txID, onCommitUtxo) - utxosOffset++ - // Note: There is no [offset] if the RewardValidatorTx is // aborted, because the validator reward is not awarded. onAbortUtxo := &avax.UTXO{ UTXOID: avax.UTXOID{ TxID: txID, - OutputIndex: utxosOffset, + OutputIndex: uint32(len(outputs) + len(stake)), }, Asset: stakeAsset, Out: out, @@ -818,21 +953,120 @@ func (e *proposalTxExecutor) rewardDelegatorTx(uDelegatorTx txs.DelegatorTx, del return nil } +func (e *proposalTxExecutor) rewardContinuousValidatorTx(uValidatorTx txs.ValidatorTx, validator *state.Staker) error { + txID := validator.TxID + stake := uValidatorTx.Stake() + + stakeAsset := stake[0].Asset + + outputIndexOffset := uint32(len(uValidatorTx.Outputs())) + + // Refund the stake only when validator is about to leave + // the staking set + for i, out := range stake { + utxo := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: outputIndexOffset + uint32(i), + }, + Asset: out.Asset, + Out: out.Output(), + } + e.onCommitState.AddUTXO(utxo) + e.onAbortState.AddUTXO(utxo) + } + + // Provide the potential reward here + accrued rewards. + reward := validator.PotentialReward + validator.AccruedRewards + + utxosOffset := 0 + if reward > 0 { + validationRewardsOwner := uValidatorTx.ValidationRewardsOwner() + outIntf, err := e.backend.Fx.CreateOutput(reward, validationRewardsOwner) + if err != nil { + return fmt.Errorf("failed to create output: %w", err) + } + out, ok := outIntf.(verify.State) + if !ok { + return ErrInvalidState + } + + utxo := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: outputIndexOffset + uint32(len(stake)), + }, + Asset: stakeAsset, + Out: out, + } + e.onCommitState.AddUTXO(utxo) + e.onCommitState.AddRewardUTXO(txID, utxo) + + utxosOffset++ + } + + // Provide the accrued delegatee rewards from successful delegations here. + delegateeReward, err := e.onCommitState.GetDelegateeReward( + validator.SubnetID, + validator.NodeID, + ) + if err != nil { + return fmt.Errorf("failed to fetch accrued delegatee rewards: %w", err) + } + + if delegateeReward == 0 { + return nil + } + + delegationRewardsOwner := uValidatorTx.DelegationRewardsOwner() + outIntf, err := e.backend.Fx.CreateOutput(delegateeReward, delegationRewardsOwner) + if err != nil { + return fmt.Errorf("failed to create output: %w", err) + } + out, ok := outIntf.(verify.State) + if !ok { + return ErrInvalidState + } + + onCommitUtxo := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: outputIndexOffset + uint32(len(stake)+utxosOffset), + }, + Asset: stakeAsset, + Out: out, + } + e.onCommitState.AddUTXO(onCommitUtxo) + e.onCommitState.AddRewardUTXO(txID, onCommitUtxo) + + // Note: There is no [offset] if the RewardValidatorTx is + // aborted, because the validator reward is not awarded. + onAbortUtxo := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: outputIndexOffset + uint32(len(stake)), + }, + Asset: stakeAsset, + Out: out, + } + e.onAbortState.AddUTXO(onAbortUtxo) + e.onAbortState.AddRewardUTXO(txID, onAbortUtxo) + return nil +} + // Invariants: // 1. [newWeight] > [e.backend.Config.MaxValidatorStake] // 2. [newWeight] > [staker.Weight] -func (e *proposalTxExecutor) rewardExcessValidatorTx( - rewardValidatorTx *txs.RewardValidatorTx, +func (e *proposalTxExecutor) rewardExcessContinuousValidatorTx( uValidatorTx txs.ValidatorTx, newWeight uint64, delegateeReward uint64, staker *state.Staker, - utxoOffset uint32, ) (uint64, uint64, error) { + // todo: think about using similar technique as rewards/calculator.Split // todo: think about having any of them 0 asset := uValidatorTx.Stake()[0].Asset - // Invariant: newWeight > staker.Weight restakingRewards, err := math.Sub(newWeight, staker.Weight) if err != nil { return 0, 0, err @@ -843,20 +1077,37 @@ func (e *proposalTxExecutor) rewardExcessValidatorTx( return 0, 0, err } - excessRatio := excess / restakingRewards + excessRatio := float64(excess) / float64(restakingRewards) // < 0 - excessValidationReward, err := math.Mul(excessRatio, staker.PotentialReward) - if err != nil { - return 0, 0, err - } + excessValidationReward := uint64(math2.Round(excessRatio * float64(staker.PotentialReward))) + excessDelegateeReward := uint64(math2.Round(excessRatio * float64(delegateeReward))) - excessDelegateeReward, err := math.Mul(excessRatio, delegateeReward) - if err != nil { - return 0, 0, err + if excessValidationReward > 0 { + // Create UTXO for [excessValidationReward] + outIntf, err := e.backend.Fx.CreateOutput(excessValidationReward, uValidatorTx.ValidationRewardsOwner()) + if err != nil { + return 0, 0, fmt.Errorf("failed to create output: %w", err) + } + + out, ok := outIntf.(verify.State) + if !ok { + return 0, 0, ErrInvalidState + } + + excessValidationRewardUTXO := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: e.tx.ID(), + OutputIndex: 0, + }, + Asset: asset, + Out: out, + } + e.onCommitState.AddUTXO(excessValidationRewardUTXO) + e.onCommitState.AddRewardUTXO(e.tx.ID(), excessValidationRewardUTXO) } - // Create UTXO for [excessDelegateeReward] if excessDelegateeReward > 0 { + // Create UTXO for [excessDelegateeReward] outIntf, err := e.backend.Fx.CreateOutput(excessDelegateeReward, uValidatorTx.DelegationRewardsOwner()) if err != nil { return 0, 0, fmt.Errorf("failed to create output: %w", err) @@ -869,38 +1120,15 @@ func (e *proposalTxExecutor) rewardExcessValidatorTx( excessDelegateeRewardUTXO := &avax.UTXO{ UTXOID: avax.UTXOID{ - TxID: rewardValidatorTx.TxID, - OutputIndex: utxoOffset, + TxID: e.tx.ID(), + OutputIndex: 1, }, Asset: asset, Out: out, } e.onCommitState.AddUTXO(excessDelegateeRewardUTXO) - e.onCommitState.AddRewardUTXO(rewardValidatorTx.TxID, excessDelegateeRewardUTXO) + e.onCommitState.AddRewardUTXO(e.tx.ID(), excessDelegateeRewardUTXO) } - // Create UTXO for [excessValidationReward] - outIntf, err := e.backend.Fx.CreateOutput(excessValidationReward, uValidatorTx.ValidationRewardsOwner()) - if err != nil { - return 0, 0, fmt.Errorf("failed to create output: %w", err) - } - - out, ok := outIntf.(verify.State) - if !ok { - return 0, 0, ErrInvalidState - } - - excessValidationRewardUTXO := &avax.UTXO{ - UTXOID: avax.UTXOID{ - TxID: rewardValidatorTx.TxID, - OutputIndex: utxoOffset + 1, - }, - Asset: asset, - Out: out, - } - - e.onCommitState.AddUTXO(excessValidationRewardUTXO) - e.onCommitState.AddRewardUTXO(rewardValidatorTx.TxID, excessValidationRewardUTXO) - - return excessDelegateeReward, excessValidationReward, nil + return excessValidationReward, excessDelegateeReward, nil } diff --git a/vms/platformvm/txs/executor/reward_validator_test.go b/vms/platformvm/txs/executor/reward_validator_test.go index 6dcf3c575d84..39635ad4426c 100644 --- a/vms/platformvm/txs/executor/reward_validator_test.go +++ b/vms/platformvm/txs/executor/reward_validator_test.go @@ -36,6 +36,15 @@ func newRewardValidatorTx(t testing.TB, txID ids.ID) (*txs.Tx, error) { return tx, tx.SyntacticVerify(snowtest.Context(t, snowtest.PChainID)) } +func newRewardContinuousValidatorTx(t testing.TB, txID ids.ID, timestamp uint64) (*txs.Tx, error) { + utx := &txs.RewardContinuousValidatorTx{TxID: txID, Timestamp: timestamp} + tx, err := txs.NewSigned(utx, txs.Codec, nil) + if err != nil { + return nil, err + } + return tx, tx.SyntacticVerify(snowtest.Context(t, snowtest.PChainID)) +} + func TestRewardValidatorTxExecuteOnCommit(t *testing.T) { require := require.New(t) env := newEnvironment(t, upgradetest.ApricotPhase5) @@ -881,10 +890,78 @@ func TestRewardDelegatorTxExecuteOnAbort(t *testing.T) { require.Equal(initialSupply-expectedReward, newSupply, "should have removed un-rewarded tokens from the potential supply") } +func TestRewardValidatorStakerType(t *testing.T) { + require := require.New(t) + env := newEnvironment(t, upgradetest.Fortuna) + + wallet := newWallet(t, env, walletConfig{}) + sk, err := localsigner.New() + require.NoError(err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + vdrStartTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + + vdrTx, err := wallet.IssueAddContinuousValidatorTx( + &txs.Validator{ + NodeID: ids.GenerateTestNodeID(), + Wght: env.config.MinValidatorStake, + }, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + reward.PercentDenominator, + defaultMinStakingDuration, + ) + require.NoError(err) + + addValTx := vdrTx.Unsigned.(*txs.AddContinuousValidatorTx) + + vdrStaker, err := state.NewCurrentStaker( + vdrTx.ID(), + addValTx, + vdrStartTime, + uint64(1000000), + ) + require.NoError(err) + + require.NoError(env.state.PutCurrentValidator(vdrStaker)) + env.state.AddTx(vdrTx, status.Committed) + require.NoError(env.state.Commit()) + + env.state.SetTimestamp(vdrStaker.EndTime) + + tx, err := newRewardValidatorTx(t, vdrTx.ID()) + require.NoError(err) + + onCommitState, err := state.NewDiff(lastAcceptedID, env) + require.NoError(err) + + onAbortState, err := state.NewDiff(lastAcceptedID, env) + require.NoError(err) + + feeCalculator := state.PickFeeCalculator(env.config, onCommitState) + err = ProposalTx( + &env.backend, + feeCalculator, + tx, + onCommitState, + onAbortState, + ) + require.ErrorIs(err, ErrShouldBeFixedStaker) +} + func TestRewardContinuousValidatorTxExecute(t *testing.T) { require := require.New(t) - env := newEnvironment(t, upgradetest.ApricotPhase5) - dummyHeight := uint64(1) + env := newEnvironment(t, upgradetest.Fortuna) wallet := newWallet(t, env, walletConfig{}) sk, err := localsigner.New() @@ -899,7 +976,12 @@ func TestRewardContinuousValidatorTxExecute(t *testing.T) { vdrWeight := env.config.MinValidatorStake vdrPeriod := defaultMinStakingDuration - rewardOwners := &secp256k1fx.OutputOwners{ + validationRewardOwners := &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + } + + delegationRewardOwners := &secp256k1fx.OutputOwners{ Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}, } @@ -911,8 +993,8 @@ func TestRewardContinuousValidatorTxExecute(t *testing.T) { }, pop, env.ctx.AVAXAssetID, - rewardOwners, - rewardOwners, + validationRewardOwners, + delegationRewardOwners, reward.PercentDenominator, vdrPeriod, ) @@ -932,13 +1014,16 @@ func TestRewardContinuousValidatorTxExecute(t *testing.T) { require.Equal(vdrStartTime.Add(vdrPeriod), vdrStaker.EndTime) require.NoError(env.state.PutCurrentValidator(vdrStaker)) + avax.Produce(env.state, vdrTx.ID(), vdrTx.Unsigned.Outputs()) env.state.AddTx(vdrTx, status.Committed) + require.NoError(env.state.Commit()) - env.state.SetTimestamp(vdrStaker.EndTime) - env.state.SetHeight(dummyHeight) + delegateeRewards := uint64(1000) + require.NoError(env.state.SetDelegateeReward(constants.PrimaryNetworkID, vdrStaker.NodeID, delegateeRewards)) require.NoError(env.state.Commit()) - tx, err := newRewardValidatorTx(t, vdrTx.ID()) + // Removing staker too early + tx, err := newRewardContinuousValidatorTx(t, vdrTx.ID(), uint64(env.state.GetTimestamp().Unix())) require.NoError(err) onCommitState, err := state.NewDiff(lastAcceptedID, env) @@ -948,6 +1033,27 @@ func TestRewardContinuousValidatorTxExecute(t *testing.T) { require.NoError(err) feeCalculator := state.PickFeeCalculator(env.config, onCommitState) + + err = ProposalTx( + &env.backend, + feeCalculator, + tx, + onCommitState, + onAbortState, + ) + require.ErrorIs(err, ErrRemoveStakerTooEarly) + + // Check first cycle + env.state.SetTimestamp(vdrStaker.EndTime) + tx, err = newRewardContinuousValidatorTx(t, vdrTx.ID(), uint64(env.state.GetTimestamp().Unix())) + require.NoError(err) + + onCommitState, err = state.NewDiff(lastAcceptedID, env) + require.NoError(err) + + onAbortState, err = state.NewDiff(lastAcceptedID, env) + require.NoError(err) + require.NoError(ProposalTx( &env.backend, feeCalculator, @@ -961,25 +1067,61 @@ func TestRewardContinuousValidatorTxExecute(t *testing.T) { require.NoError(err) require.Equal(vdrWeight, validator.Weight) - require.Equal(uint64(0), validator.AccruedRewards) - require.Equal(nil, validator.AccruedDelegateeRewards) // todo: + require.Zero(validator.AccruedRewards) + require.Zero(validator.AccruedDelegateeRewards) require.Equal(onAbortState.GetTimestamp(), validator.StartTime) require.Equal(onAbortState.GetTimestamp().Add(vdrPeriod), validator.EndTime) require.Equal(vdrPeriod, validator.ContinuationPeriod) + // No utxos on add validator tx. + utxoID := &avax.UTXOID{ + TxID: vdrTx.ID(), + OutputIndex: 0, + } + + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.Error(database.ErrNotFound, err) + + // No utxos on reward tx. + utxoID = &avax.UTXOID{ + TxID: tx.ID(), + OutputIndex: 0, + } + + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.Error(database.ErrNotFound, err) + // Check onCommitState validator, err = onCommitState.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) require.NoError(err) - expectedReward := validator.PotentialReward + validator.AccruedRewards // todo: add delegators + expectedValidationRewards := validator.PotentialReward + validator.AccruedRewards + expectedDelegationRewards := validator.AccruedDelegateeRewards - require.Equal(vdrWeight+vdrPotentialReward, validator.Weight) + require.Equal(vdrWeight+vdrPotentialReward+delegateeRewards, validator.Weight) require.Equal(vdrPotentialReward, validator.AccruedRewards) - require.Equal(nil, validator.AccruedDelegateeRewards) // todo: + require.Equal(delegateeRewards, validator.AccruedDelegateeRewards) require.Equal(onAbortState.GetTimestamp(), validator.StartTime) require.Equal(onAbortState.GetTimestamp().Add(vdrPeriod), validator.EndTime) require.Equal(vdrPeriod, validator.ContinuationPeriod) + // No UTXOs created on add validator tx, nor reward tx. + for _, state := range []state.Diff{onCommitState, onAbortState} { + utxoID = &avax.UTXOID{ + TxID: vdrTx.ID(), + OutputIndex: 0, + } + _, err = state.GetUTXO(utxoID.InputID()) + require.Error(database.ErrNotFound, err) + + utxoID = &avax.UTXOID{ + TxID: tx.ID(), + OutputIndex: 0, + } + _, err = state.GetUTXO(utxoID.InputID()) + require.Error(database.ErrNotFound, err) + } + // Move forward with onCommitState require.NoError(onCommitState.Apply(env.state)) require.NoError(env.state.Commit()) @@ -991,7 +1133,7 @@ func TestRewardContinuousValidatorTxExecute(t *testing.T) { // Check after being stopped env.state.SetTimestamp(validator.EndTime) - tx, err = newRewardValidatorTx(t, vdrTx.ID()) + tx, err = newRewardContinuousValidatorTx(t, vdrTx.ID(), uint64(env.state.GetTimestamp().Unix())) require.NoError(err) onCommitState, err = state.NewDiffOn(env.state) @@ -1042,17 +1184,16 @@ func TestRewardContinuousValidatorTxExecute(t *testing.T) { // Check onCommitState { - validator, err = onCommitState.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) require.ErrorIs(database.ErrNotFound, err) - outputIndex := len(addContValTx.Outputs()) + outputIndex := uint32(len(addContValTx.Outputs())) // Check stake UTXOs for _, stake := range addContValTx.Stake() { stakeUTXOID := &avax.UTXOID{ TxID: vdrTx.ID(), - OutputIndex: uint32(outputIndex), + OutputIndex: outputIndex, } stakeUTXO, err := onCommitState.GetUTXO(stakeUTXOID.InputID()) @@ -1067,59 +1208,63 @@ func TestRewardContinuousValidatorTxExecute(t *testing.T) { // Check Rewards UTXOs rewardUTXOID := &avax.UTXOID{ TxID: vdrTx.ID(), - OutputIndex: uint32(outputIndex), + OutputIndex: outputIndex, } + outputIndex++ rewardUTXO, err := onCommitState.GetUTXO(rewardUTXOID.InputID()) require.NoError(err) require.Equal(env.ctx.AVAXAssetID, rewardUTXO.Asset.AssetID()) - require.Equal(expectedReward, rewardUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) - require.True(rewardOwners.Equals(&rewardUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + require.Equal(expectedValidationRewards, rewardUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(validationRewardOwners.Equals(&rewardUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) - { - // Check Delegating Rewards UTXOs - // todo: add delegators + // Check Delegating Rewards UTXOs + delegatingRewardsUTXOID := &avax.UTXOID{ + TxID: vdrTx.ID(), + OutputIndex: outputIndex, } + delegatingRewardsUTXO, err := onCommitState.GetUTXO(delegatingRewardsUTXOID.InputID()) + require.NoError(err) + require.Equal(env.ctx.AVAXAssetID, delegatingRewardsUTXO.Asset.AssetID()) + require.Equal(expectedDelegationRewards, delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(delegationRewardOwners.Equals(&delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + // No other utxos utxoID := &avax.UTXOID{ TxID: vdrTx.ID(), - OutputIndex: uint32(outputIndex), + OutputIndex: outputIndex, } _, err = onAbortState.GetUTXO(utxoID.InputID()) require.Error(database.ErrNotFound, err) } - // todo IMPORTANT: test if withdrawal works correct with multiple excesses and then stop - - // todo: check balance of the staker (where stakeouts are going) // todo: add a test for a staker with previous cycles ended - - // todo: add delegators in this flow so we are 100% is correct - // todo: extract correctness of state changing based on AddContinuousValidatorTx and StopContinuousValidatorTx - // this test should only check rewards stuff } -func TestRewardContinuousValidatorTxMaxStakeLimit(t *testing.T) { +func TestRewardContinuousValidatorStakerType(t *testing.T) { require := require.New(t) - env := newEnvironment(t, upgradetest.ApricotPhase5) + env := newEnvironment(t, upgradetest.Fortuna) wallet := newWallet(t, env, walletConfig{}) - sk, err := localsigner.New() require.NoError(err) pop, err := signer.NewProofOfPossession(sk) require.NoError(err) - vdrPotentialReward := uint64(1_000_000) - delPotentialReward := uint64(500_000) - vdrWeight := env.config.MaxValidatorStake - 500_000 + vdrStartTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + vdrEndTime := uint64(genesistest.DefaultValidatorStartTime.Add(2 * defaultMinStakingDuration).Unix()) - vdrTx, err := wallet.IssueAddContinuousValidatorTx( - &txs.Validator{ - NodeID: ids.GenerateTestNodeID(), - Wght: vdrWeight, + vdrTx, err := wallet.IssueAddPermissionlessValidatorTx( + &txs.SubnetValidator{ + Validator: txs.Validator{ + NodeID: ids.GenerateTestNodeID(), + End: vdrEndTime, + Wght: env.config.MinValidatorStake, + }, + Subnet: constants.PrimaryNetworkID, }, pop, env.ctx.AVAXAssetID, @@ -1132,59 +1277,178 @@ func TestRewardContinuousValidatorTxMaxStakeLimit(t *testing.T) { Addrs: []ids.ShortID{ids.GenerateTestShortID()}, }, reward.PercentDenominator, - defaultMinStakingDuration, ) require.NoError(err) - uVdrTx := vdrTx.Unsigned.(*txs.AddContinuousValidatorTx) + addValTx := vdrTx.Unsigned.(*txs.AddPermissionlessValidatorTx) vdrStaker, err := state.NewCurrentStaker( vdrTx.ID(), - uVdrTx, - time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0), - vdrPotentialReward, + addValTx, + vdrStartTime, + uint64(1000000), ) require.NoError(err) require.NoError(env.state.PutCurrentValidator(vdrStaker)) env.state.AddTx(vdrTx, status.Committed) + require.NoError(env.state.Commit()) - // Add delegator - delTx, err := wallet.IssueAddPermissionlessDelegatorTx( + env.state.SetTimestamp(vdrStaker.EndTime) + + tx, err := newRewardContinuousValidatorTx(t, vdrTx.ID(), uint64(env.state.GetTimestamp().Unix())) + require.NoError(err) + + onCommitState, err := state.NewDiff(lastAcceptedID, env) + require.NoError(err) + + onAbortState, err := state.NewDiff(lastAcceptedID, env) + require.NoError(err) + + feeCalculator := state.PickFeeCalculator(env.config, onCommitState) + err = ProposalTx( + &env.backend, + feeCalculator, + tx, + onCommitState, + onAbortState, + ) + require.ErrorIs(err, ErrShouldBeContinuousStaker) +} + +func TestRewardContinuousValidatorInvalidTimestamp(t *testing.T) { + require := require.New(t) + env := newEnvironment(t, upgradetest.Fortuna) + + wallet := newWallet(t, env, walletConfig{}) + sk, err := localsigner.New() + require.NoError(err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + vdrStartTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + vdrEndTime := uint64(genesistest.DefaultValidatorStartTime.Add(2 * defaultMinStakingDuration).Unix()) + + vdrTx, err := wallet.IssueAddPermissionlessValidatorTx( &txs.SubnetValidator{ Validator: txs.Validator{ - NodeID: vdrStaker.NodeID, - Start: uint64(vdrStaker.StartTime.Unix()), - End: uint64(vdrStaker.EndTime.Unix()), - Wght: delPotentialReward, + NodeID: ids.GenerateTestNodeID(), + End: vdrEndTime, + Wght: env.config.MinValidatorStake, }, Subnet: constants.PrimaryNetworkID, }, + pop, env.ctx.AVAXAssetID, &secp256k1fx.OutputOwners{ Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}, }, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + reward.PercentDenominator, ) require.NoError(err) - uDelTx := delTx.Unsigned.(*txs.AddDelegatorTx) + addValTx := vdrTx.Unsigned.(*txs.AddPermissionlessValidatorTx) - delegator, err := state.NewCurrentStaker( - delTx.ID(), - uDelTx, - vdrStaker.StartTime, - 1000, + vdrStaker, err := state.NewCurrentStaker( + vdrTx.ID(), + addValTx, + vdrStartTime, + uint64(1000000), ) require.NoError(err) - require.NoError(env.state.PutCurrentValidator(delegator)) + require.NoError(env.state.PutCurrentValidator(vdrStaker)) env.state.AddTx(vdrTx, status.Committed) + require.NoError(env.state.Commit()) env.state.SetTimestamp(vdrStaker.EndTime) + + tx, err := newRewardContinuousValidatorTx(t, vdrTx.ID(), uint64(env.state.GetTimestamp().Unix())-1) + require.NoError(err) + + onCommitState, err := state.NewDiff(lastAcceptedID, env) + require.NoError(err) + + onAbortState, err := state.NewDiff(lastAcceptedID, env) + require.NoError(err) + + feeCalculator := state.PickFeeCalculator(env.config, onCommitState) + err = ProposalTx( + &env.backend, + feeCalculator, + tx, + onCommitState, + onAbortState, + ) + require.ErrorIs(err, ErrInvalidTimestamp) +} + +func TestRewardContinuousValidatorTxMaxStakeLimit(t *testing.T) { + require := require.New(t) + env := newEnvironment(t, upgradetest.Fortuna) + + wallet := newWallet(t, env, walletConfig{}) + + sk, err := localsigner.New() + require.NoError(err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + potentialReward := uint64(10_000_000) + delegateeRewards := uint64(5_000_000) + vdrWeight := env.config.MaxValidatorStake - 500_000 + + validationRewardOwners := &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + } + + delegationRewardOwners := &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + } + + vdrTx, err := wallet.IssueAddContinuousValidatorTx( + &txs.Validator{ + NodeID: ids.GenerateTestNodeID(), + Wght: vdrWeight, + }, + pop, + env.ctx.AVAXAssetID, + validationRewardOwners, + delegationRewardOwners, + reward.PercentDenominator, + defaultMinStakingDuration, + ) + require.NoError(err) + + uVdrTx := vdrTx.Unsigned.(*txs.AddContinuousValidatorTx) + + vdrStaker, err := state.NewCurrentStaker( + vdrTx.ID(), + uVdrTx, + time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0), + potentialReward, + ) + require.NoError(err) + + require.NoError(env.state.PutCurrentValidator(vdrStaker)) + avax.Produce(env.state, vdrTx.ID(), vdrTx.Unsigned.Outputs()) + env.state.AddTx(vdrTx, status.Committed) require.NoError(env.state.Commit()) - tx, err := newRewardValidatorTx(t, vdrTx.ID()) + require.NoError(env.state.SetDelegateeReward(constants.PrimaryNetworkID, vdrStaker.NodeID, delegateeRewards)) + + env.state.SetTimestamp(vdrStaker.EndTime) + + tx, err := newRewardContinuousValidatorTx(t, vdrTx.ID(), uint64(env.state.GetTimestamp().Unix())) require.NoError(err) onCommitState, err := state.NewDiffOn(env.state) @@ -1201,6 +1465,29 @@ func TestRewardContinuousValidatorTxMaxStakeLimit(t *testing.T) { onAbortState, )) + // Check onAbortState + { + // Check Delegating Rewards UTXOs + delegatingRewardsUTXOID := &avax.UTXOID{ + TxID: tx.ID(), + OutputIndex: 0, + } + + delegatingRewardsUTXO, err := onAbortState.GetUTXO(delegatingRewardsUTXOID.InputID()) + require.NoError(err) + require.Equal(env.ctx.AVAXAssetID, delegatingRewardsUTXO.Asset.AssetID()) + require.Equal(uint64(4_500_000), delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(delegationRewardOwners.Equals(&delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + // No other utxos + utxoID := &avax.UTXOID{ + OutputIndex: 1, + } + + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.Error(database.ErrNotFound, err) + } + // Check onCommitState require.NoError(onCommitState.Apply(env.state)) require.NoError(env.state.Commit()) @@ -1209,6 +1496,48 @@ func TestRewardContinuousValidatorTxMaxStakeLimit(t *testing.T) { require.NoError(err) require.Equal(env.config.MaxValidatorStake, validator.Weight) - require.Equal(vdrPotentialReward, validator.AccruedRewards) - require.Equal(nil, validator.AccruedDelegateeRewards) // todo: + require.Equal(uint64(333_333), validator.AccruedRewards) + require.Equal(uint64(166_667), validator.AccruedDelegateeRewards) + + // Check UTXOs for excess withdrawn + { + // Check Rewards UTXOs + rewardUTXOID := &avax.UTXOID{ + TxID: tx.ID(), + OutputIndex: 0, + } + + rewardUTXO, err := onCommitState.GetUTXO(rewardUTXOID.InputID()) + require.NoError(err) + require.Equal(env.ctx.AVAXAssetID, rewardUTXO.Asset.AssetID()) + require.Equal(uint64(9_666_667), rewardUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(validationRewardOwners.Equals(&rewardUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + // Check Delegating Rewards UTXOs + delegatingRewardsUTXOID := &avax.UTXOID{ + TxID: tx.ID(), + OutputIndex: 1, + } + + delegatingRewardsUTXO, err := onCommitState.GetUTXO(delegatingRewardsUTXOID.InputID()) + require.NoError(err) + require.Equal(env.ctx.AVAXAssetID, delegatingRewardsUTXO.Asset.AssetID()) + require.Equal(uint64(4_833_333), delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(delegationRewardOwners.Equals(&delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + } + + // No other utxos + utxoID := &avax.UTXOID{ + OutputIndex: 2, + } + + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.Error(database.ErrNotFound, err) + + // Another reward validator tx + require.NoError(env.state.Commit()) + + require.NoError(env.state.SetDelegateeReward(constants.PrimaryNetworkID, vdrStaker.NodeID, delegateeRewards)) + + env.state.SetTimestamp(vdrStaker.EndTime) } diff --git a/vms/platformvm/txs/executor/standard_tx_executor.go b/vms/platformvm/txs/executor/standard_tx_executor.go index 9896f71432a4..af1e790ea704 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor.go +++ b/vms/platformvm/txs/executor/standard_tx_executor.go @@ -114,6 +114,10 @@ func (*standardTxExecutor) RewardValidatorTx(*txs.RewardValidatorTx) error { return ErrWrongTxType } +func (*standardTxExecutor) RewardContinuousValidatorTx(tx *txs.RewardContinuousValidatorTx) error { + return ErrWrongTxType +} + func (e *standardTxExecutor) AddValidatorTx(tx *txs.AddValidatorTx) error { if tx.Validator.NodeID == ids.EmptyNodeID { return errEmptyNodeID diff --git a/vms/platformvm/txs/executor/standard_tx_executor_test.go b/vms/platformvm/txs/executor/standard_tx_executor_test.go index 73646d848c46..262fef5954a9 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor_test.go +++ b/vms/platformvm/txs/executor/standard_tx_executor_test.go @@ -4520,7 +4520,7 @@ func TestStandardExecutorStopContinuousValidatorTx(t *testing.T) { diff, err = state.NewDiffOn(env.state) require.NoError(err) - standarxTxEx := &standardTxExecutor{ + standardTxEx := &standardTxExecutor{ backend: &env.backend, feeCalculator: feeCalculator, tx: stopContVdrTx, @@ -4528,10 +4528,10 @@ func TestStandardExecutorStopContinuousValidatorTx(t *testing.T) { } if test.updateState != nil { - test.updateState(require, standarxTxEx.state) + test.updateState(require, standardTxEx.state) } - require.ErrorIs(stopContVdrTx.Unsigned.Visit(standarxTxEx), test.expectedErr) + require.ErrorIs(stopContVdrTx.Unsigned.Visit(standardTxEx), test.expectedErr) require.True(stopContVdrTx.Unsigned.(*txs.StopContinuousValidatorTx).BaseTx.SyntacticallyVerified) require.NoError(diff.Apply(env.state)) require.NoError(env.state.Commit()) @@ -4619,6 +4619,25 @@ func TestStandardExecutorStopContinuousValidatorTxInvalidStaker(t *testing.T) { require.ErrorIs(err, ErrInvalidStakerTx) } +func TestStandardExecutorRewardContinuousValidatorTx(t *testing.T) { + require := require.New(t) + env := newEnvironment(t, upgradetest.Latest) + + tx, err := newRewardContinuousValidatorTx(t, ids.GenerateTestID(), 1) + require.NoError(err) + + diff, err := state.NewDiffOn(env.state) + require.NoError(err) + + _, _, _, err = StandardTx( + &env.backend, + state.PickFeeCalculator(env.config, env.state), + tx, + diff, + ) + require.ErrorIs(err, ErrWrongTxType) +} + func must[T any](t require.TestingT) func(T, error) T { return func(val T, err error) T { require.NoError(t, err) diff --git a/vms/platformvm/txs/executor/warp_verifier.go b/vms/platformvm/txs/executor/warp_verifier.go index aa4d668cd148..360f18076ff9 100644 --- a/vms/platformvm/txs/executor/warp_verifier.go +++ b/vms/platformvm/txs/executor/warp_verifier.go @@ -78,6 +78,10 @@ func (*warpVerifier) RewardValidatorTx(*txs.RewardValidatorTx) error { return nil } +func (*warpVerifier) RewardContinuousValidatorTx(tx *txs.RewardContinuousValidatorTx) error { + return nil +} + func (*warpVerifier) RemoveSubnetValidatorTx(*txs.RemoveSubnetValidatorTx) error { return nil } diff --git a/vms/platformvm/txs/fee/complexity.go b/vms/platformvm/txs/fee/complexity.go index 9cc8d8e3d966..ae763e5c1cc5 100644 --- a/vms/platformvm/txs/fee/complexity.go +++ b/vms/platformvm/txs/fee/complexity.go @@ -534,6 +534,10 @@ func (*complexityVisitor) RewardValidatorTx(*txs.RewardValidatorTx) error { return ErrUnsupportedTx } +func (*complexityVisitor) RewardContinuousValidatorTx(tx *txs.RewardContinuousValidatorTx) error { + return ErrUnsupportedTx +} + func (*complexityVisitor) TransformSubnetTx(*txs.TransformSubnetTx) error { return ErrUnsupportedTx } diff --git a/vms/platformvm/txs/reward_continuous_validator_tx.go b/vms/platformvm/txs/reward_continuous_validator_tx.go new file mode 100644 index 000000000000..6fcd44523837 --- /dev/null +++ b/vms/platformvm/txs/reward_continuous_validator_tx.go @@ -0,0 +1,67 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "errors" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/vms/components/avax" +) + +var ( + _ UnsignedTx = (*RewardContinuousValidatorTx)(nil) + + errMissingTxId = errors.New("missing tx id") + errMissingTimestamp = errors.New("missing timestamp") +) + +// RewardContinuousValidatorTx is a transaction that represents a proposal to +// reward/remove a continuous validator that is currently validating from the validator set. +type RewardContinuousValidatorTx struct { + // ID of the tx that created the delegator/validator being removed/rewarded + TxID ids.ID `serialize:"true" json:"txID"` + + // End time of the validator. + Timestamp uint64 `serialize:"true" json:"timestamp"` + + unsignedBytes []byte // Unsigned byte representation of this data +} + +func (tx *RewardContinuousValidatorTx) SetBytes(unsignedBytes []byte) { + tx.unsignedBytes = unsignedBytes +} + +func (*RewardContinuousValidatorTx) InitCtx(*snow.Context) {} + +func (tx *RewardContinuousValidatorTx) Bytes() []byte { + return tx.unsignedBytes +} + +func (*RewardContinuousValidatorTx) InputIDs() set.Set[ids.ID] { + return nil +} + +func (*RewardContinuousValidatorTx) Outputs() []*avax.TransferableOutput { + return nil +} + +func (tx *RewardContinuousValidatorTx) SyntacticVerify(*snow.Context) error { + switch { + case tx == nil: + return ErrNilTx + case tx.TxID == ids.Empty: + return errMissingTxId + case tx.Timestamp == 0: + return errMissingTimestamp + } + + return nil +} + +func (tx *RewardContinuousValidatorTx) Visit(visitor Visitor) error { + return visitor.RewardContinuousValidatorTx(tx) +} diff --git a/vms/platformvm/txs/reward_continuous_validator_tx_test.go b/vms/platformvm/txs/reward_continuous_validator_tx_test.go new file mode 100644 index 000000000000..fec6c0d062b8 --- /dev/null +++ b/vms/platformvm/txs/reward_continuous_validator_tx_test.go @@ -0,0 +1,86 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" +) + +func TestRewardContinuousValidatorTxSyntacticVerify(t *testing.T) { + require := require.New(t) + + type test struct { + name string + txFunc func(*gomock.Controller) *RewardContinuousValidatorTx + err error + } + + ctx := &snow.Context{ + ChainID: ids.GenerateTestID(), + NetworkID: uint32(1337), + } + + tests := []test{ + { + name: "nil tx", + txFunc: func(*gomock.Controller) *RewardContinuousValidatorTx { + return nil + }, + err: ErrNilTx, + }, + { + name: "missing timestamp", + txFunc: func(*gomock.Controller) *RewardContinuousValidatorTx { + return &RewardContinuousValidatorTx{ + TxID: ids.GenerateTestID(), + Timestamp: 0, + } + }, + err: errMissingTimestamp, + }, + { + name: "missing tx id", + txFunc: func(*gomock.Controller) *RewardContinuousValidatorTx { + return &RewardContinuousValidatorTx{ + Timestamp: 1, + } + }, + err: errMissingTxId, + }, + { + name: "valid continuous validator", + txFunc: func(ctrl *gomock.Controller) *RewardContinuousValidatorTx { + return &RewardContinuousValidatorTx{ + TxID: ids.GenerateTestID(), + Timestamp: 1, + } + }, + err: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + tx := tt.txFunc(ctrl) + err := tx.SyntacticVerify(ctx) + require.ErrorIs(err, tt.err) + }) + } +} + +func TestAddContinuousValidatorIsUnsignedTx(t *testing.T) { + require := require.New(t) + + txIntf := any((*RewardContinuousValidatorTx)(nil)) + _, ok := txIntf.(UnsignedTx) + require.True(ok) +} diff --git a/vms/platformvm/txs/stop_continuous_validator_tx_test.go b/vms/platformvm/txs/stop_continuous_validator_tx_test.go new file mode 100644 index 000000000000..0bc0981ccf13 --- /dev/null +++ b/vms/platformvm/txs/stop_continuous_validator_tx_test.go @@ -0,0 +1,6 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +// todo: diff --git a/vms/platformvm/txs/tx.go b/vms/platformvm/txs/tx.go index b0b753fe3b5f..77722463eaf8 100644 --- a/vms/platformvm/txs/tx.go +++ b/vms/platformvm/txs/tx.go @@ -163,8 +163,6 @@ func (tx *Tx) Sign(c codec.Manager, signers [][]*secp256k1.PrivateKey) error { tx.Creds = append(tx.Creds, cred) // Attach credential } - tx.UTXOs() - signedBytes, err := c.Marshal(CodecVersion, tx) if err != nil { return fmt.Errorf("couldn't marshal ProposalTx: %w", err) diff --git a/vms/platformvm/txs/visitor.go b/vms/platformvm/txs/visitor.go index 4e8dc34d6791..8b496c44c574 100644 --- a/vms/platformvm/txs/visitor.go +++ b/vms/platformvm/txs/visitor.go @@ -36,4 +36,5 @@ type Visitor interface { // ? Transactions: AddContinuousValidatorTx(tx *AddContinuousValidatorTx) error StopContinuousValidatorTx(tx *StopContinuousValidatorTx) error + RewardContinuousValidatorTx(*RewardContinuousValidatorTx) error } diff --git a/wallet/chain/p/signer/visitor.go b/wallet/chain/p/signer/visitor.go index baa0ebdf9803..c23aa025dcfe 100644 --- a/wallet/chain/p/signer/visitor.go +++ b/wallet/chain/p/signer/visitor.go @@ -51,6 +51,10 @@ func (*visitor) RewardValidatorTx(*txs.RewardValidatorTx) error { return ErrUnsupportedTxType } +func (*visitor) RewardContinuousValidatorTx(tx *txs.RewardContinuousValidatorTx) error { + return ErrUnsupportedTxType +} + func (s *visitor) AddValidatorTx(tx *txs.AddValidatorTx) error { txSigners, err := s.getSigners(constants.PlatformChainID, tx.Ins) if err != nil { diff --git a/wallet/chain/p/wallet/backend_visitor.go b/wallet/chain/p/wallet/backend_visitor.go index c516a092c366..5fd0e52781f1 100644 --- a/wallet/chain/p/wallet/backend_visitor.go +++ b/wallet/chain/p/wallet/backend_visitor.go @@ -38,6 +38,10 @@ func (*backendVisitor) RewardValidatorTx(*txs.RewardValidatorTx) error { return ErrUnsupportedTxType } +func (*backendVisitor) RewardContinuousValidatorTx(tx *txs.RewardContinuousValidatorTx) error { + return ErrUnsupportedTxType +} + func (b *backendVisitor) AddValidatorTx(tx *txs.AddValidatorTx) error { return b.baseTx(&tx.BaseTx) } From 99e3f6004d30ecb2965ca46b659aa48c6ad5fc46 Mon Sep 17 00:00:00 2001 From: "razvan.angheluta" Date: Tue, 7 Oct 2025 23:06:33 +0300 Subject: [PATCH 3/3] adding tests + sentinel errors --- vms/platformvm/state/diff.go | 11 +- vms/platformvm/state/diff_test.go | 282 ++++++++++++++++++ vms/platformvm/state/mock_chain.go | 8 +- vms/platformvm/state/mock_diff.go | 8 +- vms/platformvm/state/mock_state.go | 8 +- vms/platformvm/state/staker.go | 21 +- vms/platformvm/state/stakers.go | 31 +- vms/platformvm/state/state.go | 18 +- vms/platformvm/state/state_test.go | 281 ++++++++++++++++- .../txs/executor/proposal_tx_executor.go | 5 - 10 files changed, 598 insertions(+), 75 deletions(-) diff --git a/vms/platformvm/state/diff.go b/vms/platformvm/state/diff.go index 3e422c4058c5..7cd3a292398a 100644 --- a/vms/platformvm/state/diff.go +++ b/vms/platformvm/state/diff.go @@ -311,7 +311,6 @@ func (d *diff) PutCurrentValidator(staker *Staker) error { return d.currentStakerDiffs.PutValidator(staker) } -// todo: add test for this func (d *diff) UpdateCurrentValidator(staker *Staker) error { parentState, ok := d.stateVersions.GetState(d.parentID) if !ok { @@ -332,7 +331,7 @@ func (d *diff) StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) error return d.currentStakerDiffs.updateValidator(parentState, subnetID, nodeID, func(validator Staker) (*Staker, error) { if validator.ContinuationPeriod == 0 { - return nil, fmt.Errorf("validator %s is not in a continuous staker cycle", nodeID) + return nil, errIncompatibleContinuousStaker } validator.ContinuationPeriod = 0 @@ -341,11 +340,9 @@ func (d *diff) StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) error }) } -// todo: add test for this func (d *diff) ResetContinuousValidatorCycle( subnetID ids.ID, nodeID ids.NodeID, - startTime time.Time, weight uint64, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64, ) error { @@ -355,7 +352,11 @@ func (d *diff) ResetContinuousValidatorCycle( } return d.currentStakerDiffs.updateValidator(parentState, subnetID, nodeID, func(validator Staker) (*Staker, error) { - if err := validator.resetContinuationStakerCycle(startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards); err != nil { + if validator.ContinuationPeriod == 0 { + return nil, errIncompatibleContinuousStaker + } + + if err := validator.resetContinuationStakerCycle(weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards); err != nil { return nil, err } diff --git a/vms/platformvm/state/diff_test.go b/vms/platformvm/state/diff_test.go index a036ae56e9e9..59b34f2aab08 100644 --- a/vms/platformvm/state/diff_test.go +++ b/vms/platformvm/state/diff_test.go @@ -16,6 +16,7 @@ import ( "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils" "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/crypto/bls/signer/localsigner" "github.com/ava-labs/avalanchego/utils/iterator" "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/avalanchego/vms/components/avax" @@ -40,11 +41,15 @@ func TestDiffMissingState(t *testing.T) { func TestMutatedValidatorDiffState(t *testing.T) { require := require.New(t) + blsKey, err := localsigner.New() + require.NoError(err) + state := newTestState(t, memdb.New()) // Put a current validator currentValidator := &Staker{ TxID: ids.GenerateTestID(), + PublicKey: blsKey.PublicKey(), SubnetID: ids.GenerateTestID(), NodeID: ids.GenerateTestNodeID(), Weight: 100, @@ -1074,3 +1079,280 @@ func TestDiffStacking(t *testing.T) { require.NoError(err) require.Equal(owner3, owner) } + +func TestDiffUpdateValidator(t *testing.T) { + tests := []struct { + name string + updateValidator func(*Staker) + updateState func(*require.Assertions, Diff) + expectedErr error + }{ + { + name: "invalid mutation", + updateValidator: func(validator *Staker) { + validator.Weight = 5 + }, + expectedErr: errInvalidStakerMutation, + }, + { + name: "missing validator", + updateValidator: func(validator *Staker) { + validator.NodeID = ids.GenerateTestNodeID() + }, + expectedErr: database.ErrNotFound, + }, + { + name: "deleted validator", + updateState: func(require *require.Assertions, diff Diff) { + currentStakerIterator, err := diff.GetCurrentStakerIterator() + require.NoError(err) + require.True(currentStakerIterator.Next()) + + stakerToRemove := currentStakerIterator.Value() + currentStakerIterator.Release() + + diff.DeleteCurrentValidator(stakerToRemove) + }, + expectedErr: database.ErrNotFound, + }, + { + name: "valid mutation", + updateValidator: func(validator *Staker) { + validator.Weight = 15 + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + + state := newTestState(t, memdb.New()) + + blsKey, err := localsigner.New() + require.NoError(err) + + currentValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: ids.GenerateTestID(), + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + } + require.NoError(state.PutCurrentValidator(currentValidator)) + + d, err := NewDiffOn(state) + require.NoError(err) + + if test.updateState != nil { + test.updateState(require, d) + } + + validator := *currentValidator + if test.updateValidator != nil { + test.updateValidator(&validator) + } + + require.ErrorIs(d.UpdateCurrentValidator(&validator), test.expectedErr) + }) + } +} + +func TestDiffStopContinuousValidator(t *testing.T) { + require := require.New(t) + + subnetID := ids.GenerateTestID() + + state := newTestState(t, memdb.New()) + d, err := NewDiffOn(state) + + blsKey, err := localsigner.New() + require.NoError(err) + + fixedValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: subnetID, + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + } + require.NoError(d.PutCurrentValidator(fixedValidator)) + + require.ErrorIs(d.StopContinuousValidator(subnetID, ids.GenerateTestNodeID()), database.ErrNotFound) + require.ErrorIs(d.StopContinuousValidator(subnetID, fixedValidator.NodeID), errIncompatibleContinuousStaker) + + blsKey, err = localsigner.New() + require.NoError(err) + + continuousValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: subnetID, + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + ContinuationPeriod: 14 * 24 * time.Hour, + } + require.NoError(d.PutCurrentValidator(continuousValidator)) + + require.NoError(d.StopContinuousValidator(subnetID, continuousValidator.NodeID)) + + validator, err := d.GetCurrentValidator(subnetID, continuousValidator.NodeID) + require.NoError(err) + + require.Equal(continuousValidator.Weight, validator.Weight) + require.Equal(continuousValidator.PotentialReward, validator.PotentialReward) + require.Equal(continuousValidator.AccruedRewards, validator.AccruedRewards) + require.Equal(continuousValidator.AccruedDelegateeRewards, validator.AccruedDelegateeRewards) + require.Equal(continuousValidator.StartTime, validator.StartTime) + require.Equal(continuousValidator.EndTime, validator.EndTime) + require.Equal(time.Duration(0), validator.ContinuationPeriod) + + require.ErrorIs(d.StopContinuousValidator(subnetID, continuousValidator.NodeID), errIncompatibleContinuousStaker) +} + +func TestDiffResetContinuousValidatorCycleValidation(t *testing.T) { + require := require.New(t) + + subnetID := ids.GenerateTestID() + + state := newTestState(t, memdb.New()) + d, err := NewDiffOn(state) + + blsKey, err := localsigner.New() + require.NoError(err) + + continuousValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: subnetID, + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + PotentialReward: 100, + AccruedRewards: 10, + AccruedDelegateeRewards: 5, + NextTime: time.Time{}, + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + ContinuationPeriod: 14 * 24 * time.Hour, + } + require.NoError(d.PutCurrentValidator(continuousValidator)) + + tests := []struct { + name string + expectedErr error + subnetID ids.ID + nodeID ids.NodeID + weight uint64 + potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64 + }{ + { + name: "decreased accrued rewards", + subnetID: subnetID, + nodeID: continuousValidator.NodeID, + weight: continuousValidator.Weight, + potentialReward: continuousValidator.PotentialReward, + totalAccruedRewards: continuousValidator.AccruedRewards - 1, + totalAccruedDelegateeRewards: continuousValidator.AccruedDelegateeRewards, + expectedErr: errDecreasedAccruedRewards, + }, + { + name: "decreased accrued delegatee rewards", + subnetID: subnetID, + nodeID: continuousValidator.NodeID, + weight: continuousValidator.Weight, + potentialReward: continuousValidator.PotentialReward, + totalAccruedRewards: continuousValidator.AccruedRewards, + totalAccruedDelegateeRewards: continuousValidator.AccruedDelegateeRewards - 1, + expectedErr: errDecreasedAccruedDelegateeRewards, + }, + { + name: "decreased weight", + subnetID: subnetID, + nodeID: continuousValidator.NodeID, + weight: continuousValidator.Weight - 1, + potentialReward: continuousValidator.PotentialReward, + totalAccruedRewards: continuousValidator.AccruedRewards, + totalAccruedDelegateeRewards: continuousValidator.AccruedDelegateeRewards, + expectedErr: errDecreasedWeight, + }, + } + + for _, test := range tests { + err = d.ResetContinuousValidatorCycle( + subnetID, + test.nodeID, + test.weight, + test.potentialReward, + test.totalAccruedRewards, + test.totalAccruedDelegateeRewards, + ) + + require.ErrorIs(err, test.expectedErr) + } +} + +func TestDiffResetContinuousValidatorCycle(t *testing.T) { + require := require.New(t) + + subnetID := ids.GenerateTestID() + + state := newTestState(t, memdb.New()) + d, err := NewDiffOn(state) + + blsKey, err := localsigner.New() + require.NoError(err) + + continuousValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: subnetID, + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + PotentialReward: 100, + AccruedRewards: 10, + AccruedDelegateeRewards: 5, + NextTime: time.Time{}, + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + ContinuationPeriod: 14 * 24 * time.Hour, + } + require.NoError(d.PutCurrentValidator(continuousValidator)) + + newWeight := continuousValidator.Weight + 10 + newPotentialReward := continuousValidator.PotentialReward + 15 + newAccruedRewards := continuousValidator.AccruedRewards + 20 + newAccruedDelegateeRewards := continuousValidator.AccruedDelegateeRewards + 25 + + expectedStartTime := continuousValidator.EndTime + expectedEndTime := continuousValidator.EndTime.Add(continuousValidator.ContinuationPeriod) + err = d.ResetContinuousValidatorCycle( + subnetID, + continuousValidator.NodeID, + newWeight, + newPotentialReward, + newAccruedRewards, + newAccruedDelegateeRewards, + ) + require.NoError(err) + + continuousValidator, err = d.GetCurrentValidator(subnetID, continuousValidator.NodeID) + require.NoError(err) + + require.Equal(newWeight, continuousValidator.Weight) + require.Equal(newPotentialReward, continuousValidator.PotentialReward) + require.Equal(newAccruedRewards, continuousValidator.AccruedRewards) + require.Equal(newAccruedDelegateeRewards, continuousValidator.AccruedDelegateeRewards) + require.Equal(expectedStartTime, continuousValidator.StartTime) + require.Equal(expectedEndTime, continuousValidator.EndTime) +} diff --git a/vms/platformvm/state/mock_chain.go b/vms/platformvm/state/mock_chain.go index 657660578653..0d68334481a7 100644 --- a/vms/platformvm/state/mock_chain.go +++ b/vms/platformvm/state/mock_chain.go @@ -611,17 +611,17 @@ func (mr *MockChainMockRecorder) PutPendingValidator(staker any) *gomock.Call { } // ResetContinuousValidatorCycle mocks base method. -func (m *MockChain) ResetContinuousValidatorCycle(subnetID ids.ID, nodeID ids.NodeID, startTime time.Time, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { +func (m *MockChain) ResetContinuousValidatorCycle(subnetID ids.ID, nodeID ids.NodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ResetContinuousValidatorCycle", subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) + ret := m.ctrl.Call(m, "ResetContinuousValidatorCycle", subnetID, nodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) ret0, _ := ret[0].(error) return ret0 } // ResetContinuousValidatorCycle indicates an expected call of ResetContinuousValidatorCycle. -func (mr *MockChainMockRecorder) ResetContinuousValidatorCycle(subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards any) *gomock.Call { +func (mr *MockChainMockRecorder) ResetContinuousValidatorCycle(subnetID, nodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetContinuousValidatorCycle", reflect.TypeOf((*MockChain)(nil).ResetContinuousValidatorCycle), subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetContinuousValidatorCycle", reflect.TypeOf((*MockChain)(nil).ResetContinuousValidatorCycle), subnetID, nodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) } // SetAccruedFees mocks base method. diff --git a/vms/platformvm/state/mock_diff.go b/vms/platformvm/state/mock_diff.go index d076a9c0b003..372f6c962d47 100644 --- a/vms/platformvm/state/mock_diff.go +++ b/vms/platformvm/state/mock_diff.go @@ -625,17 +625,17 @@ func (mr *MockDiffMockRecorder) PutPendingValidator(staker any) *gomock.Call { } // ResetContinuousValidatorCycle mocks base method. -func (m *MockDiff) ResetContinuousValidatorCycle(subnetID ids.ID, nodeID ids.NodeID, startTime time.Time, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { +func (m *MockDiff) ResetContinuousValidatorCycle(subnetID ids.ID, nodeID ids.NodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ResetContinuousValidatorCycle", subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) + ret := m.ctrl.Call(m, "ResetContinuousValidatorCycle", subnetID, nodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) ret0, _ := ret[0].(error) return ret0 } // ResetContinuousValidatorCycle indicates an expected call of ResetContinuousValidatorCycle. -func (mr *MockDiffMockRecorder) ResetContinuousValidatorCycle(subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards any) *gomock.Call { +func (mr *MockDiffMockRecorder) ResetContinuousValidatorCycle(subnetID, nodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetContinuousValidatorCycle", reflect.TypeOf((*MockDiff)(nil).ResetContinuousValidatorCycle), subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetContinuousValidatorCycle", reflect.TypeOf((*MockDiff)(nil).ResetContinuousValidatorCycle), subnetID, nodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) } // SetAccruedFees mocks base method. diff --git a/vms/platformvm/state/mock_state.go b/vms/platformvm/state/mock_state.go index 2c2233ec157e..ffb976b9e37f 100644 --- a/vms/platformvm/state/mock_state.go +++ b/vms/platformvm/state/mock_state.go @@ -877,17 +877,17 @@ func (mr *MockStateMockRecorder) ReindexBlocks(lock, log any) *gomock.Call { } // ResetContinuousValidatorCycle mocks base method. -func (m *MockState) ResetContinuousValidatorCycle(subnetID ids.ID, nodeID ids.NodeID, startTime time.Time, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { +func (m *MockState) ResetContinuousValidatorCycle(subnetID ids.ID, nodeID ids.NodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ResetContinuousValidatorCycle", subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) + ret := m.ctrl.Call(m, "ResetContinuousValidatorCycle", subnetID, nodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) ret0, _ := ret[0].(error) return ret0 } // ResetContinuousValidatorCycle indicates an expected call of ResetContinuousValidatorCycle. -func (mr *MockStateMockRecorder) ResetContinuousValidatorCycle(subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards any) *gomock.Call { +func (mr *MockStateMockRecorder) ResetContinuousValidatorCycle(subnetID, nodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetContinuousValidatorCycle", reflect.TypeOf((*MockState)(nil).ResetContinuousValidatorCycle), subnetID, nodeID, startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetContinuousValidatorCycle", reflect.TypeOf((*MockState)(nil).ResetContinuousValidatorCycle), subnetID, nodeID, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards) } // SetAccruedFees mocks base method. diff --git a/vms/platformvm/state/staker.go b/vms/platformvm/state/staker.go index 95760689e230..0fa0f91893ff 100644 --- a/vms/platformvm/state/staker.go +++ b/vms/platformvm/state/staker.go @@ -148,7 +148,6 @@ func NewPendingStaker(txID ids.ID, staker txs.ScheduledStaker) (*Staker, error) }, nil } -// todo: test this func (s *Staker) ValidMutation(ms Staker) error { if s.ContinuationPeriod != ms.ContinuationPeriod && ms.ContinuationPeriod != 0 { // Only transition allowed for continuation period is setting it to 0. @@ -182,27 +181,22 @@ func (s *Staker) ValidMutation(ms Staker) error { return nil } -// todo: test this -func (s *Staker) resetContinuationStakerCycle(startTime time.Time, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { - if s.ContinuationPeriod == 0 { - return fmt.Errorf("cannot reset a non-continuous validator") - } - +func (s *Staker) resetContinuationStakerCycle(weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { if totalAccruedRewards < s.AccruedRewards { - return fmt.Errorf("accrued rewards cannot be less than current value") + return errDecreasedAccruedRewards } if totalAccruedDelegateeRewards < s.AccruedDelegateeRewards { - return fmt.Errorf("accrued delegatee rewards cannot be less than current value") + return errDecreasedAccruedDelegateeRewards } if weight < s.Weight { - return fmt.Errorf("weight cannot be less than current value") + return errDecreasedWeight } - endTime := startTime.Add(s.ContinuationPeriod) + endTime := s.EndTime.Add(s.ContinuationPeriod) - s.StartTime = startTime + s.StartTime = s.EndTime s.EndTime = endTime s.PotentialReward = potentialReward s.AccruedRewards = totalAccruedRewards @@ -212,9 +206,8 @@ func (s *Staker) resetContinuationStakerCycle(startTime time.Time, weight, poten return nil } -// todo: test this func (s Staker) immutableFieldsAreUnmodified(ms Staker) bool { - // Mutable fields: Weight, StartTime, EndTime, PotentialReward, AccruedRewards, ContinuationPeriod + // Mutable fields: Weight, StartTime, EndTime, PotentialReward, AccruedRewards, AccruedDelegateeRewards, ContinuationPeriod return s.TxID == ms.TxID && s.NodeID == ms.NodeID && s.PublicKey.Equals(ms.PublicKey) && diff --git a/vms/platformvm/state/stakers.go b/vms/platformvm/state/stakers.go index b43047415ab7..c7b0ed31a08c 100644 --- a/vms/platformvm/state/stakers.go +++ b/vms/platformvm/state/stakers.go @@ -6,7 +6,6 @@ package state import ( "errors" "fmt" - "time" "github.com/google/btree" @@ -15,7 +14,11 @@ import ( "github.com/ava-labs/avalanchego/utils/iterator" ) -var ErrAddingStakerAfterDeletion = errors.New("attempted to add a staker after deleting it") +var ( + ErrAddingStakerAfterDeletion = errors.New("attempted to add a staker after deleting it") + errInvalidStakerMutation = errors.New("invalid staker mutation") + errIncompatibleContinuousStaker = errors.New("incompatible continuous staker state") +) type Stakers interface { CurrentStakers @@ -116,7 +119,6 @@ type ContinuousStakers interface { ResetContinuousValidatorCycle( subnetID ids.ID, nodeID ids.NodeID, - startTime time.Time, weight uint64, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64, ) error @@ -181,14 +183,15 @@ func (v *baseStakers) DeleteValidator(staker *Staker) { v.stakers.Delete(staker) } -func (v *baseStakers) UpdateValidator( +// Invariant: [getMutatedValidator] returns a non-nil Staker. +func (v *baseStakers) updateValidator( subnetID ids.ID, nodeID ids.NodeID, getMutatedValidator func(Staker) (*Staker, error), ) error { validator := v.getOrCreateValidator(subnetID, nodeID) if validator.validator == nil { - return fmt.Errorf("validator %s does not exist", nodeID) + return database.ErrNotFound } mutatedValidator, err := getMutatedValidator(*validator.validator) @@ -196,12 +199,8 @@ func (v *baseStakers) UpdateValidator( return err } - if mutatedValidator == nil { - return fmt.Errorf("mutated validator cannot be nil") - } - if err := validator.validator.ValidMutation(*mutatedValidator); err != nil { - return err + return fmt.Errorf("%w: %w", errInvalidStakerMutation, err) } validatorDiff := v.getOrCreateValidatorDiff(subnetID, nodeID) @@ -431,21 +430,16 @@ func (s *diffStakers) updateValidator( switch validatorDiff.validatorStatus { case deleted: - return fmt.Errorf("validator %s updated after deletion", nodeID) + return database.ErrNotFound case added, modified: - if validatorDiff.validator == nil { - // This shouldn't happen. - return fmt.Errorf("validator %s is missing for update", nodeID) - } - mutatedValidator, err := getMutatedValidator(*validatorDiff.validator) if err != nil { return err } if err := validatorDiff.validator.ValidMutation(*mutatedValidator); err != nil { - return err + return fmt.Errorf("%w: %w", errInvalidStakerMutation, err) } // Keep the same validatorDiff.validatorStatus. @@ -470,7 +464,7 @@ func (s *diffStakers) updateValidator( } if err := validator.ValidMutation(*mutatedValidator); err != nil { - return err + return fmt.Errorf("%w: %w", errInvalidStakerMutation, err) } validatorDiff.validator = mutatedValidator @@ -478,6 +472,7 @@ func (s *diffStakers) updateValidator( validatorDiff.oldValidator = validator default: + // This shouldn't happen. return fmt.Errorf("unknown validator status (%s) for %s", validatorDiff.validatorStatus, nodeID) } diff --git a/vms/platformvm/state/state.go b/vms/platformvm/state/state.go index 46424745c601..b633b5d7c630 100644 --- a/vms/platformvm/state/state.go +++ b/vms/platformvm/state/state.go @@ -958,18 +958,16 @@ func (s *state) PutCurrentValidator(staker *Staker) error { return nil } -// todo: add test for this func (s *state) UpdateCurrentValidator(staker *Staker) error { - return s.currentStakers.UpdateValidator(staker.SubnetID, staker.NodeID, func(validator Staker) (*Staker, error) { + return s.currentStakers.updateValidator(staker.SubnetID, staker.NodeID, func(validator Staker) (*Staker, error) { return staker, nil }) } -// todo: add test for this func (s *state) StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) error { - return s.currentStakers.UpdateValidator(subnetID, nodeID, func(validator Staker) (*Staker, error) { + return s.currentStakers.updateValidator(subnetID, nodeID, func(validator Staker) (*Staker, error) { if validator.ContinuationPeriod == 0 { - return nil, fmt.Errorf("validator %s is not a continuous staker", nodeID) + return nil, errIncompatibleContinuousStaker } validator.ContinuationPeriod = 0 @@ -977,16 +975,18 @@ func (s *state) StopContinuousValidator(subnetID ids.ID, nodeID ids.NodeID) erro }) } -// todo: add test for this func (s *state) ResetContinuousValidatorCycle( subnetID ids.ID, nodeID ids.NodeID, - startTime time.Time, weight uint64, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64, ) error { - return s.currentStakers.UpdateValidator(subnetID, nodeID, func(validator Staker) (*Staker, error) { - if err := validator.resetContinuationStakerCycle(startTime, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards); err != nil { + return s.currentStakers.updateValidator(subnetID, nodeID, func(validator Staker) (*Staker, error) { + if validator.ContinuationPeriod == 0 { + return nil, errIncompatibleContinuousStaker + } + + if err := validator.resetContinuationStakerCycle(weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards); err != nil { return nil, err } diff --git a/vms/platformvm/state/state_test.go b/vms/platformvm/state/state_test.go index 018492dcc0e3..eb5baee4f12f 100644 --- a/vms/platformvm/state/state_test.go +++ b/vms/platformvm/state/state_test.go @@ -2358,16 +2358,273 @@ func TestGetCurrentValidators(t *testing.T) { } } -func TestContinuousValidatorsLifecycle(t *testing.T) { - // todo: implement - //validator, err := env.state.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) - //require.NoError(err) - //require.Equal(time.Duration(0), validator.ContinuationPeriod) - // - //require.NoError(diff.Apply(env.state)) - //require.NoError(env.state.Commit()) - // - //validator, err = onCommitState.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) - //require.NoError(err) - //require.Equal(time.Duration(0), validator.ContinuationPeriod) +func TestStateUpdateValidator(t *testing.T) { + tests := []struct { + name string + updateValidator func(*Staker) + updateState func(*require.Assertions, State) + expectedErr error + }{ + { + name: "invalid mutation", + updateValidator: func(validator *Staker) { + validator.Weight = 5 + }, + expectedErr: errInvalidStakerMutation, + }, + { + name: "missing validator", + updateValidator: func(validator *Staker) { + validator.NodeID = ids.GenerateTestNodeID() + }, + expectedErr: database.ErrNotFound, + }, + { + name: "deleted validator", + updateState: func(require *require.Assertions, state State) { + currentStakerIterator, err := state.GetCurrentStakerIterator() + require.NoError(err) + require.True(currentStakerIterator.Next()) + + stakerToRemove := currentStakerIterator.Value() + currentStakerIterator.Release() + + state.DeleteCurrentValidator(stakerToRemove) + }, + expectedErr: database.ErrNotFound, + }, + { + name: "valid mutation", + updateValidator: func(validator *Staker) { + validator.Weight = 15 + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + + state := newTestState(t, memdb.New()) + + blsKey, err := localsigner.New() + require.NoError(err) + + currentValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: ids.GenerateTestID(), + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + } + require.NoError(state.PutCurrentValidator(currentValidator)) + + if test.updateState != nil { + test.updateState(require, state) + } + + validator := *currentValidator + if test.updateValidator != nil { + test.updateValidator(&validator) + } + + require.ErrorIs(state.UpdateCurrentValidator(&validator), test.expectedErr) + }) + } +} + +func TestStateStopContinuousValidator(t *testing.T) { + require := require.New(t) + + subnetID := ids.GenerateTestID() + + state := newTestState(t, memdb.New()) + + blsKey, err := localsigner.New() + require.NoError(err) + + fixedValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: subnetID, + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + } + require.NoError(state.PutCurrentValidator(fixedValidator)) + + require.ErrorIs(state.StopContinuousValidator(subnetID, ids.GenerateTestNodeID()), database.ErrNotFound) + require.ErrorIs(state.StopContinuousValidator(subnetID, fixedValidator.NodeID), errIncompatibleContinuousStaker) + + blsKey, err = localsigner.New() + require.NoError(err) + + continuousValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: subnetID, + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + ContinuationPeriod: 14 * 24 * time.Hour, + } + require.NoError(state.PutCurrentValidator(continuousValidator)) + + require.NoError(state.StopContinuousValidator(subnetID, continuousValidator.NodeID)) + + validator, err := state.GetCurrentValidator(subnetID, continuousValidator.NodeID) + require.NoError(err) + + require.Equal(continuousValidator.Weight, validator.Weight) + require.Equal(continuousValidator.PotentialReward, validator.PotentialReward) + require.Equal(continuousValidator.AccruedRewards, validator.AccruedRewards) + require.Equal(continuousValidator.AccruedDelegateeRewards, validator.AccruedDelegateeRewards) + require.Equal(continuousValidator.StartTime, validator.StartTime) + require.Equal(continuousValidator.EndTime, validator.EndTime) + require.Equal(time.Duration(0), validator.ContinuationPeriod) + + require.ErrorIs(state.StopContinuousValidator(subnetID, continuousValidator.NodeID), errIncompatibleContinuousStaker) +} + +func TestStateResetContinuousValidatorCycleValidation(t *testing.T) { + require := require.New(t) + + subnetID := ids.GenerateTestID() + + state := newTestState(t, memdb.New()) + + blsKey, err := localsigner.New() + require.NoError(err) + + continuousValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: subnetID, + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + PotentialReward: 100, + AccruedRewards: 10, + AccruedDelegateeRewards: 5, + NextTime: time.Time{}, + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + ContinuationPeriod: 14 * 24 * time.Hour, + } + require.NoError(state.PutCurrentValidator(continuousValidator)) + + tests := []struct { + name string + expectedErr error + subnetID ids.ID + nodeID ids.NodeID + weight uint64 + potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64 + }{ + { + name: "decreased accrued rewards", + subnetID: subnetID, + nodeID: continuousValidator.NodeID, + weight: continuousValidator.Weight, + potentialReward: continuousValidator.PotentialReward, + totalAccruedRewards: continuousValidator.AccruedRewards - 1, + totalAccruedDelegateeRewards: continuousValidator.AccruedDelegateeRewards, + expectedErr: errDecreasedAccruedRewards, + }, + { + name: "decreased accrued delegatee rewards", + subnetID: subnetID, + nodeID: continuousValidator.NodeID, + weight: continuousValidator.Weight, + potentialReward: continuousValidator.PotentialReward, + totalAccruedRewards: continuousValidator.AccruedRewards, + totalAccruedDelegateeRewards: continuousValidator.AccruedDelegateeRewards - 1, + expectedErr: errDecreasedAccruedDelegateeRewards, + }, + { + name: "decreased weight", + subnetID: subnetID, + nodeID: continuousValidator.NodeID, + weight: continuousValidator.Weight - 1, + potentialReward: continuousValidator.PotentialReward, + totalAccruedRewards: continuousValidator.AccruedRewards, + totalAccruedDelegateeRewards: continuousValidator.AccruedDelegateeRewards, + expectedErr: errDecreasedWeight, + }, + } + + for _, test := range tests { + err = state.ResetContinuousValidatorCycle( + subnetID, + test.nodeID, + test.weight, + test.potentialReward, + test.totalAccruedRewards, + test.totalAccruedDelegateeRewards, + ) + + require.ErrorIs(err, test.expectedErr) + } +} + +func TestStateResetContinuousValidatorCycle(t *testing.T) { + require := require.New(t) + + subnetID := ids.GenerateTestID() + + state := newTestState(t, memdb.New()) + + blsKey, err := localsigner.New() + require.NoError(err) + + continuousValidator := &Staker{ + TxID: ids.GenerateTestID(), + NodeID: ids.GenerateTestNodeID(), + PublicKey: blsKey.PublicKey(), + SubnetID: subnetID, + Weight: 10, + StartTime: time.Unix(1, 0), + EndTime: time.Unix(2, 0), + PotentialReward: 100, + AccruedRewards: 10, + AccruedDelegateeRewards: 5, + NextTime: time.Time{}, + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + ContinuationPeriod: 14 * 24 * time.Hour, + } + require.NoError(state.PutCurrentValidator(continuousValidator)) + + newWeight := continuousValidator.Weight + 10 + newPotentialReward := continuousValidator.PotentialReward + 15 + newAccruedRewards := continuousValidator.AccruedRewards + 20 + newAccruedDelegateeRewards := continuousValidator.AccruedDelegateeRewards + 25 + + expectedStartTime := continuousValidator.EndTime + expectedEndTime := continuousValidator.EndTime.Add(continuousValidator.ContinuationPeriod) + err = state.ResetContinuousValidatorCycle( + subnetID, + continuousValidator.NodeID, + newWeight, + newPotentialReward, + newAccruedRewards, + newAccruedDelegateeRewards, + ) + require.NoError(err) + + continuousValidator, err = state.GetCurrentValidator(subnetID, continuousValidator.NodeID) + require.NoError(err) + + require.Equal(newWeight, continuousValidator.Weight) + require.Equal(newPotentialReward, continuousValidator.PotentialReward) + require.Equal(newAccruedRewards, continuousValidator.AccruedRewards) + require.Equal(newAccruedDelegateeRewards, continuousValidator.AccruedDelegateeRewards) + require.Equal(expectedStartTime, continuousValidator.StartTime) + require.Equal(expectedEndTime, continuousValidator.EndTime) } diff --git a/vms/platformvm/txs/executor/proposal_tx_executor.go b/vms/platformvm/txs/executor/proposal_tx_executor.go index a929afab9775..6a133ec4ba68 100644 --- a/vms/platformvm/txs/executor/proposal_tx_executor.go +++ b/vms/platformvm/txs/executor/proposal_tx_executor.go @@ -494,8 +494,6 @@ func (e *proposalTxExecutor) RewardContinuousValidatorTx(tx *txs.RewardContinuou return err } - newStartTime := currentChainTime - { // Set onAbortState. delegateeReward, err := e.onCommitState.GetDelegateeReward( @@ -515,7 +513,6 @@ func (e *proposalTxExecutor) RewardContinuousValidatorTx(tx *txs.RewardContinuou newWeight := stakerToReward.Weight if delegateeReward > 0 { - // todo: test this flow newAccruedDelegateeRewards, err = math.Add(stakerToReward.AccruedDelegateeRewards, delegateeReward) if err != nil { return err @@ -586,7 +583,6 @@ func (e *proposalTxExecutor) RewardContinuousValidatorTx(tx *txs.RewardContinuou err = e.onAbortState.ResetContinuousValidatorCycle( stakerToReward.SubnetID, stakerToReward.NodeID, - newStartTime, newWeight, onAbortPotentialReward, stakerToReward.AccruedRewards, @@ -680,7 +676,6 @@ func (e *proposalTxExecutor) RewardContinuousValidatorTx(tx *txs.RewardContinuou err = e.onCommitState.ResetContinuousValidatorCycle( stakerToReward.SubnetID, stakerToReward.NodeID, - newStartTime, newWeight, onCommitPotentialReward, newAccruedRewards,