diff --git a/vms/components/avax/utxo_fetching.go b/vms/components/avax/utxo_fetching.go index eb7cbee4060f..85842a4170cd 100644 --- a/vms/components/avax/utxo_fetching.go +++ b/vms/components/avax/utxo_fetching.go @@ -44,6 +44,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..c8aa7eb93af2 100644 --- a/vms/components/avax/utxo_fetching_test.go +++ b/vms/components/avax/utxo_fetching_test.go @@ -158,3 +158,59 @@ func TestGetPaginatedUTXOs(t *testing.T) { require.NoError(err) require.Len(notPaginatedUTXOs, len(totalUTXOs)) } + +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() + + utxo := &UTXO{ + Asset: Asset{ID: ids.GenerateTestID()}, + Out: &secp256k1fx.TransferOutput{ + Amt: 12345, + OutputOwners: secp256k1fx.OutputOwners{ + Locktime: 54321, + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + }, + } + + utxo.UTXOID = UTXOID{ + TxID: txID, + OutputIndex: 0, + } + 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) + + 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 560854f1d993..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", @@ -173,3 +180,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..7cd3a292398a 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,59 @@ func (d *diff) PutCurrentValidator(staker *Staker) error { return d.currentStakerDiffs.PutValidator(staker) } +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, errIncompatibleContinuousStaker + } + + validator.ContinuationPeriod = 0 + + return &validator, nil + }) +} + +func (d *diff) ResetContinuousValidatorCycle( + subnetID ids.ID, + nodeID ids.NodeID, + 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 validator.ContinuationPeriod == 0 { + return nil, errIncompatibleContinuousStaker + } + + if err := validator.resetContinuationStakerCycle(weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards); err != nil { + return nil, err + } + + return &validator, nil + }) +} + func (d *diff) DeleteCurrentValidator(staker *Staker) { d.currentStakerDiffs.DeleteValidator(staker) } @@ -601,6 +655,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 +688,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..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" @@ -37,6 +38,44 @@ func TestDiffMissingState(t *testing.T) { require.ErrorIs(t, err, ErrMissingParentState) } +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, + 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) @@ -1040,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/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..0d68334481a7 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, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { + m.ctrl.T.Helper() + 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, 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, 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..372f6c962d47 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, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { + m.ctrl.T.Helper() + 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, 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, 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..ffb976b9e37f 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, weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { + m.ctrl.T.Helper() + 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, 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, 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..0fa0f91893ff 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,71 @@ func NewPendingStaker(txID ids.ID, staker txs.ScheduledStaker) (*Staker, error) Priority: staker.PendingPriority(), }, nil } + +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 +} + +func (s *Staker) resetContinuationStakerCycle(weight, potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64) error { + if totalAccruedRewards < s.AccruedRewards { + return errDecreasedAccruedRewards + } + + if totalAccruedDelegateeRewards < s.AccruedDelegateeRewards { + return errDecreasedAccruedDelegateeRewards + } + + if weight < s.Weight { + return errDecreasedWeight + } + + endTime := s.EndTime.Add(s.ContinuationPeriod) + + s.StartTime = s.EndTime + s.EndTime = endTime + s.PotentialReward = potentialReward + s.AccruedRewards = totalAccruedRewards + s.AccruedDelegateeRewards = totalAccruedDelegateeRewards + s.Weight = weight + + return nil +} + +func (s Staker) immutableFieldsAreUnmodified(ms Staker) bool { + // Mutable fields: Weight, StartTime, EndTime, PotentialReward, AccruedRewards, AccruedDelegateeRewards, 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..c7b0ed31a08c 100644 --- a/vms/platformvm/state/stakers.go +++ b/vms/platformvm/state/stakers.go @@ -14,11 +14,16 @@ 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 PendingStakers + ContinuousStakers } type CurrentStakers interface { @@ -39,6 +44,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 +110,20 @@ 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, + 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 +183,37 @@ func (v *baseStakers) DeleteValidator(staker *Staker) { v.stakers.Delete(staker) } +// 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 database.ErrNotFound + } + + mutatedValidator, err := getMutatedValidator(*validator.validator) + if err != nil { + return err + } + + if err := validator.validator.ValidMutation(*mutatedValidator); err != nil { + return fmt.Errorf("%w: %w", errInvalidStakerMutation, 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 +323,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 +335,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 +376,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 +420,65 @@ 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 database.ErrNotFound + + case added, modified: + mutatedValidator, err := getMutatedValidator(*validatorDiff.validator) + if err != nil { + return err + } + + if err := validatorDiff.validator.ValidMutation(*mutatedValidator); err != nil { + return fmt.Errorf("%w: %w", errInvalidStakerMutation, 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 fmt.Errorf("%w: %w", errInvalidStakerMutation, err) + } + + validatorDiff.validator = mutatedValidator + validatorDiff.validatorStatus = modified + validatorDiff.oldValidator = validator + + default: + // This shouldn't happen. + 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..b633b5d7c630 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 } +func (s *state) UpdateCurrentValidator(staker *Staker) error { + return s.currentStakers.updateValidator(staker.SubnetID, staker.NodeID, func(validator Staker) (*Staker, error) { + return staker, nil + }) +} + +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, errIncompatibleContinuousStaker + } + + validator.ContinuationPeriod = 0 + return &validator, nil + }) +} + +func (s *state) ResetContinuousValidatorCycle( + subnetID ids.ID, + nodeID ids.NodeID, + weight uint64, + potentialReward, totalAccruedRewards, totalAccruedDelegateeRewards uint64, +) error { + 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 + } + + 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..eb5baee4f12f 100644 --- a/vms/platformvm/state/state_test.go +++ b/vms/platformvm/state/state_test.go @@ -2357,3 +2357,274 @@ func TestGetCurrentValidators(t *testing.T) { }) } } + +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/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..658acf2bc09c 100644 --- a/vms/platformvm/txs/codec.go +++ b/vms/platformvm/txs/codec.go @@ -127,5 +127,10 @@ func RegisterEtnaTypes(targetCodec linearcodec.Codec) error { targetCodec.RegisterType(&SetL1ValidatorWeightTx{}), targetCodec.RegisterType(&IncreaseL1ValidatorBalanceTx{}), targetCodec.RegisterType(&DisableL1ValidatorTx{}), + + // 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 b1682615feb5..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 } @@ -128,6 +132,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/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 79288ef296b6..6a133ec4ba68 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") @@ -147,6 +151,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 @@ -374,6 +386,10 @@ 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) { @@ -414,6 +430,289 @@ func (e *proposalTxExecutor) RewardValidatorTx(tx *txs.RewardValidatorTx) error 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 + } + + { + // 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 { + newAccruedDelegateeRewards, err = math.Add(stakerToReward.AccruedDelegateeRewards, delegateeReward) + if err != nil { + return err + } + + newWeight, err = math.Add(stakerToReward.Weight, delegateeReward) + if err != nil { + return err + } + + if newWeight > e.backend.Config.MaxValidatorStake { + // Create UTXO for [excessDelegateeReward] + // todo: maybe extract this?! + asset := validatorTx.Stake()[0].Asset + + excessDelegateeReward, err := math.Sub(newWeight, e.backend.Config.MaxValidatorStake) + if err != nil { + return err + } + + outIntf, err := e.backend.Fx.CreateOutput(excessDelegateeReward, validatorTx.DelegationRewardsOwner()) + if err != nil { + return fmt.Errorf("failed to create output: %w", err) + } + + out, ok := outIntf.(verify.State) + if !ok { + return ErrInvalidState + } + + 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) + + newAccruedDelegateeRewards, err = math.Sub(newAccruedDelegateeRewards, excessDelegateeReward) + if err != nil { + return err + } + + newWeight, err = math.Sub(newWeight, excessDelegateeReward) + if err != nil { + return err + } + + // [newWeight] is equal to [e.backend.Config.MaxValidatorStake]. + } + } + + onAbortPotentialReward := rewards.Calculate( + continuousStaker.PeriodDuration(), + newWeight, + 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, + newWeight, + onAbortPotentialReward, + stakerToReward.AccruedRewards, + 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 + } + + 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 { + excessValidationRewards, excessDelegateeRewards, err := e.rewardExcessContinuousValidatorTx( + validatorTx, + newWeight, + delegateeReward, + stakerToReward, + ) + 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, 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, + 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.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 +} + func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, validator *state.Staker) error { var ( txID = validator.TxID @@ -648,3 +947,183 @@ 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) rewardExcessContinuousValidatorTx( + uValidatorTx txs.ValidatorTx, + newWeight uint64, + delegateeReward uint64, + staker *state.Staker, +) (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 + + 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 := float64(excess) / float64(restakingRewards) // < 0 + + excessValidationReward := uint64(math2.Round(excessRatio * float64(staker.PotentialReward))) + excessDelegateeReward := uint64(math2.Round(excessRatio * float64(delegateeReward))) + + 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) + } + + 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) + } + + out, ok := outIntf.(verify.State) + if !ok { + return 0, 0, ErrInvalidState + } + + excessDelegateeRewardUTXO := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: e.tx.ID(), + OutputIndex: 1, + }, + Asset: asset, + Out: out, + } + e.onCommitState.AddUTXO(excessDelegateeRewardUTXO) + e.onCommitState.AddRewardUTXO(e.tx.ID(), excessDelegateeRewardUTXO) + } + + 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 eb860780605b..39635ad4426c 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" @@ -34,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) @@ -878,3 +889,655 @@ func TestRewardDelegatorTxExecuteOnAbort(t *testing.T) { require.NoError(err) 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.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) + vdrNodeID := ids.GenerateTestNodeID() + vdrPotentialReward := uint64(1000000) + vdrWeight := env.config.MinValidatorStake + vdrPeriod := defaultMinStakingDuration + + 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: vdrNodeID, + Wght: vdrWeight, + }, + pop, + env.ctx.AVAXAssetID, + validationRewardOwners, + delegationRewardOwners, + 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)) + avax.Produce(env.state, vdrTx.ID(), vdrTx.Unsigned.Outputs()) + env.state.AddTx(vdrTx, status.Committed) + require.NoError(env.state.Commit()) + + delegateeRewards := uint64(1000) + require.NoError(env.state.SetDelegateeReward(constants.PrimaryNetworkID, vdrStaker.NodeID, delegateeRewards)) + require.NoError(env.state.Commit()) + + // Removing staker too early + 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, 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, + tx, + onCommitState, + onAbortState, + )) + + // Check onAbortState + validator, err := onAbortState.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + require.NoError(err) + + require.Equal(vdrWeight, validator.Weight) + 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) + + expectedValidationRewards := validator.PotentialReward + validator.AccruedRewards + expectedDelegationRewards := validator.AccruedDelegateeRewards + + require.Equal(vdrWeight+vdrPotentialReward+delegateeRewards, validator.Weight) + require.Equal(vdrPotentialReward, validator.AccruedRewards) + 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()) + + // Stop validator + err = env.state.StopContinuousValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + require.NoError(err) + + // Check after being stopped + env.state.SetTimestamp(validator.EndTime) + + tx, err = newRewardContinuousValidatorTx(t, vdrTx.ID(), uint64(env.state.GetTimestamp().Unix())) + 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 := uint32(len(addContValTx.Outputs())) + + // Check stake UTXOs + for _, stake := range addContValTx.Stake() { + stakeUTXOID := &avax.UTXOID{ + TxID: vdrTx.ID(), + OutputIndex: 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: outputIndex, + } + outputIndex++ + + rewardUTXO, err := onCommitState.GetUTXO(rewardUTXOID.InputID()) + require.NoError(err) + require.Equal(env.ctx.AVAXAssetID, rewardUTXO.Asset.AssetID()) + require.Equal(expectedValidationRewards, rewardUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(validationRewardOwners.Equals(&rewardUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + // 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: outputIndex, + } + + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.Error(database.ErrNotFound, err) + } + + // todo: add a test for a staker with previous cycles ended +} + +func TestRewardContinuousValidatorStakerType(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: 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) + + addValTx := vdrTx.Unsigned.(*txs.AddPermissionlessValidatorTx) + + 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 := 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: 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) + + addValTx := vdrTx.Unsigned.(*txs.AddPermissionlessValidatorTx) + + 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 := 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()) + + 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) + 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 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()) + + validator, err := env.state.GetCurrentValidator(vdrStaker.SubnetID, vdrStaker.NodeID) + require.NoError(err) + + require.Equal(env.config.MaxValidatorStake, validator.Weight) + 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/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..af1e790ea704 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]. @@ -113,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 @@ -1273,6 +1278,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 +1360,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..262fef5954a9 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor_test.go +++ b/vms/platformvm/txs/executor/standard_tx_executor_test.go @@ -4289,6 +4289,355 @@ 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) + + standardTxEx := &standardTxExecutor{ + backend: &env.backend, + feeCalculator: feeCalculator, + tx: stopContVdrTx, + state: diff, + } + + if test.updateState != nil { + test.updateState(require, standardTxEx.state) + } + + 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()) + }) + } +} + +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 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 d3c063ee4d84..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 } @@ -122,6 +126,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..ae763e5c1cc5 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") @@ -513,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 } @@ -795,6 +820,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/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/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/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/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..8b496c44c574 100644 --- a/vms/platformvm/txs/visitor.go +++ b/vms/platformvm/txs/visitor.go @@ -32,4 +32,9 @@ type Visitor interface { SetL1ValidatorWeightTx(*SetL1ValidatorWeightTx) error IncreaseL1ValidatorBalanceTx(*IncreaseL1ValidatorBalanceTx) error DisableL1ValidatorTx(*DisableL1ValidatorTx) error + + // ? Transactions: + AddContinuousValidatorTx(tx *AddContinuousValidatorTx) error + StopContinuousValidatorTx(tx *StopContinuousValidatorTx) error + RewardContinuousValidatorTx(*RewardContinuousValidatorTx) 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..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 { @@ -235,6 +239,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..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) } @@ -59,6 +63,7 @@ func (b *backendVisitor) CreateSubnetTx(tx *txs.CreateSubnetTx) error { b.txID, tx.Owner, ) + return b.baseTx(&tx.BaseTx) } @@ -172,6 +177,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,