diff --git a/utils/math/safe_math.go b/utils/math/safe_math.go index 4ee5817905e1..b9875cc62873 100644 --- a/utils/math/safe_math.go +++ b/utils/math/safe_math.go @@ -5,13 +5,16 @@ package math import ( "errors" + "math" + "math/bits" "golang.org/x/exp/constraints" ) var ( - ErrOverflow = errors.New("overflow") - ErrUnderflow = errors.New("underflow") + ErrOverflow = errors.New("overflow") + ErrUnderflow = errors.New("underflow") + errDivideByZero = errors.New("divide by zero") // Deprecated: Add64 is deprecated. Use Add[uint64] instead. Add64 = Add[uint64] @@ -58,3 +61,25 @@ func Mul[T constraints.Unsigned](a, b T) (T, error) { func AbsDiff[T constraints.Unsigned](a, b T) T { return max(a, b) - min(a, b) } + +// MulDiv computes (a * b) / c with full precision. +// The result is rounded to the nearest integer. +// Returns errDivideByZero if c is zero, or ErrOverflow if the result exceeds uint64. +func MulDiv(a, b, c uint64) (uint64, error) { + if c == 0 { + return 0, errDivideByZero + } + + hi, lo := bits.Mul64(a, b) + if c <= hi { + return 0, ErrOverflow + } + quo, rem := bits.Div64(hi, lo, c) + if rem < (1<<63) && 2*rem < c { + return quo, nil + } + if quo == math.MaxUint64 { + return 0, ErrOverflow + } + return quo + 1, nil +} diff --git a/utils/math/safe_math_test.go b/utils/math/safe_math_test.go index ec14b24dccf4..fad8bb3f5177 100644 --- a/utils/math/safe_math_test.go +++ b/utils/math/safe_math_test.go @@ -115,3 +115,111 @@ func TestAbsDiff(t *testing.T) { require.Zero(AbsDiff(uint64(1), uint64(1))) require.Zero(AbsDiff(uint64(0), uint64(0))) } + +func TestMulDiv(t *testing.T) { + tests := []struct { + name string + a uint64 + b uint64 + c uint64 + want uint64 + wantErr error + }{ + { + name: "division by zero", + a: 100, + b: 5, + c: 0, + wantErr: errDivideByZero, + }, + { + name: "a is zero", + a: 0, + b: 4, + c: 50, + want: 0, + }, + { + name: "b is zero", + a: 250, + b: 0, + c: 50, + want: 0, + }, + { + name: "basic case 1", + a: 100, + b: 3, + c: 10, + want: 30, + }, + { + name: "basic case 2", + a: 250, + b: 4, + c: 50, + want: 20, + }, + { + name: "precision", + a: 7, + b: 3, + c: 10, + want: 2, + }, + { + name: "overflow", + a: maxUint64, + b: 10, + c: 2, + wantErr: ErrOverflow, + }, + { + name: "round down", + a: 10, + b: 10, + c: 30, + want: 3, + }, + { + name: "round up", + a: 20, + b: 10, + c: 30, + want: 7, + }, + { + name: "large values without overflow", + a: 300_000_000_000, + b: 200_000_000_000, + c: 400_000_000_000, + want: 150_000_000_000, + }, + { + name: "small a large c", + a: 5, + b: 3, + c: 10, + want: 2, + }, + { + name: "maxUint64 * maxUint64 / maxUint64", + a: maxUint64, + b: maxUint64, + c: maxUint64, + want: maxUint64, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MulDiv(tt.a, tt.b, tt.c) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/vms/platformvm/api/validator.go b/vms/platformvm/api/validator.go index 8f215d066ce9..507c6a1b3128 100644 --- a/vms/platformvm/api/validator.go +++ b/vms/platformvm/api/validator.go @@ -86,6 +86,14 @@ type PermissionlessValidator struct { Staked []UTXO `json:"staked,omitempty"` Signer *signer.ProofOfPossession `json:"signer,omitempty"` + // ACP-236 + // The owner who can modify auto-renewed validator config, if applicable. + ConfigOwner *Owner `json:"configOwner,omitempty"` + // The validation cycle duration in seconds, if applicable. + Period *json.Uint64 `json:"period,omitempty"` + // Percentage of rewards to auto-compound, if applicable. + AutoCompoundRewardShares *json.Uint32 `json:"autoCompoundRewardShares,omitempty"` + // The delegators delegating to this validator DelegatorCount *json.Uint64 `json:"delegatorCount,omitempty"` DelegatorWeight *json.Uint64 `json:"delegatorWeight,omitempty"` diff --git a/vms/platformvm/block/builder/builder.go b/vms/platformvm/block/builder/builder.go index 191dc06ee01c..afc368ddd894 100644 --- a/vms/platformvm/block/builder/builder.go +++ b/vms/platformvm/block/builder/builder.go @@ -323,7 +323,12 @@ func buildBlock( return nil, fmt.Errorf("could not find next staker to reward: %w", err) } if shouldReward { - rewardValidatorTx, err := NewRewardValidatorTx(builder.txExecutorBackend.Ctx, stakerTxID) + stakerTx, _, err := parentState.GetTx(stakerTxID) + if err != nil { + return nil, err + } + + rewardValidatorTx, err := newRewardTxForStaker(builder.txExecutorBackend.Ctx, stakerTx, timestamp) if err != nil { return nil, fmt.Errorf("could not build tx to reward staker: %w", err) } @@ -608,7 +613,7 @@ func executeTx( } // getNextStakerToReward returns the next staker txID to remove from the staking -// set with a RewardValidatorTx rather than an AdvanceTimeTx. [chainTimestamp] +// set with a RewardValidatorTx/RewardAutoRenewedValidatorTx rather than an AdvanceTimeTx. [chainTimestamp] // is the timestamp of the chain at the time this validator would be getting // removed and is used to calculate [shouldReward]. // Returns: @@ -650,3 +655,20 @@ func NewRewardValidatorTx(ctx *snow.Context, txID ids.ID) (*txs.Tx, error) { } return tx, tx.SyntacticVerify(ctx) } + +func newRewardTxForStaker(ctx *snow.Context, stakerTx *txs.Tx, timestamp time.Time) (*txs.Tx, error) { + if _, ok := stakerTx.Unsigned.(*txs.AddAutoRenewedValidatorTx); ok { + return newRewardAutoRenewedValidatorTx(ctx, stakerTx.ID(), uint64(timestamp.Unix())) + } + + return NewRewardValidatorTx(ctx, stakerTx.ID()) +} + +func newRewardAutoRenewedValidatorTx(ctx *snow.Context, txID ids.ID, timestamp uint64) (*txs.Tx, error) { + utx := &txs.RewardAutoRenewedValidatorTx{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/block/builder/builder_test.go b/vms/platformvm/block/builder/builder_test.go index 9883f574c6ee..4468cf8d7e14 100644 --- a/vms/platformvm/block/builder/builder_test.go +++ b/vms/platformvm/block/builder/builder_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" "github.com/ava-labs/avalanchego/snow/consensus/snowman" "github.com/ava-labs/avalanchego/upgrade/upgradetest" "github.com/ava-labs/avalanchego/utils/constants" @@ -18,12 +19,14 @@ import ( "github.com/ava-labs/avalanchego/utils/iterator" "github.com/ava-labs/avalanchego/utils/timer/mockable" "github.com/ava-labs/avalanchego/utils/units" + "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/platformvm/block" "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/state/statetest" + "github.com/ava-labs/avalanchego/vms/platformvm/status" "github.com/ava-labs/avalanchego/vms/platformvm/txs" "github.com/ava-labs/avalanchego/vms/secp256k1fx" @@ -210,6 +213,82 @@ func TestBuildBlockShouldReward(t *testing.T) { require.NotEmpty(rewardUTXOs) } +func TestBuildBlockShouldRewardAutoRenewedValidator(t *testing.T) { + require := require.New(t) + + env := newEnvironment(t, upgradetest.Latest) + + // Remove genesis validators so our auto-renewed validator is the only staker + currentStakerIterator, err := env.state.GetCurrentStakerIterator() + require.NoError(err) + for _, staker := range iterator.ToSlice(currentStakerIterator) { + require.NoError(env.state.DeleteCurrentValidator(staker)) + } + + var ( + nodeID = ids.GenerateTestNodeID() + stakePeriod = 24 * time.Hour + startTime = genesistest.DefaultValidatorStartTime + endTime = startTime.Add(stakePeriod) + ) + + sk, err := localsigner.New() + require.NoError(err) + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + // Build the AddAutoRenewedValidatorTx directly + addTx, err := txs.NewSigned(&txs.AddAutoRenewedValidatorTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: env.ctx.NetworkID, + BlockchainID: env.ctx.ChainID, + }}, + ValidatorNodeID: nodeID, + Signer: pop, + StakeOuts: []*avax.TransferableOutput{}, + ValidatorRewardsOwner: &secp256k1fx.OutputOwners{}, + DelegatorRewardsOwner: &secp256k1fx.OutputOwners{}, + Owner: &secp256k1fx.OutputOwners{}, + DelegationShares: reward.PercentDenominator, + Wght: env.config.MinValidatorStake, + AutoCompoundRewardShares: reward.PercentDenominator, + Period: uint64(stakePeriod / time.Second), + }, txs.Codec, nil) + require.NoError(err) + + txID := addTx.ID() + validatorTx := addTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + + // Add the tx and staker directly to state + env.state.AddTx(addTx, status.Committed) + + staker, err := state.NewStaker(txID, validatorTx, startTime, endTime, validatorTx.Weight(), 0) + require.NoError(err) + + require.NoError(env.state.PutCurrentValidator(staker)) + require.NoError(env.state.SetStakingInfo(staker.SubnetID, staker.NodeID, state.StakingInfo{Period: stakePeriod})) + require.NoError(env.state.Commit()) + + // Advance time to the validator's end time so it should be rewarded + env.state.SetTimestamp(endTime) + env.backend.Clk.Set(endTime) + + // Build the block + blk, err := env.Builder.BuildBlock(t.Context()) + require.NoError(err) + + proposalBlk := blk.(*blockexecutor.Block).Block + require.IsType(&block.BanffProposalBlock{}, proposalBlk) + + proposalTxs := proposalBlk.Txs() + require.Len(proposalTxs, 1) + + rewardTx, ok := proposalTxs[0].Unsigned.(*txs.RewardAutoRenewedValidatorTx) + require.True(ok) + require.Equal(txID, rewardTx.TxID) + require.Equal(uint64(endTime.Unix()), rewardTx.Timestamp) +} + func TestBuildBlockAdvanceTime(t *testing.T) { require := require.New(t) @@ -617,3 +696,103 @@ func TestGetNextStakerToReward(t *testing.T) { }) } } + +func TestNewRewardTxForStaker(t *testing.T) { + var ( + networkID = uint32(1337) + chainID = ids.GenerateTestID() + ) + + ctx := &snow.Context{ + ChainID: chainID, + NetworkID: networkID, + } + + validBaseTx := txs.BaseTx{ + BaseTx: avax.BaseTx{ + NetworkID: networkID, + BlockchainID: chainID, + }, + } + + blsSK, err := localsigner.New() + require.NoError(t, err) + + blsPOP, err := signer.NewProofOfPossession(blsSK) + require.NoError(t, err) + + tests := []struct { + name string + stakerTxFunc func(t testing.TB) *txs.Tx + timestamp time.Time + wantTxType any + }{ + { + name: "AddAutoRenewedValidatorTx returns RewardAutoRenewedValidatorTx", + stakerTxFunc: func(t testing.TB) *txs.Tx { + utx := &txs.AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 2, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{}, + ValidatorRewardsOwner: &secp256k1fx.OutputOwners{}, + DelegatorRewardsOwner: &secp256k1fx.OutputOwners{}, + DelegationShares: reward.PercentDenominator, + Owner: &secp256k1fx.OutputOwners{}, + } + + tx, err := txs.NewSigned(utx, txs.Codec, nil) + require.NoError(t, err) + return tx + }, + timestamp: time.Unix(1000, 0), + wantTxType: &txs.RewardAutoRenewedValidatorTx{}, + }, + { + name: "AddPermissionlessValidatorTx returns RewardValidatorTx", + stakerTxFunc: func(t testing.TB) *txs.Tx { + utx := &txs.AddPermissionlessValidatorTx{ + BaseTx: validBaseTx, + Validator: txs.Validator{ + NodeID: ids.GenerateTestNodeID(), + End: uint64(genesistest.DefaultValidatorStartTime.Add(time.Hour).Unix()), + Wght: 2, + }, + Subnet: ids.GenerateTestID(), + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{}, + ValidatorRewardsOwner: &secp256k1fx.OutputOwners{}, + DelegatorRewardsOwner: &secp256k1fx.OutputOwners{}, + DelegationShares: reward.PercentDenominator, + } + + tx, err := txs.NewSigned(utx, txs.Codec, nil) + require.NoError(t, err) + return tx + }, + timestamp: time.Unix(1000, 0), + wantTxType: &txs.RewardValidatorTx{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stakerTx := tt.stakerTxFunc(t) + + rewardTx, err := newRewardTxForStaker(ctx, stakerTx, tt.timestamp) + require.NoError(t, err) + require.NotNil(t, rewardTx) + require.IsType(t, tt.wantTxType, rewardTx.Unsigned) + + switch utx := rewardTx.Unsigned.(type) { + case *txs.RewardAutoRenewedValidatorTx: + require.Equal(t, stakerTx.ID(), utx.TxID) + require.Equal(t, uint64(tt.timestamp.Unix()), utx.Timestamp) + case *txs.RewardValidatorTx: + require.Equal(t, stakerTx.ID(), utx.TxID) + } + }) + } +} diff --git a/vms/platformvm/block/codec.go b/vms/platformvm/block/codec.go index 8e52bb974cac..b5c1886631ac 100644 --- a/vms/platformvm/block/codec.go +++ b/vms/platformvm/block/codec.go @@ -36,6 +36,7 @@ func init() { RegisterBanffTypes(c), RegisterDurangoTypes(c), RegisterEtnaTypes(c), + RegisterHeliconTypes(c), ) } @@ -86,3 +87,9 @@ func RegisterDurangoTypes(targetCodec linearcodec.Codec) error { func RegisterEtnaTypes(targetCodec linearcodec.Codec) error { return txs.RegisterEtnaTypes(targetCodec) } + +// RegisterHeliconTypes registers the type information for transactions that +// were valid during the Helicon series of upgrades. +func RegisterHeliconTypes(targetCodec linearcodec.Codec) error { + return txs.RegisterHeliconTypes(targetCodec) +} diff --git a/vms/platformvm/block/executor/block_test.go b/vms/platformvm/block/executor/block_test.go index cf1d866bc3a4..c6a155d9d9b7 100644 --- a/vms/platformvm/block/executor/block_test.go +++ b/vms/platformvm/block/executor/block_test.go @@ -5,7 +5,6 @@ package executor import ( "testing" - "time" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -18,6 +17,7 @@ import ( "github.com/ava-labs/avalanchego/utils/constants" "github.com/ava-labs/avalanchego/vms/platformvm/block" "github.com/ava-labs/avalanchego/vms/platformvm/config" + "github.com/ava-labs/avalanchego/vms/platformvm/genesis/genesistest" "github.com/ava-labs/avalanchego/vms/platformvm/reward" "github.com/ava-labs/avalanchego/vms/platformvm/state" "github.com/ava-labs/avalanchego/vms/platformvm/state/statetest" @@ -276,7 +276,7 @@ func TestBlockOptions(t *testing.T) { }, TxID: stakerTxID, } - primaryNetworkValidatorStartTime = time.Now() + primaryNetworkValidatorStartTime = genesistest.DefaultValidatorStartTime staker = &state.Staker{ StartTime: primaryNetworkValidatorStartTime, NodeID: nodeID, @@ -334,7 +334,7 @@ func TestBlockOptions(t *testing.T) { }, TxID: stakerTxID, } - primaryNetworkValidatorStartTime = time.Now() + primaryNetworkValidatorStartTime = genesistest.DefaultValidatorStartTime staker = &state.Staker{ StartTime: primaryNetworkValidatorStartTime, NodeID: nodeID, @@ -391,7 +391,7 @@ func TestBlockOptions(t *testing.T) { }, TxID: stakerTxID, } - primaryNetworkValidatorStartTime = time.Now() + primaryNetworkValidatorStartTime = genesistest.DefaultValidatorStartTime staker = &state.Staker{ StartTime: primaryNetworkValidatorStartTime, NodeID: nodeID, @@ -457,7 +457,7 @@ func TestBlockOptions(t *testing.T) { }, TxID: stakerTxID, } - primaryNetworkValidatorStartTime = time.Now() + primaryNetworkValidatorStartTime = genesistest.DefaultValidatorStartTime staker = &state.Staker{ StartTime: primaryNetworkValidatorStartTime, NodeID: nodeID, @@ -505,6 +505,114 @@ func TestBlockOptions(t *testing.T) { }, expectedPreferenceType: &block.BanffAbortBlock{}, }, + { + name: "banff proposal block; reward auto-renewed validator; sufficient uptime; prefer commit", + blkF: func(ctrl *gomock.Controller) *Block { + var ( + stakerTxID = ids.GenerateTestID() + nodeID = ids.GenerateTestNodeID() + stakerTx = &txs.Tx{ + Unsigned: &txs.AddAutoRenewedValidatorTx{ + ValidatorNodeID: nodeID, + }, + TxID: stakerTxID, + } + primaryNetworkValidatorStartTime = genesistest.DefaultValidatorStartTime + staker = &state.Staker{ + StartTime: primaryNetworkValidatorStartTime, + NodeID: nodeID, + } + ) + + state := statetest.New(t, statetest.Config{}) + state.AddTx(stakerTx, status.Committed) + require.NoError(t, state.PutCurrentValidator(staker)) + + uptimes := uptimemock.NewCalculator(ctrl) + uptimes.EXPECT().CalculateUptimePercentFrom(nodeID, primaryNetworkValidatorStartTime).Return(.9, nil) + + manager := &manager{ + backend: &backend{ + state: state, + ctx: snowtest.Context(t, snowtest.PChainID), + }, + txExecutorBackend: &executor.Backend{ + Config: &config.Internal{ + UptimePercentage: .8, + }, + Uptimes: uptimes, + }, + } + + return &Block{ + Block: &block.BanffProposalBlock{ + ApricotProposalBlock: block.ApricotProposalBlock{ + Tx: &txs.Tx{ + Unsigned: &txs.RewardAutoRenewedValidatorTx{ + TxID: stakerTxID, + }, + }, + }, + }, + manager: manager, + } + }, + expectedPreferenceType: &block.BanffCommitBlock{}, + }, + { + name: "banff proposal block; reward auto-renewed validator; insufficient uptime; prefer abort", + blkF: func(ctrl *gomock.Controller) *Block { + var ( + stakerTxID = ids.GenerateTestID() + nodeID = ids.GenerateTestNodeID() + stakerTx = &txs.Tx{ + Unsigned: &txs.AddAutoRenewedValidatorTx{ + ValidatorNodeID: nodeID, + }, + TxID: stakerTxID, + } + primaryNetworkValidatorStartTime = genesistest.DefaultValidatorStartTime + staker = &state.Staker{ + StartTime: primaryNetworkValidatorStartTime, + NodeID: nodeID, + } + ) + + state := statetest.New(t, statetest.Config{}) + state.AddTx(stakerTx, status.Committed) + require.NoError(t, state.PutCurrentValidator(staker)) + + uptimes := uptimemock.NewCalculator(ctrl) + uptimes.EXPECT().CalculateUptimePercentFrom(nodeID, primaryNetworkValidatorStartTime).Return(.5, nil) + + manager := &manager{ + backend: &backend{ + state: state, + ctx: snowtest.Context(t, snowtest.PChainID), + }, + txExecutorBackend: &executor.Backend{ + Config: &config.Internal{ + UptimePercentage: .8, + }, + Uptimes: uptimes, + }, + } + + return &Block{ + Block: &block.BanffProposalBlock{ + ApricotProposalBlock: block.ApricotProposalBlock{ + Tx: &txs.Tx{ + Unsigned: &txs.RewardAutoRenewedValidatorTx{ + TxID: stakerTxID, + }, + }, + }, + }, + manager: manager, + } + }, + expectedPreferenceType: &block.BanffAbortBlock{}, + }, } for _, tt := range tests { diff --git a/vms/platformvm/block/executor/options.go b/vms/platformvm/block/executor/options.go index b32a04ce2714..ba78f04c0dbe 100644 --- a/vms/platformvm/block/executor/options.go +++ b/vms/platformvm/block/executor/options.go @@ -141,17 +141,17 @@ func (*options) ApricotAtomicBlock(*block.ApricotAtomicBlock) error { } func (o *options) prefersCommit(tx *txs.Tx) (bool, error) { - unsignedTx, ok := tx.Unsigned.(*txs.RewardValidatorTx) + unsignedTx, ok := tx.Unsigned.(txs.RewardTx) if !ok { return false, fmt.Errorf("%w: %T", errUnexpectedProposalTxType, tx.Unsigned) } - stakerTx, _, err := o.state.GetTx(unsignedTx.TxID) + stakerTx, _, err := o.state.GetTx(unsignedTx.StakerTxID()) if err != nil { return false, fmt.Errorf("%w: %w", errFailedFetchingStakerTx, err) } - staker, ok := stakerTx.Unsigned.(txs.Staker) + staker, ok := stakerTx.Unsigned.(txs.BaseStaker) if !ok { return false, fmt.Errorf("%w: %T", errUnexpectedStakerTxType, stakerTx.Unsigned) } diff --git a/vms/platformvm/client.go b/vms/platformvm/client.go index 19a94a321cbc..1dafd9234b36 100644 --- a/vms/platformvm/client.go +++ b/vms/platformvm/client.go @@ -5,6 +5,7 @@ package platformvm import ( "context" + "fmt" "maps" "time" @@ -20,6 +21,7 @@ import ( "github.com/ava-labs/avalanchego/vms/components/gas" "github.com/ava-labs/avalanchego/vms/platformvm/fx" "github.com/ava-labs/avalanchego/vms/platformvm/status" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" "github.com/ava-labs/avalanchego/vms/platformvm/validators/fee" "github.com/ava-labs/avalanchego/vms/secp256k1fx" @@ -653,12 +655,46 @@ func GetDeactivationOwners( return deactivationOwners, nil } -// GetOwners returns the union of GetSubnetOwners and GetDeactivationOwners. +// GetAutoRenewedValidatorConfigOwners returns a map of auto-renewed validator +// tx ID to config owner. +func GetAutoRenewedValidatorConfigOwners( + c *Client, + ctx context.Context, + txIDs ...ids.ID, +) (map[ids.ID]fx.Owner, error) { + if len(txIDs) == 0 { + return nil, nil + } + + owners := make(map[ids.ID]fx.Owner, len(txIDs)) + for _, txID := range txIDs { + txBytes, err := c.GetTx(ctx, txID) + if err != nil { + return nil, err + } + + tx, err := txs.Parse(txs.Codec, txBytes) + if err != nil { + return nil, err + } + + addTx, ok := tx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + if !ok { + return nil, fmt.Errorf("expected AddAutoRenewedValidatorTx but got %T for txID %s", tx.Unsigned, txID) + } + owners[txID] = addTx.Owner + } + return owners, nil +} + +// GetOwners returns the union of GetSubnetOwners, GetDeactivationOwners, and +// GetAutoRenewedValidatorConfigOwners. func GetOwners( c *Client, ctx context.Context, subnetIDs []ids.ID, validationIDs []ids.ID, + autoRenewedValidatorTxIDs []ids.ID, ) (map[ids.ID]fx.Owner, error) { subnetOwners, err := GetSubnetOwners(c, ctx, subnetIDs...) if err != nil { @@ -668,9 +704,14 @@ func GetOwners( if err != nil { return nil, err } + configOwners, err := GetAutoRenewedValidatorConfigOwners(c, ctx, autoRenewedValidatorTxIDs...) + if err != nil { + return nil, err + } - owners := make(map[ids.ID]fx.Owner, len(subnetOwners)+len(deactivationOwners)) + owners := make(map[ids.ID]fx.Owner, len(subnetOwners)+len(deactivationOwners)+len(configOwners)) maps.Copy(owners, subnetOwners) maps.Copy(owners, deactivationOwners) + maps.Copy(owners, configOwners) return owners, nil } diff --git a/vms/platformvm/client_permissionless_validator.go b/vms/platformvm/client_permissionless_validator.go index 0a893795ac50..f5649cd7b580 100644 --- a/vms/platformvm/client_permissionless_validator.go +++ b/vms/platformvm/client_permissionless_validator.go @@ -62,6 +62,11 @@ type ClientPermissionlessValidator struct { DelegatorCount *uint64 DelegatorWeight *uint64 Delegators []ClientDelegator + + // ACP-236 + ConfigOwner *ClientOwner + Period *uint64 + AutoCompoundRewardShares *uint32 } // ClientDelegator is the repr. of a delegator sent over client @@ -158,6 +163,11 @@ func getClientPrimaryOrSubnetValidator(apiValidator api.PermissionlessValidator) return ClientPermissionlessValidator{}, err } + configOwner, err := apiOwnerToClientOwner(apiValidator.ConfigOwner) + if err != nil { + return ClientPermissionlessValidator{}, err + } + var clientDelegators []ClientDelegator if apiValidator.Delegators != nil { clientDelegators = make([]ClientDelegator, len(*apiValidator.Delegators)) @@ -188,5 +198,9 @@ func getClientPrimaryOrSubnetValidator(apiValidator api.PermissionlessValidator) DelegatorCount: (*uint64)(apiValidator.DelegatorCount), DelegatorWeight: (*uint64)(apiValidator.DelegatorWeight), Delegators: clientDelegators, + + ConfigOwner: configOwner, + Period: (*uint64)(apiValidator.Period), + AutoCompoundRewardShares: (*uint32)(apiValidator.AutoCompoundRewardShares), }, nil } diff --git a/vms/platformvm/docs/validators_auto_renewed.md b/vms/platformvm/docs/validators_auto_renewed.md new file mode 100644 index 000000000000..7fc39c017bed --- /dev/null +++ b/vms/platformvm/docs/validators_auto_renewed.md @@ -0,0 +1,96 @@ +# Auto-Renewed Validators + +This document describes the implementation of auto-renewed staking (ACP-236) for primary network validators on the P-Chain. For the full specification, see the ACP-236 proposal. + +## Transaction Types + +Three new transaction types are introduced, all gated behind the Helicon upgrade. + +### AddAutoRenewedValidatorTx + +Creates a new auto-renewed validator. Defined in `txs/add_auto_renewed_validator_tx.go`. + +Verification rules (in `txs/executor/staker_tx_verification.go`): +- Weight must be between `minValidatorStake` and `maxValidatorStake`. +- Delegation fee must be >= `minDelegationFee`. +- Period must be between `minStakeDuration` and `maxStakeDuration`. +- NodeID must not already be validating on the primary network. +- UTXO flow check must pass. + +On execution (in `txs/executor/standard_tx_executor.go`): +- Potential reward is calculated for the first cycle. +- Current supply is updated. +- A staker is added with `StartTime = chainTimestamp` and `EndTime = StartTime + Period`. +- StakingInfo metadata is initialized with the auto-renew configuration. + +### SetAutoRenewedValidatorConfigTx + +Updates the auto-renew configuration. Defined in `txs/set_auto_renewed_validator_config_tx.go`. + +Verification rules (in `txs/executor/staker_tx_verification.go`): +- Referenced TxID must be an `AddAutoRenewedValidatorTx`. +- Validator must be currently active. +- TxID must match the validator's latest transaction ID. +- If Period > 0, it must be >= `minStakeDuration`. +- Must be authorized by the validator's `Owner`. + +On execution (in `txs/executor/standard_tx_executor.go`): +- StakingInfo is updated with new `AutoCompoundRewardShares` and `Period`. Changes take effect at the next cycle boundary. + +### RewardAutoRenewedValidatorTx + +Issued by the block builder at the end of each cycle. Defined in `txs/reward_auto_renewed_validator_tx.go`. Includes a `Timestamp` field to ensure unique transaction IDs across cycles. + +This is a proposal transaction with commit and abort paths, handled in `txs/executor/proposal_tx_executor.go`. + +## Cycle End Processing + +When a cycle ends, the block builder (in `block/builder/builder.go`) issues a `RewardAutoRenewedValidatorTx` as a proposal block. + +### Commit Path + +Taken when the validator has sufficient uptime and is eligible for rewards. + +**If Period > 0 (continue validating):** + +1. The current cycle's `PotentialReward` and `DelegateeReward` are each split according to `AutoCompoundRewardShares`. The restake portion increases validator weight; the withdrawal portion creates UTXOs. Accrued values (`AccruedRewards`, `AccruedDelegateeRewards`) are not split — they carry forward and are added to with the restake portion. + +2. If the new weight would exceed `MaxValidatorStake`, excess rewards are withdrawn proportionally between validation and delegatee streams (see `createOverflowUTXOs` in `txs/executor/proposal_tx_executor.go`). + +3. A new cycle begins immediately with `StartTime` = previous `EndTime`, new `PotentialReward` recalculated, and `DelegateeReward` reset. `AccruedRewards` and `AccruedDelegateeRewards` accumulate across cycles. + +**If Period == 0 (graceful exit):** + +All rewards (`PotentialReward` + `AccruedRewards` + `DelegateeReward` + `AccruedDelegateeRewards`) are withdrawn. Principal is returned. Validator is removed. + +### Abort Path + +Taken when the validator did not meet uptime requirements, regardless of Period. Auto-renewal is conditioned on reward eligibility; failing uptime forces exit. +- Principal is returned. +- `AccruedRewards`, `AccruedDelegateeRewards`, and `DelegateeReward` are returned. +- Current cycle's `PotentialReward` is forfeited. + +## StakingInfo Metadata + +Mutable state is stored separately from the core `Staker` record in a `StakingInfo` structure (see `state/metadata_validator.go`): + +- `DelegateeReward` - pending delegatee rewards from the current cycle. +- `AccruedRewards` - validation rewards carried forward from previous cycles. +- `AccruedDelegateeRewards` - delegatee rewards carried forward from previous cycles. +- `AutoCompoundRewardShares` - current restake ratio. +- `Period` - current cycle duration (0 means stop at cycle end). +- `StakerEndTime` - Unix timestamp of the current cycle's end. + +## UTXO Creation + +Attached to `AddAutoRenewedValidatorTx`: +- Initial stake outputs (returned when validator stops). + +Attached to `RewardAutoRenewedValidatorTx`: +- Withdrawal portion of rewards (based on `AutoCompoundRewardShares`). +- Overflow rewards when restaking would exceed `MaxValidatorStake`. +- All accrued rewards when validator stops (graceful exit or forced). + +## API + +The `GetStakers` endpoint (in `service.go`) returns `Period`, `AutoCompoundRewardShares`, and `ConfigOwner` for auto-renewed validators. diff --git a/vms/platformvm/metrics/tx_metrics.go b/vms/platformvm/metrics/tx_metrics.go index 58d29ab9b6a8..cf180a8fe068 100644 --- a/vms/platformvm/metrics/tx_metrics.go +++ b/vms/platformvm/metrics/tx_metrics.go @@ -173,3 +173,24 @@ func (m *txMetrics) DisableL1ValidatorTx(*txs.DisableL1ValidatorTx) error { }).Inc() return nil } + +func (m *txMetrics) AddAutoRenewedValidatorTx(*txs.AddAutoRenewedValidatorTx) error { + m.numTxs.With(prometheus.Labels{ + txLabel: "add_auto_renewed_validator", + }).Inc() + return nil +} + +func (m *txMetrics) SetAutoRenewedValidatorConfigTx(*txs.SetAutoRenewedValidatorConfigTx) error { + m.numTxs.With(prometheus.Labels{ + txLabel: "set_auto_renewed_validator_config", + }).Inc() + return nil +} + +func (m *txMetrics) RewardAutoRenewedValidatorTx(*txs.RewardAutoRenewedValidatorTx) error { + m.numTxs.With(prometheus.Labels{ + txLabel: "reward_auto_renewed_validator", + }).Inc() + return nil +} diff --git a/vms/platformvm/service.go b/vms/platformvm/service.go index 28e414522364..c635c09fb0f4 100644 --- a/vms/platformvm/service.go +++ b/vms/platformvm/service.go @@ -82,6 +82,9 @@ type stakerAttributes struct { validationRewardsOwner fx.Owner delegationRewardsOwner fx.Owner proofOfPossession *signer.ProofOfPossession + + // ACP-236 + configOwner fx.Owner } // GetHeight returns the height of the last accepted block @@ -689,6 +692,10 @@ func (s *Service) loadStakerTxAttributes(txID ids.ID) (*stakerAttributes, error) proofOfPossession: pop, } + if addAutoRenewedValidatorTx, ok := stakerTx.(*txs.AddAutoRenewedValidatorTx); ok { + attr.configOwner = addAutoRenewedValidatorTx.Owner + } + case txs.DelegatorTx: attr = &stakerAttributes{ rewardsOwner: stakerTx.RewardsOwner(), @@ -903,6 +910,20 @@ func (s *Service) getPrimaryOrSubnetValidators(subnetID ids.ID, nodeIDs set.Set[ DelegationFee: delegationFee, Signer: attr.proofOfPossession, } + + if attr.configOwner != nil { + configOwner, ok := attr.configOwner.(*secp256k1fx.OutputOwners) + if !ok { + return nil, fmt.Errorf("expected *secp256k1fx.OutputOwners but got %T", attr.configOwner) + } + vdr.ConfigOwner, err = s.getAPIOwner(configOwner) + if err != nil { + return nil, err + } + vdr.Period = utils.PointerTo(avajson.Uint64(stakingInfo.Period / time.Second)) + vdr.AutoCompoundRewardShares = utils.PointerTo(avajson.Uint32(stakingInfo.AutoCompoundRewardShares)) + } + validators = append(validators, vdr) case txs.PrimaryNetworkDelegatorCurrentPriority, txs.SubnetPermissionlessDelegatorCurrentPriority: diff --git a/vms/platformvm/service_test.go b/vms/platformvm/service_test.go index 3d969cd19c39..73952528aea3 100644 --- a/vms/platformvm/service_test.go +++ b/vms/platformvm/service_test.go @@ -1479,7 +1479,15 @@ func TestGetCurrentValidatorsForL1(t *testing.T) { StartTime: staker.StartTime.Add(-time.Second), Priority: txs.PrimaryNetworkValidatorCurrentPriority, } + service.vm.state.AddTx(&txs.Tx{ + TxID: primaryStaker.TxID, + Unsigned: &txs.AddValidatorTx{}, + }, status.Committed) require.NoError(service.vm.state.PutCurrentValidator(primaryStaker)) + service.vm.state.AddTx(&txs.Tx{ + TxID: staker.TxID, + Unsigned: &txs.AddSubnetValidatorTx{}, + }, status.Committed) staker.Priority = txs.SubnetPermissionedValidatorCurrentPriority require.NoError(service.vm.state.PutCurrentValidator(staker)) diff --git a/vms/platformvm/state/metadata_codec.go b/vms/platformvm/state/metadata_codec.go index d078db256916..0d1eea77e934 100644 --- a/vms/platformvm/state/metadata_codec.go +++ b/vms/platformvm/state/metadata_codec.go @@ -17,6 +17,9 @@ const ( CodecVersion1Tag = "v1" CodecVersion1 uint16 = 1 + + codecVersion2Tag = "v2" + codecVersion2 uint16 = 2 ) var MetadataCodec codec.Manager @@ -24,11 +27,13 @@ var MetadataCodec codec.Manager func init() { c0 := linearcodec.New([]string{CodecVersion0Tag}) c1 := linearcodec.New([]string{CodecVersion0Tag, CodecVersion1Tag}) + c2 := linearcodec.New([]string{CodecVersion0Tag, CodecVersion1Tag, codecVersion2Tag}) MetadataCodec = codec.NewManager(math.MaxInt32) err := errors.Join( MetadataCodec.RegisterCodec(CodecVersion0, c0), MetadataCodec.RegisterCodec(CodecVersion1, c1), + MetadataCodec.RegisterCodec(codecVersion2, c2), ) if err != nil { panic(err) diff --git a/vms/platformvm/state/metadata_delegator_test.go b/vms/platformvm/state/metadata_delegator_test.go index 95a77ce156ce..37b367c864dc 100644 --- a/vms/platformvm/state/metadata_delegator_test.go +++ b/vms/platformvm/state/metadata_delegator_test.go @@ -53,7 +53,7 @@ func TestParseDelegatorMetadata(t *testing.T) { name: "invalid codec version", bytes: []byte{ // codec version - 0x00, 0x02, + 0x00, 0x03, // potential reward 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7b, // staker start time diff --git a/vms/platformvm/state/metadata_validator.go b/vms/platformvm/state/metadata_validator.go index d357e4f66063..bc8faf442aba 100644 --- a/vms/platformvm/state/metadata_validator.go +++ b/vms/platformvm/state/metadata_validator.go @@ -33,6 +33,14 @@ type validatorMetadata struct { PotentialDelegateeReward uint64 `v0:"true"` StakerStartTime uint64 ` v1:"true"` + // ACP-236 fields for auto-renewed validators. + // Weight is computed as: tx.Weight + AccruedRewards + AccruedDelegateeRewards + AccruedRewards uint64 `v2:"true"` // the sum of validation rewards restaked from previous cycles. + AccruedDelegateeRewards uint64 `v2:"true"` // the sum of delegatee rewards restaked from previous cycles. + AutoCompoundRewardShares uint32 `v2:"true"` // the percentage of rewards to restake at cycle end + Period uint64 `v2:"true"` // the validation cycle duration in seconds. + StakerEndTime uint64 `v2:"true"` // the Unix timestamp (seconds) when the current cycle ends. + txID ids.ID lastUpdated time.Time } @@ -78,11 +86,21 @@ func parseValidatorMetadata(bytes []byte, metadata *validatorMetadata) error { // StakingInfo holds mutable validator data that can be modified. type StakingInfo struct { DelegateeReward uint64 + + // ACP-236 + AccruedRewards uint64 + AccruedDelegateeRewards uint64 + AutoCompoundRewardShares uint32 + Period time.Duration } func stakingInfoFromMetadata(vdrMetadata *validatorMetadata) StakingInfo { return StakingInfo{ - DelegateeReward: vdrMetadata.PotentialDelegateeReward, + DelegateeReward: vdrMetadata.PotentialDelegateeReward, + AccruedRewards: vdrMetadata.AccruedRewards, + AccruedDelegateeRewards: vdrMetadata.AccruedDelegateeRewards, + AutoCompoundRewardShares: vdrMetadata.AutoCompoundRewardShares, + Period: time.Duration(vdrMetadata.Period) * time.Second, } } @@ -189,6 +207,10 @@ func (vs *validatorState) SetStakingInfo( return database.ErrNotFound } metadata.PotentialDelegateeReward = stakingInfo.DelegateeReward + metadata.AccruedRewards = stakingInfo.AccruedRewards + metadata.AccruedDelegateeRewards = stakingInfo.AccruedDelegateeRewards + metadata.AutoCompoundRewardShares = stakingInfo.AutoCompoundRewardShares + metadata.Period = uint64(stakingInfo.Period / time.Second) vs.addUpdatedTxID(vdrID, subnetID, metadata.txID) return nil diff --git a/vms/platformvm/state/metadata_validator_test.go b/vms/platformvm/state/metadata_validator_test.go index b28a8ac3be62..6e8b155e958e 100644 --- a/vms/platformvm/state/metadata_validator_test.go +++ b/vms/platformvm/state/metadata_validator_test.go @@ -411,7 +411,33 @@ func TestParseValidatorMetadata(t *testing.T) { expectedErr: nil, }, { - name: "invalid codec version", + name: "uptime + potential reward + potential delegatee reward + staker start time", + bytes: []byte{ + // codec version + 0x00, 0x01, + // up duration + 0x00, 0x00, 0x00, 0x00, 0x00, 0x5B, 0x8D, 0x80, + // last updated + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0D, 0xBB, 0xA0, + // potential reward + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x86, 0xA0, + // potential delegatee reward + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4E, 0x20, + // staker start time + 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x93, 0xE0, + }, + expected: &validatorMetadata{ + UpDuration: 6000000, + LastUpdated: 900000, + lastUpdated: time.Unix(900000, 0), + PotentialReward: 100000, + PotentialDelegateeReward: 20000, + StakerStartTime: 300000, + }, + expectedErr: nil, + }, + { + name: "codec v2 fields", bytes: []byte{ // codec version 0x00, 0x02, @@ -423,6 +449,47 @@ func TestParseValidatorMetadata(t *testing.T) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x86, 0xA0, // potential delegatee reward 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4E, 0x20, + // staker start time + 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x93, 0xE0, + // accrued rewards + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xE8, + // accrued delegatee rewards + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xF4, + // auto compound reward shares + 0x00, 0x04, 0x93, 0xE0, + // renewal period + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x51, 0x80, + // staker end time (400000 = 0x61A80) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x1A, 0x80, + }, + expected: &validatorMetadata{ + UpDuration: 6000000, + LastUpdated: 900000, + lastUpdated: time.Unix(900000, 0), + PotentialReward: 100000, + PotentialDelegateeReward: 20000, + StakerStartTime: 300000, + AccruedRewards: 1000, + AccruedDelegateeRewards: 500, + AutoCompoundRewardShares: 300000, + Period: 86400, + StakerEndTime: 400000, + }, + expectedErr: nil, + }, + { + name: "invalid codec version", + bytes: []byte{ + // codec version + 0x00, 0x03, + // up duration + 0x00, 0x00, 0x00, 0x00, 0x00, 0x5B, 0x8D, 0x80, + // last updated + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0D, 0xBB, 0xA0, + // potential reward + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x86, 0xA0, + // potential delegatee reward + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4E, 0x20, }, expected: nil, expectedErr: codec.ErrUnknownVersion, diff --git a/vms/platformvm/state/staker.go b/vms/platformvm/state/staker.go index 030f96cccf4a..8388743b46bb 100644 --- a/vms/platformvm/state/staker.go +++ b/vms/platformvm/state/staker.go @@ -100,23 +100,7 @@ func NewCurrentStaker( startTime time.Time, potentialReward uint64, ) (*Staker, error) { - publicKey, _, err := staker.PublicKey() - if err != nil { - return nil, err - } - endTime := staker.EndTime() - 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(), - }, nil + return NewStaker(txID, staker, startTime, staker.EndTime(), staker.Weight(), potentialReward) } func NewPendingStaker(txID ids.ID, staker txs.ScheduledStaker) (*Staker, error) { @@ -137,3 +121,29 @@ func NewPendingStaker(txID ids.ID, staker txs.ScheduledStaker) (*Staker, error) Priority: staker.PendingPriority(), }, nil } + +func NewStaker( + txID ids.ID, + staker txs.BaseStaker, + startTime time.Time, + endTime time.Time, + weight uint64, // we need this, because staker.Weight() returns the initial weight (without any accrued rewards) + potentialReward uint64, +) (*Staker, error) { + publicKey, _, err := staker.PublicKey() + if err != nil { + return nil, err + } + return &Staker{ + TxID: txID, + NodeID: staker.NodeID(), + PublicKey: publicKey, + SubnetID: staker.SubnetID(), + Weight: weight, + StartTime: startTime, + EndTime: endTime, + PotentialReward: potentialReward, + NextTime: endTime, + Priority: staker.CurrentPriority(), + }, nil +} diff --git a/vms/platformvm/state/state.go b/vms/platformvm/state/state.go index a03be42e14f6..b15699f51b3a 100644 --- a/vms/platformvm/state/state.go +++ b/vms/platformvm/state/state.go @@ -1908,11 +1908,6 @@ func (s *State) loadCurrentValidators() error { return fmt.Errorf("failed loading validator transaction txID %s, %w", txID, err) } - stakerTx, ok := tx.Unsigned.(txs.Staker) - if !ok { - return fmt.Errorf("expected tx type txs.Staker but got %T", tx.Unsigned) - } - metadataBytes := validatorIt.Value() metadata := &validatorMetadata{ txID: txID, @@ -1929,13 +1924,41 @@ func (s *State) loadCurrentValidators() error { return err } - staker, err := NewCurrentStaker( - txID, - stakerTx, - time.Unix(int64(metadata.StakerStartTime), 0), - metadata.PotentialReward) - if err != nil { - return err + var staker *Staker + switch stakerTx := tx.Unsigned.(type) { + case *txs.AddAutoRenewedValidatorTx: + weight, err := safemath.Add(stakerTx.Weight(), metadata.AccruedRewards) + if err != nil { + return fmt.Errorf("overflow computing weight: %w", err) + } + weight, err = safemath.Add(weight, metadata.AccruedDelegateeRewards) + if err != nil { + return fmt.Errorf("overflow computing weight: %w", err) + } + + staker, err = NewStaker( + txID, + stakerTx, + time.Unix(int64(metadata.StakerStartTime), 0), + time.Unix(int64(metadata.StakerEndTime), 0), + weight, + metadata.PotentialReward, + ) + if err != nil { + return fmt.Errorf("failed creating staker: %w", err) + } + case txs.Staker: + staker, err = NewCurrentStaker( + txID, + stakerTx, + time.Unix(int64(metadata.StakerStartTime), 0), + metadata.PotentialReward, + ) + if err != nil { + return err + } + default: + return fmt.Errorf("invalid staker tx type: %T", tx.Unsigned) } validator := s.currentStakers.getOrCreateValidator(staker.SubnetID, staker.NodeID) @@ -2239,10 +2262,7 @@ func (s *State) initValidatorSets() error { } func (s *State) write(updateValidators bool, height uint64) error { - codecVersion := CodecVersion1 - if !s.upgrades.IsDurangoActivated(s.GetTimestamp()) { - codecVersion = CodecVersion0 - } + codecVersion := s.resolveValidatorMetadataCodec() return errors.Join( s.writeBlocks(), @@ -2265,6 +2285,20 @@ func (s *State) write(updateValidators bool, height uint64) error { ) } +func (s *State) resolveValidatorMetadataCodec() uint16 { + ts := s.GetTimestamp() + + if s.upgrades.IsHeliconActivated(ts) { + return codecVersion2 + } + + if s.upgrades.IsDurangoActivated(ts) { + return CodecVersion1 + } + + return CodecVersion0 +} + func (s *State) Close() error { return errors.Join( s.expiryDB.Close(), @@ -2867,6 +2901,7 @@ func (s *State) writeCurrentStakers(codecVersion uint16) error { UpDuration: 0, LastUpdated: startTime, StakerStartTime: startTime, + StakerEndTime: uint64(staker.EndTime.Unix()), PotentialReward: staker.PotentialReward, PotentialDelegateeReward: 0, } diff --git a/vms/platformvm/state/state_test.go b/vms/platformvm/state/state_test.go index e9487bbe50d8..8b9d2af5e1b5 100644 --- a/vms/platformvm/state/state_test.go +++ b/vms/platformvm/state/state_test.go @@ -689,6 +689,103 @@ func createPermissionlessDelegatorTx(subnetID ids.ID, delegatorData txs.Validato } } +func createStakerAndTx( + t testing.TB, + subnetID ids.ID, + nodeID ids.NodeID, + startTime time.Time, + endTime time.Time, + weight uint64, + potentialReward uint64, +) (*Staker, *txs.Tx) { + unsignedTx := createPermissionlessValidatorTx(t, subnetID, txs.Validator{ + NodeID: nodeID, + End: uint64(endTime.Unix()), + Wght: weight, + }) + tx := &txs.Tx{Unsigned: unsignedTx} + require.NoError(t, tx.Initialize(txs.Codec)) + + staker, err := NewCurrentStaker(tx.ID(), unsignedTx, startTime, potentialReward) + require.NoError(t, err) + + return staker, tx +} + +func createAutoRenewedValidatorTx( + t testing.TB, + nodeID ids.NodeID, + weight uint64, + period uint64, + autoCompoundRewardShares uint32, +) *txs.AddAutoRenewedValidatorTx { + sk, err := localsigner.New() + require.NoError(t, err) + sig, err := signer.NewProofOfPossession(sk) + require.NoError(t, err) + + return &txs.AddAutoRenewedValidatorTx{ + BaseTx: txs.BaseTx{ + BaseTx: avax.BaseTx{ + NetworkID: constants.MainnetID, + BlockchainID: constants.PlatformChainID, + Outs: []*avax.TransferableOutput{}, + Ins: []*avax.TransferableInput{ + { + UTXOID: avax.UTXOID{ + TxID: ids.GenerateTestID(), + OutputIndex: 1, + }, + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + In: &secp256k1fx.TransferInput{ + Amt: 2 * units.KiloAvax, + Input: secp256k1fx.Input{ + SigIndices: []uint32{1}, + }, + }, + }, + }, + Memo: types.JSONByteSlice{}, + }, + }, + ValidatorNodeID: nodeID, + Signer: sig, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: ids.GenerateTestID(), + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 2 * units.KiloAvax, + OutputOwners: secp256k1fx.OutputOwners{ + Locktime: 0, + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + }, + }, + }, + ValidatorRewardsOwner: &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + DelegatorRewardsOwner: &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + Owner: &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ids.GenerateTestShortID()}, + }, + DelegationShares: reward.PercentDenominator, + AutoCompoundRewardShares: autoCompoundRewardShares, + Wght: weight, + Period: period, + } +} + func TestValidatorWeightDiff(t *testing.T) { type op struct { op func(*ValidatorWeightDiff, uint64) error @@ -3917,3 +4014,186 @@ func TestStateAndDiffIntegration(t *testing.T) { }) } } + +func TestLoadCurrentValidatorsWeight(t *testing.T) { + require := require.New(t) + + db := memdb.New() + state := newTestState(t, db) + + var ( + normalNodeID = ids.GenerateTestNodeID() + autoRenewedNodeID = ids.GenerateTestNodeID() + subnetID = constants.PrimaryNetworkID + startTime = genesistest.DefaultValidatorStartTime + endTime = startTime.Add(24 * time.Hour) + + normalWeight = uint64(2000) + autoRenewedWeight = uint64(3000) + accruedRewards = uint64(100) + accruedDelRewards = uint64(50) + potentialReward = uint64(500) + period = uint64(3600) // 1 hour + autoCompoundRewardShares = uint32(100_000) // 10% + ) + + // Create and add a normal (permissionless) validator + normalStaker, normalTx := createStakerAndTx(t, subnetID, normalNodeID, startTime, endTime, normalWeight, potentialReward) + state.AddTx(normalTx, status.Committed) + + d, err := NewDiffOn(state, StakerAdditionAfterDeletionAllowed) + require.NoError(err) + require.NoError(d.PutCurrentValidator(normalStaker)) + require.NoError(d.Apply(state)) + + // Create and add an auto-renewed validator + autoRenewedUnsigned := createAutoRenewedValidatorTx(t, autoRenewedNodeID, autoRenewedWeight, period, autoCompoundRewardShares) + autoRenewedTx := &txs.Tx{Unsigned: autoRenewedUnsigned} + require.NoError(autoRenewedTx.Initialize(txs.Codec)) + state.AddTx(autoRenewedTx, status.Committed) + + newPeriod := period * 2 + newAutoCompoundRewardShares := autoCompoundRewardShares * 2 + + autoRenewedStaker, err := NewStaker( + autoRenewedTx.ID(), + autoRenewedUnsigned, + startTime, + endTime, + autoRenewedWeight, + potentialReward, + ) + require.NoError(err) + + d, err = NewDiffOn(state, StakerAdditionAfterDeletionAllowed) + require.NoError(err) + require.NoError(d.PutCurrentValidator(autoRenewedStaker)) + require.NoError(d.Apply(state)) + + // Commit the first block to persist both validators + require.NoError(state.Commit()) + + // Set accrued rewards on the auto-renewed validator's metadata + require.NoError(state.SetStakingInfo(subnetID, autoRenewedNodeID, StakingInfo{ + AccruedRewards: accruedRewards, + AccruedDelegateeRewards: accruedDelRewards, + AutoCompoundRewardShares: newAutoCompoundRewardShares, + Period: time.Duration(newPeriod) * time.Second, + })) + + // Commit again so the updated metadata is persisted + require.NoError(state.Commit()) + + // Reload state from the same database + reloadedState := newTestState(t, db) + + // Verify the normal validator kept its original weight + gotNormal, err := reloadedState.GetCurrentValidator(subnetID, normalNodeID) + require.NoError(err) + require.Equal(normalWeight, gotNormal.Weight) + + // Verify the auto-renewed validator has weight = txWeight + accruedRewards + accruedDelegateeRewards + gotAutoRenewed, err := reloadedState.GetCurrentValidator(subnetID, autoRenewedNodeID) + require.NoError(err) + wantWeight := autoRenewedWeight + accruedRewards + accruedDelRewards + require.Equal(wantWeight, gotAutoRenewed.Weight) + + gotStakingInfo, err := reloadedState.GetStakingInfo(subnetID, autoRenewedNodeID) + require.NoError(err) + require.Equal(newPeriod, uint64(gotStakingInfo.Period/time.Second)) + require.Equal(newAutoCompoundRewardShares, gotStakingInfo.AutoCompoundRewardShares) +} + +func TestAutoRenewedValidatorRestakeStateReload(t *testing.T) { + require := require.New(t) + + db := memdb.New() + state := newTestState(t, db) + + var ( + nodeID = ids.GenerateTestNodeID() + subnetID = constants.PrimaryNetworkID + + startTime = genesistest.DefaultValidatorStartTime + endTime = startTime.Add(24 * time.Hour) + + weight = uint64(3000) + potentialReward = uint64(500) + period = uint64(3600) // 1 hour + autoCompoundRewardShares = uint32(100_000) // 10% + + accruedRewards = uint64(100) + accruedDelRewards = uint64(50) + ) + + // Create and add the auto-renewed validator with initial times + autoRenewedUnsigned := createAutoRenewedValidatorTx(t, nodeID, weight, period, autoCompoundRewardShares) + autoRenewedTx := &txs.Tx{Unsigned: autoRenewedUnsigned} + require.NoError(autoRenewedTx.Initialize(txs.Codec)) + state.AddTx(autoRenewedTx, status.Committed) + + autoRenewedStaker, err := NewStaker( + autoRenewedTx.ID(), + autoRenewedUnsigned, + startTime, + endTime, + weight, + potentialReward, + ) + require.NoError(err) + + d, err := NewDiffOn(state, StakerAdditionAfterDeletionAllowed) + require.NoError(err) + require.NoError(d.PutCurrentValidator(autoRenewedStaker)) + require.NoError(d.Apply(state)) + require.NoError(state.Commit()) + + // Simulate restake: delete and re-add with updated times and weight + wantStartTime := endTime + wantEndTime := endTime.Add(time.Duration(period) * time.Second) + wantWeight := weight + accruedRewards + accruedDelRewards + wantPotentialReward := uint64(600) + + d, err = NewDiffOn(state, StakerAdditionAfterDeletionAllowed) + require.NoError(err) + require.NoError(d.DeleteCurrentValidator(autoRenewedStaker)) + + renewedStaker, err := NewStaker( + autoRenewedTx.ID(), + autoRenewedUnsigned, + wantStartTime, + wantEndTime, + wantWeight, + wantPotentialReward, + ) + require.NoError(err) + require.NoError(d.PutCurrentValidator(renewedStaker)) + require.NoError(d.Apply(state)) + + // Update staking info with accrued rewards + require.NoError(state.SetStakingInfo(subnetID, nodeID, StakingInfo{ + AccruedRewards: accruedRewards, + AccruedDelegateeRewards: accruedDelRewards, + AutoCompoundRewardShares: autoCompoundRewardShares, + Period: time.Duration(period) * time.Second, + })) + + require.NoError(state.Commit()) + + // Reload state from the same database + reloadedState := newTestState(t, db) + + gotValidator, err := reloadedState.GetCurrentValidator(subnetID, nodeID) + require.NoError(err) + require.True(wantStartTime.Equal(gotValidator.StartTime)) + require.True(wantEndTime.Equal(gotValidator.EndTime)) + require.Equal(wantWeight, gotValidator.Weight) + require.Equal(wantPotentialReward, gotValidator.PotentialReward) + + gotStakingInfo, err := reloadedState.GetStakingInfo(subnetID, nodeID) + require.NoError(err) + require.Equal(accruedRewards, gotStakingInfo.AccruedRewards) + require.Equal(accruedDelRewards, gotStakingInfo.AccruedDelegateeRewards) + require.Equal(autoCompoundRewardShares, gotStakingInfo.AutoCompoundRewardShares) + require.Equal(time.Duration(period)*time.Second, gotStakingInfo.Period) +} diff --git a/vms/platformvm/txs/BUILD.bazel b/vms/platformvm/txs/BUILD.bazel index 4870fc1fa79f..c23fcbde7274 100644 --- a/vms/platformvm/txs/BUILD.bazel +++ b/vms/platformvm/txs/BUILD.bazel @@ -4,6 +4,7 @@ load("//.bazel:defs.bzl", "go_test") go_library( name = "txs", srcs = [ + "add_auto_renewed_validator_tx.go", "add_delegator_tx.go", "add_permissionless_delegator_tx.go", "add_permissionless_validator_tx.go", @@ -22,7 +23,10 @@ go_library( "priorities.go", "register_l1_validator_tx.go", "remove_subnet_validator_tx.go", + "reward_auto_renewed_validator_tx.go", + "reward_tx.go", "reward_validator_tx.go", + "set_auto_renewed_validator_config_tx.go", "set_l1_validator_weight_tx.go", "staker_tx.go", "subnet_validator.go", @@ -65,6 +69,7 @@ go_library( go_test( name = "txs_test", srcs = [ + "add_auto_renewed_validator_tx_test.go", "add_delegator_test.go", "add_permissionless_delegator_tx_test.go", "add_permissionless_validator_tx_test.go", @@ -78,6 +83,8 @@ go_test( "priorities_test.go", "register_l1_validator_tx_test.go", "remove_subnet_validator_tx_test.go", + "reward_auto_renewed_validator_tx_test.go", + "set_auto_renewed_validator_config_tx_test.go", "set_l1_validator_weight_tx_test.go", "subnet_validator_test.go", "transfer_subnet_ownership_tx_test.go", diff --git a/vms/platformvm/txs/add_auto_renewed_validator_tx.go b/vms/platformvm/txs/add_auto_renewed_validator_tx.go new file mode 100644 index 000000000000..35444d7539ac --- /dev/null +++ b/vms/platformvm/txs/add_auto_renewed_validator_tx.go @@ -0,0 +1,192 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "errors" + "fmt" + + "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/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" + + safemath "github.com/ava-labs/avalanchego/utils/math" +) + +var _ ValidatorTx = (*AddAutoRenewedValidatorTx)(nil) + +var ( + errMissingSigner = errors.New("missing signer") + errMissingPeriod = errors.New("missing period") + errTooManyAutoCompoundRewardShares = fmt.Errorf("a staker can only restake at most %d shares from rewards", reward.PercentDenominator) + errInvalidStakedAsset = errors.New("invalid staked asset") +) + +type AddAutoRenewedValidatorTx struct { + // Metadata, inputs and outputs + BaseTx `serialize:"true"` + + // Node ID of the validator + ValidatorNodeID ids.NodeID `serialize:"true" json:"nodeID"` + + // 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"` + + // Who is authorized to modify the auto-restake config + Owner fx.Owner `serialize:"true" json:"owner"` + + // 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"` + + // Percentage of rewards to restake at the end of each cycle, expressed in millionths (percentage * 10,000). + // Range [0..1_000_000]: + // 0 = restake principal only; withdraw 100% of rewards + // 300_000 = restake 30% of rewards; withdraw 70% + // 1_000_000 = restake 100% of rewards; withdraw 0% + AutoCompoundRewardShares uint32 `serialize:"true" json:"autoCompoundRewardShares"` + + // Period is the validation cycle duration, in seconds. + Period uint64 `serialize:"true" json:"period"` +} + +func (*AddAutoRenewedValidatorTx) SubnetID() ids.ID { + return constants.PrimaryNetworkID +} + +func (tx *AddAutoRenewedValidatorTx) NodeID() ids.NodeID { + return tx.ValidatorNodeID +} + +func (tx *AddAutoRenewedValidatorTx) 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 *AddAutoRenewedValidatorTx) Weight() uint64 { + return tx.Wght +} + +func (*AddAutoRenewedValidatorTx) CurrentPriority() Priority { + return PrimaryNetworkValidatorCurrentPriority +} + +func (tx *AddAutoRenewedValidatorTx) Stake() []*avax.TransferableOutput { + return tx.StakeOuts +} + +func (tx *AddAutoRenewedValidatorTx) ValidationRewardsOwner() fx.Owner { + return tx.ValidatorRewardsOwner +} + +func (tx *AddAutoRenewedValidatorTx) DelegationRewardsOwner() fx.Owner { + return tx.DelegatorRewardsOwner +} + +func (tx *AddAutoRenewedValidatorTx) Shares() uint32 { + return tx.DelegationShares +} + +// InitCtx sets the FxID fields in the inputs and outputs of this +// AddAutoRenewedValidatorTx. Also sets the ctx to the given vm.ctx so +// that the addresses can be json marshalled into human readable format +func (tx *AddAutoRenewedValidatorTx) 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) + tx.Owner.InitCtx(ctx) +} + +// SyntacticVerify returns nil iff tx is valid +func (tx *AddAutoRenewedValidatorTx) 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.AutoCompoundRewardShares > reward.PercentDenominator: + return errTooManyAutoCompoundRewardShares + 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, tx.Owner); 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) + } + } + + totalStakeWeight := uint64(0) + for _, out := range tx.StakeOuts { + if out.AssetID() != ctx.AVAXAssetID { + return errInvalidStakedAsset + } + + newWeight, err := safemath.Add(totalStakeWeight, out.Output().Amount()) + if err != nil { + return err + } + totalStakeWeight = newWeight + } + + 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 *AddAutoRenewedValidatorTx) Visit(visitor Visitor) error { + return visitor.AddAutoRenewedValidatorTx(tx) +} diff --git a/vms/platformvm/txs/add_auto_renewed_validator_tx_test.go b/vms/platformvm/txs/add_auto_renewed_validator_tx_test.go new file mode 100644 index 000000000000..f2949b3c9308 --- /dev/null +++ b/vms/platformvm/txs/add_auto_renewed_validator_tx_test.go @@ -0,0 +1,566 @@ +// Copyright (C) 2019, 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" + "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" + + safemath "github.com/ava-labs/avalanchego/utils/math" +) + +func TestAddAutoRenewedValidatorTxSyntacticVerify(t *testing.T) { + dummyErr := errors.New("dummy error") + + type test struct { + name string + txFunc func(*gomock.Controller) *AddAutoRenewedValidatorTx + err error + } + + var ( + networkID = uint32(1337) + chainID = ids.GenerateTestID() + ) + + avaxAssetID := ids.GenerateTestID() + ctx := &snow.Context{ + ChainID: chainID, + NetworkID: networkID, + AVAXAssetID: avaxAssetID, + } + + // 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(t, err) + + blsPOP, err := signer.NewProofOfPossession(blsSK) + require.NoError(t, err) + + // A BaseTx that fails syntactic verification. + invalidBaseTx := BaseTx{} + + tests := []test{ + { + name: "nil tx", + txFunc: func(*gomock.Controller) *AddAutoRenewedValidatorTx { + return nil + }, + err: ErrNilTx, + }, + { + name: "already verified", + txFunc: func(*gomock.Controller) *AddAutoRenewedValidatorTx { + return &AddAutoRenewedValidatorTx{ + BaseTx: verifiedBaseTx, + } + }, + err: nil, + }, + { + name: "empty nodeID", + txFunc: func(*gomock.Controller) *AddAutoRenewedValidatorTx { + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.EmptyNodeID, + } + }, + err: errEmptyNodeID, + }, + { + name: "no provided stake", + txFunc: func(*gomock.Controller) *AddAutoRenewedValidatorTx { + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + StakeOuts: nil, + } + }, + err: errNoStake, + }, + { + name: "missing period", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + DelegationShares: reward.PercentDenominator, + Owner: configOwner, + } + }, + err: errMissingPeriod, + }, + { + name: "too many shares", + txFunc: func(*gomock.Controller) *AddAutoRenewedValidatorTx { + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + DelegationShares: reward.PercentDenominator + 1, + } + }, + err: errTooManyShares, + }, + { + name: "too many auto compound reward shares", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + DelegationShares: reward.PercentDenominator, + AutoCompoundRewardShares: reward.PercentDenominator + 1, + Owner: configOwner, + } + }, + err: errTooManyAutoCompoundRewardShares, + }, + { + name: "invalid BaseTx", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + BaseTx: invalidBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + DelegationShares: reward.PercentDenominator, + Owner: configOwner, + } + }, + err: avax.ErrWrongNetworkID, + }, + { + name: "invalid validator rewards owner", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + invalidRewardsOwner := fxmock.NewOwner(ctrl) + invalidRewardsOwner.EXPECT().Verify().Return(dummyErr) + + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).MaxTimes(1) + + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: &signer.Empty{}, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: invalidRewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + Owner: configOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: dummyErr, + }, + { + name: "invalid delegator rewards owner", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + invalidRewardsOwner := fxmock.NewOwner(ctrl) + invalidRewardsOwner.EXPECT().Verify().Return(dummyErr) + + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil) + + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: &signer.Empty{}, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: invalidRewardsOwner, + Owner: configOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: dummyErr, + }, + { + name: "wrong signer", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: &signer.Empty{}, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + Owner: configOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: errMissingSigner, + }, + { + name: "invalid stake output", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + stakeOut := avaxmock.NewTransferableOut(ctrl) + stakeOut.EXPECT().Verify().Return(dummyErr) + + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: stakeOut, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + Owner: configOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: dummyErr, + }, + { + name: "stake overflow", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: math.MaxUint64, + }, + }, + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 2, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + Owner: configOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: safemath.ErrOverflow, + }, + { + name: "invalid staked asset", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + 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: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + Owner: configOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: errInvalidStakedAsset, + }, + { + name: "stake not sorted", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 2, + }, + }, + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + Owner: configOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: errOutputsNotSorted, + }, + { + name: "weight mismatch", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 1, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + Owner: configOwner, + DelegationShares: reward.PercentDenominator, + } + }, + err: errValidatorWeightMismatch, + }, + { + name: "valid auto-renewed validator", + txFunc: func(ctrl *gomock.Controller) *AddAutoRenewedValidatorTx { + rewardsOwner := fxmock.NewOwner(ctrl) + rewardsOwner.EXPECT().Verify().Return(nil).AnyTimes() + + configOwner := fxmock.NewOwner(ctrl) + configOwner.EXPECT().Verify().Return(nil).AnyTimes() + + return &AddAutoRenewedValidatorTx{ + BaseTx: validBaseTx, + ValidatorNodeID: ids.GenerateTestNodeID(), + Period: 1, + Wght: 2, + Signer: blsPOP, + StakeOuts: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: 1, + }, + }, + }, + ValidatorRewardsOwner: rewardsOwner, + DelegatorRewardsOwner: rewardsOwner, + DelegationShares: reward.PercentDenominator, + Owner: configOwner, + } + }, + 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(t, err, tt.err) + + if tx != nil { + require.Equal(t, tt.err == nil, tx.SyntacticallyVerified) + } + }) + } +} diff --git a/vms/platformvm/txs/codec.go b/vms/platformvm/txs/codec.go index df51404fb522..f2b84be0e80f 100644 --- a/vms/platformvm/txs/codec.go +++ b/vms/platformvm/txs/codec.go @@ -48,6 +48,7 @@ func init() { errs.Add( RegisterDurangoTypes(c), RegisterEtnaTypes(c), + RegisterHeliconTypes(c), ) } @@ -129,3 +130,13 @@ func RegisterEtnaTypes(targetCodec linearcodec.Codec) error { targetCodec.RegisterType(&DisableL1ValidatorTx{}), ) } + +// RegisterHeliconTypes registers the type information for transactions that +// were valid during the Helicon series of upgrades. +func RegisterHeliconTypes(targetCodec linearcodec.Codec) error { + return errors.Join( + targetCodec.RegisterType(&AddAutoRenewedValidatorTx{}), + targetCodec.RegisterType(&SetAutoRenewedValidatorConfigTx{}), + targetCodec.RegisterType(&RewardAutoRenewedValidatorTx{}), + ) +} diff --git a/vms/platformvm/txs/executor/atomic_tx_executor.go b/vms/platformvm/txs/executor/atomic_tx_executor.go index df62ad7de161..d4d18e3a6f96 100644 --- a/vms/platformvm/txs/executor/atomic_tx_executor.go +++ b/vms/platformvm/txs/executor/atomic_tx_executor.go @@ -128,6 +128,18 @@ func (*atomicTxExecutor) DisableL1ValidatorTx(*txs.DisableL1ValidatorTx) error { return ErrWrongTxType } +func (*atomicTxExecutor) AddAutoRenewedValidatorTx(*txs.AddAutoRenewedValidatorTx) error { + return ErrWrongTxType +} + +func (*atomicTxExecutor) SetAutoRenewedValidatorConfigTx(*txs.SetAutoRenewedValidatorConfigTx) error { + return ErrWrongTxType +} + +func (*atomicTxExecutor) RewardAutoRenewedValidatorTx(*txs.RewardAutoRenewedValidatorTx) error { + return ErrWrongTxType +} + func (e *atomicTxExecutor) ImportTx(*txs.ImportTx) error { return e.atomicTx() } diff --git a/vms/platformvm/txs/executor/helpers_test.go b/vms/platformvm/txs/executor/helpers_test.go index 5cbd4fd7d5a1..08575ce5bf13 100644 --- a/vms/platformvm/txs/executor/helpers_test.go +++ b/vms/platformvm/txs/executor/helpers_test.go @@ -247,6 +247,7 @@ func defaultConfig(f upgradetest.Fork) *config.Internal { MinValidatorStake: 5 * units.MilliAvax, MaxValidatorStake: 500 * units.MilliAvax, MinDelegatorStake: 1 * units.MilliAvax, + MinDelegationFee: 20000, MinStakeDuration: defaultMinStakingDuration, MaxStakeDuration: defaultMaxStakingDuration, RewardConfig: reward.Config{ diff --git a/vms/platformvm/txs/executor/proposal_tx_executor.go b/vms/platformvm/txs/executor/proposal_tx_executor.go index 204cd9b43ff0..c0c782b3e857 100644 --- a/vms/platformvm/txs/executor/proposal_tx_executor.go +++ b/vms/platformvm/txs/executor/proposal_tx_executor.go @@ -8,11 +8,11 @@ import ( "fmt" "time" - "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/ids" "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/state" "github.com/ava-labs/avalanchego/vms/platformvm/txs" @@ -32,14 +32,17 @@ const ( var ( _ txs.Visitor = (*proposalTxExecutor)(nil) - ErrRemoveStakerTooEarly = errors.New("attempting to remove staker before their end time") - ErrRemoveWrongStaker = errors.New("attempting to remove wrong staker") - ErrInvalidState = errors.New("generated output isn't valid state") - ErrShouldBePermissionlessStaker = errors.New("expected permissionless staker") - ErrWrongTxType = errors.New("wrong transaction type") - ErrInvalidID = errors.New("invalid ID") - ErrProposedAddStakerTxAfterBanff = errors.New("staker transaction proposed after Banff") - ErrAdvanceTimeTxIssuedAfterBanff = errors.New("AdvanceTimeTx issued after Banff") + ErrRemoveStakerTooEarly = errors.New("attempting to remove staker before their end time") + ErrRemoveWrongStaker = errors.New("attempting to remove wrong staker") + ErrInvalidState = errors.New("generated output isn't valid state") + ErrShouldBePermissionlessStaker = errors.New("expected permissionless staker") + ErrWrongTxType = errors.New("wrong transaction type") + ErrInvalidID = errors.New("invalid ID") + ErrProposedAddStakerTxAfterBanff = errors.New("staker transaction proposed after Banff") + ErrAdvanceTimeTxIssuedAfterBanff = errors.New("AdvanceTimeTx issued after Banff") + errShouldBeAutoRenewedStaker = errors.New("expected auto renewed staker") + errShouldUseRewardAutoRenewedValidator = errors.New("auto-renewed validators must be rewarded with RewardAutoRenewedValidatorTx") + errInvalidTimestamp = errors.New("invalid timestamp") ) // ProposalTx executes the proposal transaction [tx]. @@ -147,6 +150,14 @@ func (*proposalTxExecutor) DisableL1ValidatorTx(*txs.DisableL1ValidatorTx) error return ErrWrongTxType } +func (*proposalTxExecutor) AddAutoRenewedValidatorTx(*txs.AddAutoRenewedValidatorTx) error { + return ErrWrongTxType +} + +func (*proposalTxExecutor) SetAutoRenewedValidatorConfigTx(*txs.SetAutoRenewedValidatorConfigTx) 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 @@ -334,50 +345,21 @@ func (e *proposalTxExecutor) RewardValidatorTx(tx *txs.RewardValidatorTx) error return txs.ErrNilTx case tx.TxID == ids.Empty: return ErrInvalidID - case len(e.tx.Creds) != 0: - return errWrongNumberOfCredentials } - currentStakerIterator, err := e.onCommitState.GetCurrentStakerIterator() + stakerTx, stakerToReward, err := verifyRewardTx(e.onCommitState, e.tx, tx) if err != nil { return err } - if !currentStakerIterator.Next() { - return fmt.Errorf("failed to get next staker to remove: %w", database.ErrNotFound) - } - stakerToReward := currentStakerIterator.Value() - currentStakerIterator.Release() - - 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 - currentChainTime := e.onCommitState.GetTimestamp() - 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) - } // Invariant: A [txs.DelegatorTx] does not also implement the // [txs.ValidatorTx] interface. switch uStakerTx := stakerTx.Unsigned.(type) { case txs.ValidatorTx: + if _, ok := uStakerTx.(*txs.AddAutoRenewedValidatorTx); ok { + return errShouldUseRewardAutoRenewedValidator + } + if err := e.rewardValidatorTx(uStakerTx, stakerToReward); err != nil { return err } @@ -411,6 +393,76 @@ func (e *proposalTxExecutor) RewardValidatorTx(tx *txs.RewardValidatorTx) error return ErrShouldBePermissionlessStaker } + return e.decreaseAbortStateCurrentSupply(stakerToReward) +} + +func (e *proposalTxExecutor) RewardAutoRenewedValidatorTx(tx *txs.RewardAutoRenewedValidatorTx) error { + if err := e.tx.SyntacticVerify(e.backend.Ctx); err != nil { + return err + } + + if tx.Timestamp != uint64(e.onCommitState.GetTimestamp().Unix()) { + return errInvalidTimestamp + } + + stakerTx, stakerToReward, err := verifyRewardTx(e.onCommitState, e.tx, tx) + if err != nil { + return err + } + + addAutoRenewedValidatorTx, ok := stakerTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + if !ok { + return errShouldBeAutoRenewedStaker + } + + stakingInfo, err := e.onCommitState.GetStakingInfo(stakerToReward.SubnetID, stakerToReward.NodeID) + if err != nil { + return fmt.Errorf("failed to get staking info: %w", err) + } + + // In onAbortState the staker is always removed. + if err := e.onAbortState.DeleteCurrentValidator(stakerToReward); err != nil { + return fmt.Errorf("deleting current validator from abort state: %w", err) + } + + if err := e.decreaseAbortStateCurrentSupply(stakerToReward); err != nil { + return err + } + + if stakingInfo.Period > 0 { + // Running auto-renewed staker: validator will continue to the next cycle. + // On commit: restake rewards (based on AutoCompoundRewardShares) and start new cycle. + // On abort: return stake + accrued rewards, forfeit current cycle's rewards. + + // Create UTXOs for onAbortState. + if err = e.createUTXOsAutoRenewedValidatorOnAbort(addAutoRenewedValidatorTx, stakerToReward, stakingInfo); err != nil { + return fmt.Errorf("failed to create UTXO auto-renewed validator on abort: %w", err) + } + + // Set onCommitState. + if err = e.setOnCommitStateAutoRenewedValidatorRestake(addAutoRenewedValidatorTx, stakerToReward, stakingInfo); err != nil { + return err + } + + // Early return because we don't need to do anything else. + return nil + } + + // Graceful exit: validator requested to stop (Period == 0). + // Return stake + all rewards on both commit and abort paths. + if err := e.createUTXOsAutoRenewedValidatorOnGracefulExit(addAutoRenewedValidatorTx, stakerToReward, stakingInfo); err != nil { + return err + } + + // Handle staker lifecycle. + if err := e.onCommitState.DeleteCurrentValidator(stakerToReward); err != nil { + return fmt.Errorf("deleting current validator from commit state: %w", err) + } + + return nil +} + +func (e *proposalTxExecutor) decreaseAbortStateCurrentSupply(stakerToReward *state.Staker) error { // If the reward is aborted, then the current supply should be decreased. currentSupply, err := e.onAbortState.GetCurrentSupply(stakerToReward.SubnetID) if err != nil { @@ -492,7 +544,7 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val } delegationRewardsOwner := uValidatorTx.DelegationRewardsOwner() - outIntf, err := e.backend.Fx.CreateOutput(delegateeReward, delegationRewardsOwner) + outIntf, err := e.backend.Fx.CreateOutput(stakingInfo.DelegateeReward, delegationRewardsOwner) if err != nil { return fmt.Errorf("failed to create output: %w", err) } @@ -659,3 +711,350 @@ func (e *proposalTxExecutor) rewardDelegatorTx(uDelegatorTx txs.DelegatorTx, del } return nil } + +// createUTXOsAutoRenewedValidatorOnAbort creates UTXOs for an auto-renewed validator +// that failed to meet eligibility requirements. +// +// The validator receives: +// - Staked tokens (returned via stake outputs) +// - Accrued validation rewards (from previous successful periods) +// - Accrued delegatee rewards + pending delegatee rewards +// +// The validator forfeits potential reward of the ended cycle. +func (e *proposalTxExecutor) createUTXOsAutoRenewedValidatorOnAbort( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + validator *state.Staker, + stakingInfo state.StakingInfo, +) error { + createUTXOsStakeOut(addAutoRenewedValidatorTx, validator, e.onAbortState) + return e.createAbortRewardUTXOs(addAutoRenewedValidatorTx, stakingInfo) +} + +// createAbortRewardUTXOs creates reward UTXOs on the abort state for an +// auto-renewed validator. This includes accrued validation rewards and +// all delegatee rewards (accrued + pending). +func (e *proposalTxExecutor) createAbortRewardUTXOs( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + stakingInfo state.StakingInfo, +) error { + totalDelegateeRewards, err := math.Add(stakingInfo.DelegateeReward, stakingInfo.AccruedDelegateeRewards) + if err != nil { + return err + } + + if _, err = e.createRewardsUTXOs(addAutoRenewedValidatorTx, stakingInfo.AccruedRewards, totalDelegateeRewards, e.onAbortState, uint32(len(e.tx.Unsigned.Outputs()))); err != nil { + return err + } + + return nil +} + +func (e *proposalTxExecutor) createRewardsUTXOs( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + validationRewards uint64, + delegateeRewards uint64, + chainState state.Diff, + outputIndexOffset uint32, +) (uint32, error) { + // Create UTXOs for validation rewards. + if validationRewards > 0 { + utxo, err := e.newUTXO(validationRewards, addAutoRenewedValidatorTx.ValidationRewardsOwner(), e.tx.ID(), outputIndexOffset) + if err != nil { + return 0, err + } + chainState.AddUTXO(utxo) + chainState.AddRewardUTXO(e.tx.ID(), utxo) + outputIndexOffset++ + } + + // Create UTXOs for delegatee rewards. + if delegateeRewards > 0 { + utxo, err := e.newUTXO(delegateeRewards, addAutoRenewedValidatorTx.DelegationRewardsOwner(), e.tx.ID(), outputIndexOffset) + if err != nil { + return 0, err + } + chainState.AddUTXO(utxo) + chainState.AddRewardUTXO(e.tx.ID(), utxo) + outputIndexOffset++ + } + + return outputIndexOffset, nil +} + +// setOnCommitStateAutoRenewedValidatorRestake processes rewards for a running +// auto-renewed validator based on their AutoCompoundRewardShares configuration. +// +// The function: +// 1. Splits rewards (validation + delegatee) into restaking and withdrawing portions +// 2. Creates UTXOs for the withdrawn portion +// 3. Increases validator weight and accrued rewards by the restaking portion +// 4. If restaking would exceed MaxValidatorStake, the excess is withdrawn +// 5. Updates the validator state +func (e *proposalTxExecutor) setOnCommitStateAutoRenewedValidatorRestake( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + validator *state.Staker, + stakingInfo state.StakingInfo, +) error { + restakingRewards, withdrawingRewards := reward.Split(validator.PotentialReward, stakingInfo.AutoCompoundRewardShares) + restakingDelegateeRewards, withdrawingDelegateeRewards := reward.Split(stakingInfo.DelegateeReward, stakingInfo.AutoCompoundRewardShares) + + outputIndexOffset, err := e.createRewardsUTXOs(addAutoRenewedValidatorTx, withdrawingRewards, withdrawingDelegateeRewards, e.onCommitState, uint32(len(e.tx.Unsigned.Outputs()))) + if err != nil { + return err + } + + newAccruedRewards := stakingInfo.AccruedRewards + newWeight := validator.Weight + if restakingRewards > 0 { + newAccruedRewards, err = math.Add(stakingInfo.AccruedRewards, restakingRewards) + if err != nil { + return err + } + + newWeight, err = math.Add(validator.Weight, restakingRewards) + if err != nil { + return err + } + } + + newAccruedDelegateeRewards := stakingInfo.AccruedDelegateeRewards + if restakingDelegateeRewards > 0 { + newAccruedDelegateeRewards, err = math.Add(stakingInfo.AccruedDelegateeRewards, restakingDelegateeRewards) + if err != nil { + return err + } + + newWeight, err = math.Add(newWeight, restakingDelegateeRewards) + if err != nil { + return err + } + } + + if newWeight > e.backend.Config.MaxValidatorStake { + excessValidationRewards, excessDelegateeRewards, err := e.createOverflowUTXOs(addAutoRenewedValidatorTx, newWeight, restakingDelegateeRewards, restakingRewards, validator.Weight, outputIndexOffset) + if err != nil { + return err + } + + newAccruedRewards, err = math.Sub(newAccruedRewards, excessValidationRewards) + if err != nil { + return err + } + + newWeight, err = math.Sub(newWeight, excessValidationRewards) + if err != nil { + return err + } + + if excessDelegateeRewards > 0 { + newAccruedDelegateeRewards, err = math.Sub(newAccruedDelegateeRewards, excessDelegateeRewards) + if err != nil { + return err + } + + newWeight, err = math.Sub(newWeight, excessDelegateeRewards) + if err != nil { + return err + } + } + + // newWeight is equal to e.backend.Config.MaxValidatorStake. + } + + rewards, err := GetRewardsCalculator(e.backend, e.onCommitState, validator.SubnetID) + if err != nil { + return err + } + + currentSupply, err := e.onCommitState.GetCurrentSupply(validator.SubnetID) + if err != nil { + return err + } + + potentialReward := rewards.Calculate( + stakingInfo.Period, + newWeight, + currentSupply, + ) + + newCurrentSupply, err := math.Add(currentSupply, potentialReward) + if err != nil { + return err + } + + e.onCommitState.SetCurrentSupply(validator.SubnetID, newCurrentSupply) + + endTime := validator.EndTime.Add(stakingInfo.Period) + + // Update validator by deleting and putting back. + renewedValidator := *validator + renewedValidator.StartTime = renewedValidator.EndTime + renewedValidator.EndTime = endTime + renewedValidator.NextTime = endTime + renewedValidator.PotentialReward = potentialReward + renewedValidator.Weight = newWeight + + if err := e.onCommitState.DeleteCurrentValidator(validator); err != nil { + return fmt.Errorf("failed to delete validator from commit state: %w", err) + } + + if err := e.onCommitState.PutCurrentValidator(&renewedValidator); err != nil { + return fmt.Errorf("putting renewed validator: %w", err) + } + + // Update staking info + stakingInfo.DelegateeReward = 0 + stakingInfo.AccruedRewards = newAccruedRewards + stakingInfo.AccruedDelegateeRewards = newAccruedDelegateeRewards + + if err := e.onCommitState.SetStakingInfo(validator.SubnetID, validator.NodeID, stakingInfo); err != nil { + return fmt.Errorf("setting staking info for validator: %w", err) + } + + return nil +} + +// createUTXOsAutoRenewedValidatorOnGracefulExit creates UTXOs for an auto-renewed +// validator that is stopping gracefully. +// +// On commit (validator eligible for rewards): +// - Staked tokens (returned via stake outputs) +// - All validation rewards (PotentialReward + AccruedRewards) +// - All delegatee rewards (AccruedDelegateeRewards + pending delegatee rewards) +// +// On abort (validator not eligible for rewards): +// - Staked tokens (returned via stake outputs) +// - Only accrued validation rewards (AccruedRewards) +// - All delegatee rewards (AccruedDelegateeRewards + pending delegatee rewards) +func (e *proposalTxExecutor) createUTXOsAutoRenewedValidatorOnGracefulExit( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + validator *state.Staker, + stakingInfo state.StakingInfo, +) error { + createUTXOsStakeOut(addAutoRenewedValidatorTx, validator, e.onCommitState, e.onAbortState) + + if err := e.createAbortRewardUTXOs(addAutoRenewedValidatorTx, stakingInfo); err != nil { + return err + } + + totalRewards, err := math.Add(validator.PotentialReward, stakingInfo.AccruedRewards) + if err != nil { + return err + } + + totalDelegateeRewards, err := math.Add(stakingInfo.DelegateeReward, stakingInfo.AccruedDelegateeRewards) + if err != nil { + return err + } + + if _, err = e.createRewardsUTXOs(addAutoRenewedValidatorTx, totalRewards, totalDelegateeRewards, e.onCommitState, uint32(len(e.tx.Unsigned.Outputs()))); err != nil { + return err + } + + return nil +} + +// newUTXO creates a reward UTXO with the specified parameters. +// The caller is responsible for adding the UTXO to the appropriate state(s) +// via AddUTXO and AddRewardUTXO. +func (e *proposalTxExecutor) newUTXO( + amount uint64, + owner fx.Owner, + txID ids.ID, + outputIndex uint32, +) (*avax.UTXO, error) { + outIntf, err := e.backend.Fx.CreateOutput(amount, owner) + if err != nil { + return nil, fmt.Errorf("failed to create output: %w", err) + } + out, ok := outIntf.(verify.State) + if !ok { + return nil, ErrInvalidState + } + + return &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: outputIndex, + }, + Asset: avax.Asset{ID: e.backend.Ctx.AVAXAssetID}, + Out: out, + }, nil +} + +// createOverflowUTXOs creates UTXOs for the excess rewards +// that cannot be restaked because doing so would exceed MaxValidatorStake. +// +// When an auto-renewed validator's restaked rewards would push their weight above +// MaxValidatorStake, the excess is withdrawn. +// +// Returns the excess validation and delegatee rewards that were withdrawn. +func (e *proposalTxExecutor) createOverflowUTXOs( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + newWeight uint64, + delegateeReward uint64, + rewards uint64, + oldWeight uint64, + outputIndexOffset uint32, +) (excessValidationRewards uint64, excessDelegateeRewards uint64, err error) { + totalRestakingRewards, err := math.Sub(newWeight, oldWeight) + if err != nil { + return 0, 0, err + } + + // Calculate how much room we have to grow before hitting max stake. + restakingAvailability, err := math.Sub(e.backend.Config.MaxValidatorStake, oldWeight) + if err != nil { + return 0, 0, err + } + + // Distribute available space proportionally between validation and delegatee rewards. + restakingValidationReward, err := math.MulDiv(rewards, restakingAvailability, totalRestakingRewards) + if err != nil { + return 0, 0, err + } + + restakingDelegateeReward, err := math.Sub(restakingAvailability, restakingValidationReward) + if err != nil { + return 0, 0, err + } + + // rewards >= restakingValidationReward, but using math package as a defensive check. + excessValidationReward, err := math.Sub(rewards, restakingValidationReward) + if err != nil { + return 0, 0, err + } + + // delegateeReward >= restakingDelegateeReward, but using math package as a defensive check. + excessDelegateeReward, err := math.Sub(delegateeReward, restakingDelegateeReward) + if err != nil { + return 0, 0, err + } + + if _, err = e.createRewardsUTXOs(addAutoRenewedValidatorTx, excessValidationReward, excessDelegateeReward, e.onCommitState, outputIndexOffset); err != nil { + return 0, 0, err + } + + return excessValidationReward, excessDelegateeReward, nil +} + +// createUTXOsStakeOut creates UTXOs to return a validator's staked tokens. +// The UTXOs are added to all provided state diffs. +func createUTXOsStakeOut(addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, validator *state.Staker, states ...state.Diff) { + outputIndexOffset := len(addAutoRenewedValidatorTx.Outputs()) + + for i, out := range addAutoRenewedValidatorTx.Stake() { + utxo := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: validator.TxID, + OutputIndex: uint32(outputIndexOffset + i), + }, + Asset: out.Asset, + Out: out.Output(), + } + + for _, s := range states { + s.AddUTXO(utxo) + } + } +} diff --git a/vms/platformvm/txs/executor/reward_validator_test.go b/vms/platformvm/txs/executor/reward_validator_test.go index 04ed26db53f0..1ff9205c91dc 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 newRewardAutoRenewedValidatorTx(t testing.TB, txID ids.ID, timestamp uint64) *txs.Tx { + t.Helper() + + utx := &txs.RewardAutoRenewedValidatorTx{TxID: txID, Timestamp: timestamp} + tx, err := txs.NewSigned(utx, txs.Codec, nil) + require.NoError(t, err) + return tx +} + func TestRewardValidatorTxExecuteOnCommit(t *testing.T) { require := require.New(t) env := newEnvironment(t, upgradetest.ApricotPhase5) @@ -878,3 +889,863 @@ 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) { + var ( + env = newEnvironment(t, upgradetest.Fortuna) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + wallet = newWallet(t, env, walletConfig{}) + ) + + addAutoRenewedValidatorTx, err := wallet.IssueAddAutoRenewedValidatorTx( + ids.GenerateTestNodeID(), + env.config.MinValidatorStake, + must[*signer.ProofOfPossession](t)(signer.NewProofOfPossession(must[*localsigner.LocalSigner](t)(localsigner.New()))), + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + reward.PercentDenominator, + reward.PercentDenominator, + env.config.MinStakeDuration, + ) + require.NoError(t, err) + env.state.AddTx(addAutoRenewedValidatorTx, status.Committed) + + validatorTx := addAutoRenewedValidatorTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + + startTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + duration := time.Duration(validatorTx.Period) * time.Second + vdrStaker, err := state.NewStaker( + addAutoRenewedValidatorTx.ID(), + validatorTx, + startTime, + startTime.Add(duration), + validatorTx.Weight(), + uint64(1000000), + ) + require.NoError(t, err) + + require.NoError(t, env.state.PutCurrentValidator(vdrStaker)) + require.NoError(t, env.state.Commit()) + + require.NoError(t, env.state.SetStakingInfo(vdrStaker.SubnetID, vdrStaker.NodeID, state.StakingInfo{ + Period: time.Duration(validatorTx.Period) * time.Second, + })) + + env.state.SetTimestamp(vdrStaker.EndTime) + + err = ProposalTx( + &env.backend, + feeCalculator, + must[*txs.Tx](t)(newRewardValidatorTx(t, addAutoRenewedValidatorTx.ID())), + must[state.Diff](t)(state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed)), // onCommitState + must[state.Diff](t)(state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed)), // onAbortState + ) + require.ErrorIs(t, err, errShouldUseRewardAutoRenewedValidator) +} + +func TestRewardAutoRenewedValidatorTxErrors(t *testing.T) { + var ( + env = newEnvironment(t, upgradetest.Fortuna) + wallet = newWallet(t, env, walletConfig{}) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + ) + + vdrTx, err := wallet.IssueAddPermissionlessValidatorTx( + &txs.SubnetValidator{ + Validator: txs.Validator{ + NodeID: ids.GenerateTestNodeID(), + End: uint64(genesistest.DefaultValidatorStartTime.Add(2 * env.config.MinStakeDuration).Unix()), + Wght: env.config.MinValidatorStake, + }, + Subnet: constants.PrimaryNetworkID, + }, + must[*signer.ProofOfPossession](t)(signer.NewProofOfPossession(must[*localsigner.LocalSigner](t)(localsigner.New()))), + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + reward.PercentDenominator, + ) + require.NoError(t, err) + env.state.AddTx(vdrTx, status.Committed) + + staker, err := state.NewCurrentStaker( + vdrTx.ID(), + vdrTx.Unsigned.(*txs.AddPermissionlessValidatorTx), + time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0), + uint64(1000000), + ) + require.NoError(t, err) + require.NoError(t, env.state.PutCurrentValidator(staker)) + env.state.SetTimestamp(staker.EndTime) + + tests := []struct { + name string + wantErr error + updateStateAndGetTx func(t testing.TB, state *state.State) *txs.Tx + }{ + { + name: "wrong staker", + wantErr: ErrRemoveWrongStaker, + updateStateAndGetTx: func(t testing.TB, state *state.State) *txs.Tx { + return newRewardAutoRenewedValidatorTx(t, ids.GenerateTestID(), uint64(state.GetTimestamp().Unix())) + }, + }, + { + name: "invalid timestamp", + wantErr: errInvalidTimestamp, + updateStateAndGetTx: func(t testing.TB, state *state.State) *txs.Tx { + return newRewardAutoRenewedValidatorTx(t, staker.TxID, uint64(state.GetTimestamp().Unix())-1) + }, + }, + { + name: "invalid validator tx", + wantErr: errShouldBeAutoRenewedStaker, + updateStateAndGetTx: func(t testing.TB, state *state.State) *txs.Tx { + return newRewardAutoRenewedValidatorTx(t, vdrTx.ID(), uint64(state.GetTimestamp().Unix())) + }, + }, + { + name: "wrong number of credentials", + wantErr: errWrongNumberOfCredentials, + updateStateAndGetTx: func(t testing.TB, state *state.State) *txs.Tx { + tx := newRewardAutoRenewedValidatorTx(t, staker.TxID, uint64(state.GetTimestamp().Unix())) + tx.Creds = append(tx.Creds, &secp256k1fx.Credential{}) + return tx + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err = ProposalTx( + &env.backend, + feeCalculator, + tt.updateStateAndGetTx(t, env.state), + must[state.Diff](t)(state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed)), // onCommitState + must[state.Diff](t)(state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed)), // onAbortState + ) + + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestRewardAutoRenewedValidatorTxGracefulStop(t *testing.T) { + var ( + env = newEnvironment(t, upgradetest.Fortuna) + wallet = newWallet(t, env, walletConfig{}) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + ) + + vdrWeight := env.config.MaxValidatorStake - 500_000 + + sValidatorTx, err := wallet.IssueAddAutoRenewedValidatorTx( + ids.GenerateTestNodeID(), + vdrWeight, + must[*signer.ProofOfPossession](t)(signer.NewProofOfPossession(must[*localsigner.LocalSigner](t)(localsigner.New()))), + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}}, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}}, + &secp256k1fx.OutputOwners{}, + 100_000, + 400_000, + env.config.MinStakeDuration, + ) + require.NoError(t, err) + env.state.AddTx(sValidatorTx, status.Committed) + + validatorTx := sValidatorTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + avax.Produce(env.state, sValidatorTx.ID(), validatorTx.Outputs()) + + startTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + duration := time.Duration(validatorTx.Period) * time.Second + staker, err := state.NewStaker( + sValidatorTx.ID(), + validatorTx, + startTime, + startTime.Add(duration), + validatorTx.Weight(), + 10_000_000, + ) + require.NoError(t, err) + + require.NoError(t, env.state.PutCurrentValidator(staker)) + require.NoError(t, env.state.Commit()) + + require.NoError(t, env.state.SetStakingInfo(staker.SubnetID, staker.NodeID, state.StakingInfo{ + DelegateeReward: 5_000_000, + AccruedRewards: 1_000_000, + AccruedDelegateeRewards: 500_000, + AutoCompoundRewardShares: validatorTx.AutoCompoundRewardShares, + Period: 0, + })) + + env.state.SetTimestamp(staker.EndTime) + + rewardTx := newRewardAutoRenewedValidatorTx(t, sValidatorTx.ID(), uint64(env.state.GetTimestamp().Unix())) + + onCommitState, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + onAbortState, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + currentSupply, err := env.state.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + + require.NoError(t, ProposalTx( + &env.backend, + feeCalculator, + rewardTx, + onCommitState, + onAbortState, + )) + + commitSupply, err := onCommitState.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + require.Equal(t, commitSupply, currentSupply) + + abortSupply, err := onAbortState.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + require.Equal(t, abortSupply, currentSupply-staker.PotentialReward) + + for _, stateTest := range []struct { + diff state.Diff + wantValidationRewards uint64 + }{ + {diff: onAbortState, wantValidationRewards: 1_000_000}, + {diff: onCommitState, wantValidationRewards: 11_000_000}, + } { + _, err = stateTest.diff.GetCurrentValidator(staker.SubnetID, staker.NodeID) + require.ErrorIs(t, database.ErrNotFound, err) + + for i, stakeOut := range validatorTx.StakeOuts { + utxoID := &avax.UTXOID{ + TxID: sValidatorTx.ID(), + OutputIndex: uint32(len(validatorTx.Outputs())) + uint32(i), + } + + utxo, err := stateTest.diff.GetUTXO(utxoID.InputID()) + require.NoError(t, err) + + utxoOut := utxo.Out.(*secp256k1fx.TransferOutput) + require.Equal(t, stakeOut.Out.Amount(), utxoOut.Amt) + } + + rewardUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())), + } + rewardUTXO, err := stateTest.diff.GetUTXO(rewardUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, rewardUTXO.Asset.AssetID()) + require.Equal(t, stateTest.wantValidationRewards, rewardUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.ValidatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&rewardUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + delegatingRewardsUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 1, + } + delegatingRewardsUTXO, err := stateTest.diff.GetUTXO(delegatingRewardsUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, delegatingRewardsUTXO.Asset.AssetID()) + require.Equal(t, uint64(5_500_000), delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.DelegatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + // No other UTXOs + utxoID := &avax.UTXOID{ + TxID: sValidatorTx.ID(), + OutputIndex: uint32(len(validatorTx.Outputs()) + len(validatorTx.StakeOuts)), + } + _, err = stateTest.diff.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + + utxoID = &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 2, + } + _, err = stateTest.diff.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + } + + // Verify reward UTXOs are correctly tracked via GetRewardUTXOs. + require.NoError(t, onCommitState.Apply(env.state)) + require.NoError(t, env.state.Commit()) + + rewardUTXOs, err := env.state.GetRewardUTXOs(rewardTx.ID()) + require.NoError(t, err) + require.Len(t, rewardUTXOs, 2) + require.Equal(t, uint64(11_000_000), rewardUTXOs[0].Out.(*secp256k1fx.TransferOutput).Amount()) + require.Equal(t, uint64(5_500_000), rewardUTXOs[1].Out.(*secp256k1fx.TransferOutput).Amount()) +} + +func TestRewardAutoRenewedValidatorTxRestake(t *testing.T) { + var ( + env = newEnvironment(t, upgradetest.Fortuna) + wallet = newWallet(t, env, walletConfig{}) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + ) + + vdrWeight := env.config.MinValidatorStake + + sValidatorTx, err := wallet.IssueAddAutoRenewedValidatorTx( + ids.GenerateTestNodeID(), + vdrWeight, + must[*signer.ProofOfPossession](t)(signer.NewProofOfPossession(must[*localsigner.LocalSigner](t)(localsigner.New()))), + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}}, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}}, + &secp256k1fx.OutputOwners{}, + 100_000, + 400_000, + env.config.MinStakeDuration, + ) + require.NoError(t, err) + env.state.AddTx(sValidatorTx, status.Committed) + + validatorTx := sValidatorTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + avax.Produce(env.state, sValidatorTx.ID(), validatorTx.Outputs()) + + startTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + duration := time.Duration(validatorTx.Period) * time.Second + staker, err := state.NewStaker( + sValidatorTx.ID(), + validatorTx, + startTime, + startTime.Add(duration), + validatorTx.Weight(), + 10_000_000, + ) + require.NoError(t, err) + + require.NoError(t, env.state.PutCurrentValidator(staker)) + require.NoError(t, env.state.Commit()) + + require.NoError(t, env.state.SetStakingInfo(staker.SubnetID, staker.NodeID, state.StakingInfo{ + DelegateeReward: 5_000_000, + AccruedRewards: 1_000_000, + AccruedDelegateeRewards: 500_000, + AutoCompoundRewardShares: validatorTx.AutoCompoundRewardShares, + Period: time.Duration(validatorTx.Period) * time.Second, + })) + + env.state.SetTimestamp(staker.EndTime) + + rewardTx := newRewardAutoRenewedValidatorTx(t, sValidatorTx.ID(), uint64(env.state.GetTimestamp().Unix())) + + onCommitState, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + onAbortState, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + currentSupply, err := env.state.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + + require.NoError(t, ProposalTx( + &env.backend, + feeCalculator, + rewardTx, + onCommitState, + onAbortState, + )) + + // Check onAbortState. + { + _, err = onAbortState.GetCurrentValidator(staker.SubnetID, staker.NodeID) + require.ErrorIs(t, database.ErrNotFound, err) + + for i, stakeOut := range validatorTx.StakeOuts { + utxoID := &avax.UTXOID{ + TxID: sValidatorTx.ID(), + OutputIndex: uint32(len(validatorTx.Outputs())) + uint32(i), + } + + utxo, err := onAbortState.GetUTXO(utxoID.InputID()) + require.NoError(t, err) + + utxoOut := utxo.Out.(*secp256k1fx.TransferOutput) + require.Equal(t, stakeOut.Out.Amount(), utxoOut.Amt) + } + + rewardUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())), + } + rewardUTXO, err := onAbortState.GetUTXO(rewardUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, rewardUTXO.Asset.AssetID()) + require.Equal(t, uint64(1_000_000), rewardUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.ValidatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&rewardUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + delegatingRewardsUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 1, + } + delegatingRewardsUTXO, err := onAbortState.GetUTXO(delegatingRewardsUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, delegatingRewardsUTXO.Asset.AssetID()) + require.Equal(t, uint64(5_500_000), delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.DelegatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + // No other UTXOs + utxoID := &avax.UTXOID{ + TxID: sValidatorTx.ID(), + OutputIndex: uint32(len(validatorTx.Outputs()) + len(validatorTx.StakeOuts)), + } + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + + utxoID = &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 2, + } + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + + abortSupply, err := onAbortState.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + require.Equal(t, currentSupply-staker.PotentialReward, abortSupply) + } + + // Check onCommitState + { + validator, err := onCommitState.GetCurrentValidator(staker.SubnetID, staker.NodeID) + require.NoError(t, err) + + validatorMutables, err := onCommitState.GetStakingInfo(staker.SubnetID, staker.NodeID) + require.NoError(t, err) + + wantWeight := env.config.MinValidatorStake + 6_000_000 + wantAccruedRewards := uint64(5_000_000) + wantAccruedDelegateeRewards := uint64(2_500_000) + require.Equal(t, wantWeight, validator.Weight) + require.Equal(t, wantAccruedRewards, validatorMutables.AccruedRewards) + require.Equal(t, wantAccruedDelegateeRewards, validatorMutables.AccruedDelegateeRewards) + + // Check UTXOs for withdraws from auto-restake shares param + { + withdrawnRewardsUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())), + } + withdrawRewardsUTXO, err := onCommitState.GetUTXO(withdrawnRewardsUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, withdrawRewardsUTXO.Asset.AssetID()) + require.Equal(t, uint64(6_000_000), withdrawRewardsUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.ValidatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&withdrawRewardsUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + withdrawDelegateeRewardsUTXO := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 1, + } + withdrawDelegateeRewards, err := onCommitState.GetUTXO(withdrawDelegateeRewardsUTXO.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, withdrawDelegateeRewards.Asset.AssetID()) + require.Equal(t, uint64(3_000_000), withdrawDelegateeRewards.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.DelegatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&withdrawDelegateeRewards.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + } + + // No overflow UTXOs — new weight is below MaxValidatorStake + { + utxoID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 2, + } + _, err = onCommitState.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + + utxoID = &avax.UTXOID{ + TxID: sValidatorTx.ID(), + OutputIndex: uint32(len(validatorTx.Outputs())), + } + _, err = onCommitState.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + } + + commitSupply, err := onCommitState.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + require.Equal(t, currentSupply+validator.PotentialReward, commitSupply) + } + + // Verify reward UTXOs are correctly tracked via GetRewardUTXOs. + require.NoError(t, onCommitState.Apply(env.state)) + require.NoError(t, env.state.Commit()) + + rewardUTXOs, err := env.state.GetRewardUTXOs(rewardTx.ID()) + require.NoError(t, err) + require.Len(t, rewardUTXOs, 2) + require.Equal(t, uint64(6_000_000), rewardUTXOs[0].Out.(*secp256k1fx.TransferOutput).Amount()) + require.Equal(t, uint64(3_000_000), rewardUTXOs[1].Out.(*secp256k1fx.TransferOutput).Amount()) +} + +func TestRewardAutoRenewedValidatorTxMaxValidatorStake(t *testing.T) { + var ( + env = newEnvironment(t, upgradetest.Fortuna) + wallet = newWallet(t, env, walletConfig{}) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + ) + + vdrWeight := env.config.MaxValidatorStake - 2_000_000 + + sValidatorTx, err := wallet.IssueAddAutoRenewedValidatorTx( + ids.GenerateTestNodeID(), + vdrWeight, + must[*signer.ProofOfPossession](t)(signer.NewProofOfPossession(must[*localsigner.LocalSigner](t)(localsigner.New()))), + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}}, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}}, + &secp256k1fx.OutputOwners{}, + 100_000, + 400_000, + env.config.MinStakeDuration, + ) + require.NoError(t, err) + env.state.AddTx(sValidatorTx, status.Committed) + + validatorTx := sValidatorTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + avax.Produce(env.state, sValidatorTx.ID(), validatorTx.Outputs()) + + startTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + duration := time.Duration(validatorTx.Period) * time.Second + staker, err := state.NewStaker( + sValidatorTx.ID(), + validatorTx, + startTime, + startTime.Add(duration), + validatorTx.Weight(), + 10_000_000, + ) + require.NoError(t, err) + + require.NoError(t, env.state.PutCurrentValidator(staker)) + require.NoError(t, env.state.Commit()) + + require.NoError(t, env.state.SetStakingInfo(staker.SubnetID, staker.NodeID, state.StakingInfo{ + DelegateeReward: 5_000_000, + AccruedRewards: 1_000_000, + AccruedDelegateeRewards: 500_000, + AutoCompoundRewardShares: validatorTx.AutoCompoundRewardShares, + Period: time.Duration(validatorTx.Period) * time.Second, + })) + + env.state.SetTimestamp(staker.EndTime) + + rewardTx := newRewardAutoRenewedValidatorTx(t, sValidatorTx.ID(), uint64(env.state.GetTimestamp().Unix())) + + onCommitState, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + onAbortState, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + currentSupply, err := env.state.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + + require.NoError(t, ProposalTx( + &env.backend, + feeCalculator, + rewardTx, + onCommitState, + onAbortState, + )) + + // Check onAbortState. + { + _, err = onAbortState.GetCurrentValidator(staker.SubnetID, staker.NodeID) + require.ErrorIs(t, database.ErrNotFound, err) + + for i, stakeOut := range validatorTx.StakeOuts { + utxoID := &avax.UTXOID{ + TxID: sValidatorTx.ID(), + OutputIndex: uint32(len(validatorTx.Outputs())) + uint32(i), + } + + utxo, err := onAbortState.GetUTXO(utxoID.InputID()) + require.NoError(t, err) + + utxoOut := utxo.Out.(*secp256k1fx.TransferOutput) + require.Equal(t, stakeOut.Out.Amount(), utxoOut.Amt) + } + + rewardUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())), + } + rewardUTXO, err := onAbortState.GetUTXO(rewardUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, rewardUTXO.Asset.AssetID()) + require.Equal(t, uint64(1_000_000), rewardUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.ValidatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&rewardUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + delegatingRewardsUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 1, + } + delegatingRewardsUTXO, err := onAbortState.GetUTXO(delegatingRewardsUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, delegatingRewardsUTXO.Asset.AssetID()) + require.Equal(t, uint64(5_500_000), delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.DelegatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + // No other UTXOs + utxoID := &avax.UTXOID{ + TxID: sValidatorTx.ID(), + OutputIndex: uint32(len(validatorTx.Outputs()) + len(validatorTx.StakeOuts)), + } + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + + utxoID = &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 2, + } + _, err = onAbortState.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + + abortSupply, err := onAbortState.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + require.Equal(t, currentSupply-staker.PotentialReward, abortSupply) + } + + // Check onCommitState + { + validator, err := onCommitState.GetCurrentValidator(staker.SubnetID, staker.NodeID) + require.NoError(t, err) + + validatorMutables, err := onCommitState.GetStakingInfo(staker.SubnetID, staker.NodeID) + require.NoError(t, err) + + require.Equal(t, env.config.MaxValidatorStake, validator.Weight) + require.Equal(t, uint64(2_333_333), validatorMutables.AccruedRewards) + require.Equal(t, uint64(1_166_667), validatorMutables.AccruedDelegateeRewards) + + // Check UTXOs for withdraws from auto-restake shares param + { + withdrawnRewardsUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())), + } + withdrawRewardsUTXO, err := onCommitState.GetUTXO(withdrawnRewardsUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, withdrawRewardsUTXO.Asset.AssetID()) + require.Equal(t, uint64(6_000_000), withdrawRewardsUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.ValidatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&withdrawRewardsUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + withdrawDelegateeRewardsUTXO := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 1, + } + withdrawDelegateeRewards, err := onCommitState.GetUTXO(withdrawDelegateeRewardsUTXO.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, withdrawDelegateeRewards.Asset.AssetID()) + require.Equal(t, uint64(3_000_000), withdrawDelegateeRewards.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.DelegatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&withdrawDelegateeRewards.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + } + + // Check UTXOs for excess withdrawn + { + rewardUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 2, + } + rewardUTXO, err := onCommitState.GetUTXO(rewardUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, rewardUTXO.Asset.AssetID()) + require.Equal(t, uint64(2_666_667), rewardUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.ValidatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&rewardUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + delegatingRewardsUTXOID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 3, + } + delegatingRewardsUTXO, err := onCommitState.GetUTXO(delegatingRewardsUTXOID.InputID()) + require.NoError(t, err) + require.Equal(t, env.ctx.AVAXAssetID, delegatingRewardsUTXO.Asset.AssetID()) + require.Equal(t, uint64(1_333_333), delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).Amount()) + require.True(t, validatorTx.DelegatorRewardsOwner.(*secp256k1fx.OutputOwners).Equals(&delegatingRewardsUTXO.Out.(*secp256k1fx.TransferOutput).OutputOwners)) + + // No other UTXOs + utxoID := &avax.UTXOID{ + TxID: rewardTx.ID(), + OutputIndex: uint32(len(rewardTx.Unsigned.Outputs())) + 4, + } + _, err = onCommitState.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + + utxoID = &avax.UTXOID{ + TxID: sValidatorTx.ID(), + OutputIndex: uint32(len(validatorTx.Outputs())), + } + _, err = onCommitState.GetUTXO(utxoID.InputID()) + require.ErrorIs(t, database.ErrNotFound, err) + } + + commitSupply, err := onCommitState.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + require.Equal(t, currentSupply+validator.PotentialReward, commitSupply) + } + + // Verify reward UTXOs are correctly tracked via GetRewardUTXOs. + require.NoError(t, onCommitState.Apply(env.state)) + require.NoError(t, env.state.Commit()) + + rewardUTXOs, err := env.state.GetRewardUTXOs(rewardTx.ID()) + require.NoError(t, err) + require.Len(t, rewardUTXOs, 4) + require.Equal(t, uint64(6_000_000), rewardUTXOs[0].Out.(*secp256k1fx.TransferOutput).Amount()) + require.Equal(t, uint64(3_000_000), rewardUTXOs[1].Out.(*secp256k1fx.TransferOutput).Amount()) + require.Equal(t, uint64(2_666_667), rewardUTXOs[2].Out.(*secp256k1fx.TransferOutput).Amount()) + require.Equal(t, uint64(1_333_333), rewardUTXOs[3].Out.(*secp256k1fx.TransferOutput).Amount()) +} + +// TestRewardDelegatorToAutoRenewedValidator tests the full delegator reward +// flow for a delegator to an auto-renewed validator: delegator gets their +// share, delegatee share is deferred to StakingInfo.DelegateeReward. +func TestRewardDelegatorToAutoRenewedValidator(t *testing.T) { + var ( + env = newEnvironment(t, upgradetest.Fortuna) + wallet = newWallet(t, env, walletConfig{}) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + + vdrNodeID = ids.GenerateTestNodeID() + vdrRewardAddress = ids.GenerateTestShortID() + delRewardAddress = ids.GenerateTestShortID() + delegationShares = uint32(reward.PercentDenominator / 4) // 25% to delegatee + vdrWeight = env.config.MinValidatorStake + delRewardAmt = uint64(1_000_000) + vdrPotentialReward = uint64(2_000_000) + ) + + // Step 1: Create the auto-renewed validator. + sValidatorTx, err := wallet.IssueAddAutoRenewedValidatorTx( + vdrNodeID, + vdrWeight, + must[*signer.ProofOfPossession](t)(signer.NewProofOfPossession(must[*localsigner.LocalSigner](t)(localsigner.New()))), + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{vdrRewardAddress}}, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{vdrRewardAddress}}, + &secp256k1fx.OutputOwners{}, + delegationShares, + reward.PercentDenominator, // auto compound 100% + env.config.MinStakeDuration, + ) + require.NoError(t, err) + env.state.AddTx(sValidatorTx, status.Committed) + + validatorTx := sValidatorTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + avax.Produce(env.state, sValidatorTx.ID(), validatorTx.Outputs()) + + startTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + duration := time.Duration(validatorTx.Period) * time.Second + vdrStaker, err := state.NewStaker( + sValidatorTx.ID(), + validatorTx, + startTime, + startTime.Add(duration), + validatorTx.Weight(), + vdrPotentialReward, + ) + require.NoError(t, err) + + require.NoError(t, env.state.PutCurrentValidator(vdrStaker)) + require.NoError(t, env.state.Commit()) + + require.NoError(t, env.state.SetStakingInfo(vdrStaker.SubnetID, vdrStaker.NodeID, state.StakingInfo{ + AutoCompoundRewardShares: validatorTx.AutoCompoundRewardShares, + Period: time.Duration(validatorTx.Period) * time.Second, + })) + + // Step 2: Create a delegator to this auto-renewed validator. + delStartTime := genesistest.DefaultValidatorStartTimeUnix + 1 + delEndTime := uint64(startTime.Add(duration).Unix()) + + delTx, err := wallet.IssueAddDelegatorTx( + &txs.Validator{ + NodeID: vdrNodeID, + Start: delStartTime, + End: delEndTime, + Wght: env.config.MinDelegatorStake, + }, + &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{delRewardAddress}, + }, + ) + require.NoError(t, err) + + addDelTx := delTx.Unsigned.(*txs.AddDelegatorTx) + delStaker, err := state.NewCurrentStaker( + delTx.ID(), + addDelTx, + time.Unix(int64(delStartTime), 0), + delRewardAmt, + ) + require.NoError(t, err) + + require.NoError(t, env.state.PutCurrentDelegator(delStaker)) + env.state.AddTx(delTx, status.Committed) + env.state.SetTimestamp(time.Unix(int64(delEndTime), 0)) + require.NoError(t, env.state.Commit()) + + // Step 3: Reward the delegator via RewardValidatorTx. + rewardDelTx, err := newRewardValidatorTx(t, delTx.ID()) + require.NoError(t, err) + + delOnCommitState, err := state.NewDiff(lastAcceptedID, env, state.StakerAdditionAfterDeletionForbidden) + require.NoError(t, err) + + delOnAbortState, err := state.NewDiff(lastAcceptedID, env, state.StakerAdditionAfterDeletionForbidden) + require.NoError(t, err) + + require.NoError(t, ProposalTx( + &env.backend, + feeCalculator, + rewardDelTx, + delOnCommitState, + delOnAbortState, + )) + + // Verify delegator reward UTXO on commit: delegator gets 75% of delRewardAmt. + wantDelegateeReward, wantDelegatorReward := reward.Split(delRewardAmt, delegationShares) + numDelStakeUTXOs := uint32(len(delTx.Unsigned.InputIDs())) + delRewardUTXOID := &avax.UTXOID{ + TxID: delTx.ID(), + OutputIndex: numDelStakeUTXOs + 1, + } + utxo, err := delOnCommitState.GetUTXO(delRewardUTXOID.InputID()) + require.NoError(t, err) + require.IsType(t, &secp256k1fx.TransferOutput{}, utxo.Out) + castUTXO := utxo.Out.(*secp256k1fx.TransferOutput) + require.Equal(t, wantDelegatorReward, castUTXO.Amt) + require.True(t, set.Of(delRewardAddress).Equals(castUTXO.AddressesSet())) + + // Verify delegatee reward is NOT distributed yet (deferred post-Cortina). + preCortinaDelegateeUTXOID := &avax.UTXOID{ + TxID: delTx.ID(), + OutputIndex: numDelStakeUTXOs + 2, + } + _, err = delOnCommitState.GetUTXO(preCortinaDelegateeUTXOID.InputID()) + require.ErrorIs(t, err, database.ErrNotFound) + + // Verify delegatee reward in StakingInfo. + stakingInfo, err := delOnCommitState.GetStakingInfo(constants.PrimaryNetworkID, vdrNodeID) + require.NoError(t, err) + require.Equal(t, wantDelegateeReward, stakingInfo.DelegateeReward) + + stakingInfo, err = delOnAbortState.GetStakingInfo(constants.PrimaryNetworkID, vdrNodeID) + require.NoError(t, err) + require.Zero(t, stakingInfo.DelegateeReward) + + // Commit the delegator diff. + require.NoError(t, delOnCommitState.Apply(env.state)) + require.NoError(t, env.state.Commit()) + + // Verify reward UTXOs are correctly tracked via GetRewardUTXOs. + rewardUTXOs, err := env.state.GetRewardUTXOs(delTx.ID()) + require.NoError(t, err) + require.Len(t, rewardUTXOs, 1) + require.Equal(t, wantDelegatorReward, rewardUTXOs[0].Out.(*secp256k1fx.TransferOutput).Amount()) +} diff --git a/vms/platformvm/txs/executor/staker_tx_verification.go b/vms/platformvm/txs/executor/staker_tx_verification.go index b53f38be7716..5c067cb3b05a 100644 --- a/vms/platformvm/txs/executor/staker_tx_verification.go +++ b/vms/platformvm/txs/executor/staker_tx_verification.go @@ -42,6 +42,10 @@ 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") + errMissingStakerTx = errors.New("missing staker tx") + errInvalidStakerTxType = errors.New("invalid staker tx type") + errInvalidStakerTx = errors.New("invalid staker tx") + errMissingValidator = errors.New("missing validator") ) // verifySubnetValidatorPrimaryNetworkRequirements verifies the primary @@ -868,6 +872,202 @@ func verifyTransferSubnetOwnershipTx( return nil } +// verifyAddAutoRenewedValidatorTx carries out the validation for an AddAutoRenewedValidatorTx. +func verifyAddAutoRenewedValidatorTx( + backend *Backend, + feeCalculator fee.Calculator, + chainState state.Chain, + sTx *txs.Tx, + tx *txs.AddAutoRenewedValidatorTx, +) error { + if !backend.Config.UpgradeConfig.IsHeliconActivated(chainState.GetTimestamp()) { + return errHeliconUpgradeNotActive + } + + // 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() { + // Not bootstrapped yet -- don't need to do full verification. + return nil + } + + switch { + case tx.Weight() < backend.Config.MinValidatorStake: + // Ensure validator is staking at least the minimum amount + return ErrWeightTooSmall + + case tx.Weight() > backend.Config.MaxValidatorStake: + // Ensure validator isn't staking too much + return ErrWeightTooLarge + + case tx.Shares() < backend.Config.MinDelegationFee: + // Ensure the validator fee is at least the minimum amount + return ErrInsufficientDelegationFee + + case tx.Period < uint64(backend.Config.MinStakeDuration/time.Second): + // Ensure staking length is not too short + return ErrStakeTooShort + + case tx.Period > uint64(backend.Config.MaxStakeDuration/time.Second): + // Ensure staking length is not too long + return ErrStakeTooLong + } + + _, err := GetValidator(chainState, constants.PrimaryNetworkID, tx.NodeID()) + switch { + case err == nil: + return fmt.Errorf( + "%w: %s", + ErrDuplicateValidator, + tx.NodeID(), + ) + case errors.Is(err, database.ErrNotFound): + // OK: validator not found + + default: + return fmt.Errorf( + "failed to get primary network validator %s: %w", + tx.NodeID(), + err, + ) + } + + ins, outs, producedAVAX, err := utxo.GetInputOutputs(tx) + if err != nil { + return fmt.Errorf("getting utxos: %w", err) + } + + // Verify the flowcheck + fee, err := feeCalculator.CalculateFee(tx) + if err != nil { + return fmt.Errorf("calculating fee: %w", err) + } + + producedAVAX, err = safemath.Add(producedAVAX, fee) + if err != nil { + return fmt.Errorf("adding fee: %w", err) + } + + if err := backend.FlowChecker.VerifySpend( + tx, + chainState, + ins, + outs, + sTx.Creds, + map[ids.ID]uint64{ + backend.Ctx.AVAXAssetID: producedAVAX, + }, + ); err != nil { + return fmt.Errorf("%w: %w", ErrFlowCheckFailed, err) + } + + return nil +} + +// verifySetAutoRenewedValidatorConfigTx carries out the validation for an SetAutoRenewedValidatorConfigTx. +func verifySetAutoRenewedValidatorConfigTx( + backend *Backend, + feeCalculator fee.Calculator, + chainState state.Chain, + sTx *txs.Tx, + tx *txs.SetAutoRenewedValidatorConfigTx, +) (*state.Staker, error) { + if !backend.Config.UpgradeConfig.IsHeliconActivated(chainState.GetTimestamp()) { + return nil, errHeliconUpgradeNotActive + } + + if err := sTx.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 { + if errors.Is(err, database.ErrNotFound) { + return nil, errMissingStakerTx + } + + return nil, fmt.Errorf("error getting staker tx: %w", err) + } + + autoRenewedStakerTx, ok := stakerTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + if !ok { + return nil, fmt.Errorf("%w: %T", errInvalidStakerTxType, stakerTx.Unsigned) + } + + validator, err := chainState.GetCurrentValidator(constants.PrimaryNetworkID, autoRenewedStakerTx.NodeID()) + if err != nil { + if errors.Is(err, database.ErrNotFound) { + return nil, errMissingValidator + } + + return nil, fmt.Errorf("failed to get validator %s from state: %w", autoRenewedStakerTx.NodeID(), err) + } + + if tx.TxID != validator.TxID { + // This can happen if a validator restaked with the same node id. + // In this case, TxID should be the latest transaction of the auto-renewed validator. + return nil, fmt.Errorf("%w: wrong tx id", errInvalidStakerTx) + } + + if !backend.Bootstrapped.Get() { + // Not bootstrapped yet -- don't need to do full verification. + return validator, nil + } + + switch { + case tx.Period > 0 && tx.Period < uint64(backend.Config.MinStakeDuration/time.Second): + return nil, ErrStakeTooShort + case tx.Period > uint64(backend.Config.MaxStakeDuration/time.Second): + return nil, ErrStakeTooLong + } + + baseTxCreds, err := verifyAuthorization(backend.Fx, sTx, autoRenewedStakerTx.Owner, tx.Auth) + if err != nil { + return nil, err + } + + ins, outs, producedAVAX, err := utxo.GetInputOutputs(tx) + if err != nil { + return nil, fmt.Errorf("getting utxos %w", err) + } + + fee, err := feeCalculator.CalculateFee(tx) + if err != nil { + return nil, fmt.Errorf("calculating fee: %w", err) + } + + producedAVAX, err = safemath.Add(producedAVAX, fee) + if err != nil { + return nil, fmt.Errorf("adding fee: %w", err) + } + + if err := backend.FlowChecker.VerifySpend( + tx, + chainState, + ins, + outs, + baseTxCreds, + map[ids.ID]uint64{ + backend.Ctx.AVAXAssetID: producedAVAX, + }, + ); err != nil { + return nil, fmt.Errorf("%w: %w", ErrFlowCheckFailed, err) + } + + 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. @@ -886,3 +1086,47 @@ func verifyStakerStartTime(isDurangoActive bool, chainTime, stakerTime time.Time } return nil } + +func verifyRewardTx(chainState state.Chain, sTx *txs.Tx, tx txs.RewardTx) (*txs.Tx, *state.Staker, error) { + if len(sTx.Creds) != 0 { + return nil, nil, errWrongNumberOfCredentials + } + + currentStakerIterator, err := chainState.GetCurrentStakerIterator() + if err != nil { + return nil, nil, err + } + if !currentStakerIterator.Next() { + return nil, nil, fmt.Errorf("failed to get next staker to remove: %w", database.ErrNotFound) + } + stakerToReward := currentStakerIterator.Value() + currentStakerIterator.Release() + + if stakerToReward.TxID != tx.StakerTxID() { + return nil, nil, fmt.Errorf( + "%w: %s != %s", + ErrRemoveWrongStaker, + stakerToReward.TxID, + tx.StakerTxID(), + ) + } + + // Verify that the chain's timestamp is the validator's end time + currentChainTime := chainState.GetTimestamp() + if !stakerToReward.EndTime.Equal(currentChainTime) { + return nil, nil, fmt.Errorf( + "%w: TxID = %s with %s < %s", + ErrRemoveStakerTooEarly, + tx.StakerTxID(), + currentChainTime, + stakerToReward.EndTime, + ) + } + + stakerTx, _, err := chainState.GetTx(stakerToReward.TxID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get next removed staker tx: %w", err) + } + + return stakerTx, stakerToReward, nil +} diff --git a/vms/platformvm/txs/executor/standard_tx_executor.go b/vms/platformvm/txs/executor/standard_tx_executor.go index a23a6b1457a0..e900520386e9 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor.go +++ b/vms/platformvm/txs/executor/standard_tx_executor.go @@ -49,6 +49,7 @@ var ( 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") + errHeliconUpgradeNotActive = errors.New("attempting to use a Helicon-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") @@ -1370,6 +1371,105 @@ func (e *standardTxExecutor) DisableL1ValidatorTx(tx *txs.DisableL1ValidatorTx) return e.state.PutL1Validator(l1Validator) } +func (e *standardTxExecutor) AddAutoRenewedValidatorTx(tx *txs.AddAutoRenewedValidatorTx) error { + if err := verifyAddAutoRenewedValidatorTx(e.backend, e.feeCalculator, e.state, e.tx, tx); err != nil { + return err + } + + currentSupply, err := e.state.GetCurrentSupply(constants.PrimaryNetworkID) + if err != nil { + return fmt.Errorf("getting current supply %w", err) + } + + rewards, err := GetRewardsCalculator(e.backend, e.state, constants.PrimaryNetworkID) + if err != nil { + return fmt.Errorf("getting rewards calculator %w", err) + } + + period := time.Duration(tx.Period) * time.Second + potentialReward := rewards.Calculate( + period, + tx.Weight(), + currentSupply, + ) + + newCurrentSupply, err := math.Add(currentSupply, potentialReward) + if err != nil { + return fmt.Errorf("adding current supply %w", err) + } + e.state.SetCurrentSupply(constants.PrimaryNetworkID, newCurrentSupply) + + startTime := e.state.GetTimestamp() + endTime := startTime.Add(period) + + staker, err := state.NewStaker( + e.tx.ID(), + tx, + startTime, + endTime, + tx.Weight(), + potentialReward, + ) + if err != nil { + return fmt.Errorf("creating staker %w", err) + } + + if err := e.state.PutCurrentValidator(staker); err != nil { + return fmt.Errorf("putting current validator: %w", err) + } + + stakingInfo := state.StakingInfo{ + AutoCompoundRewardShares: tx.AutoCompoundRewardShares, + Period: period, + } + if err := e.state.SetStakingInfo(staker.SubnetID, staker.NodeID, stakingInfo); err != nil { + return fmt.Errorf("setting staking info: %w", err) + } + + avax.Consume(e.state, tx.Ins) + avax.Produce(e.state, e.tx.ID(), tx.Outs) + + if e.backend.Config.PartialSyncPrimaryNetwork && + tx.NodeID() == 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", e.tx.ID()), + zap.String("txType", "addAutoRenewedValidatorTx"), + zap.Stringer("nodeID", tx.NodeID()), + ) + } + + return nil +} + +func (e *standardTxExecutor) SetAutoRenewedValidatorConfigTx(tx *txs.SetAutoRenewedValidatorConfigTx) error { + validator, err := verifySetAutoRenewedValidatorConfigTx(e.backend, e.feeCalculator, e.state, e.tx, tx) + if err != nil { + return err + } + + stakingInfo, err := e.state.GetStakingInfo(validator.SubnetID, validator.NodeID) + if err != nil { + return fmt.Errorf("could not get staking info: %w", err) + } + + stakingInfo.AutoCompoundRewardShares = tx.AutoCompoundRewardShares + stakingInfo.Period = time.Duration(tx.Period) * time.Second + + if err := e.state.SetStakingInfo(validator.SubnetID, validator.NodeID, stakingInfo); err != nil { + return fmt.Errorf("could not set staking info: %w", err) + } + + avax.Consume(e.state, tx.Ins) + avax.Produce(e.state, e.tx.ID(), tx.Outs) + + return nil +} + +func (*standardTxExecutor) RewardAutoRenewedValidatorTx(*txs.RewardAutoRenewedValidatorTx) error { + return ErrWrongTxType +} + // Creates the staker as defined in [stakerTx] and adds it to [e.State]. func (e *standardTxExecutor) putStaker(stakerTx txs.Staker) error { var ( diff --git a/vms/platformvm/txs/executor/standard_tx_executor_test.go b/vms/platformvm/txs/executor/standard_tx_executor_test.go index f76590a34855..8ab6f96e6254 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor_test.go +++ b/vms/platformvm/txs/executor/standard_tx_executor_test.go @@ -26,6 +26,7 @@ import ( "github.com/ava-labs/avalanchego/utils/crypto/bls/signer/localsigner" "github.com/ava-labs/avalanchego/utils/crypto/secp256k1" "github.com/ava-labs/avalanchego/utils/hashing" + "github.com/ava-labs/avalanchego/utils/iterator" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/avalanchego/utils/units" @@ -4355,6 +4356,559 @@ func TestStandardExecutorDisableL1ValidatorTx(t *testing.T) { } } +func TestStandardExecutorAddAutoRenewedValidatorTx(t *testing.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, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + sk, err := localsigner.New() + require.NoError(t, err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(t, err) + + nodeID := ids.GenerateTestNodeID() + continuationPeriod := 2 * env.config.MinStakeDuration + weight := 2 * env.config.MinValidatorStake + configOwner := &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}} + + addContVdrTx, err := wallet.IssueAddAutoRenewedValidatorTx( + nodeID, + weight, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}}, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{ids.GenerateTestShortID()}}, + configOwner, + 100_000, + 200_000, + continuationPeriod, + ) + require.NoError(t, err) + env.state.AddTx(addContVdrTx, status.Committed) + + currentSupply, err := env.state.GetCurrentSupply(constants.PrimaryNetworkID) + require.NoError(t, err) + + wantPotentialReward := env.backend.Rewards.Calculate( + continuationPeriod, + weight, + currentSupply, + ) + + _, _, _, err = StandardTx( + &env.backend, + feeCalculator, + addContVdrTx, + diff, + ) + require.NoError(t, err) + require.True(t, addContVdrTx.Unsigned.(*txs.AddAutoRenewedValidatorTx).BaseTx.SyntacticallyVerified) + require.NoError(t, diff.Apply(env.state)) + require.NoError(t, env.state.Commit()) + + validator, err := env.state.GetCurrentValidator(constants.PrimaryNetworkID, nodeID) + require.NoError(t, err) + + wantValidator := &state.Staker{ + TxID: addContVdrTx.TxID, + NodeID: nodeID, + PublicKey: sk.PublicKey(), + SubnetID: constants.PrimaryNetworkID, + Weight: weight, + StartTime: diff.GetTimestamp(), + EndTime: diff.GetTimestamp().Add(continuationPeriod), + PotentialReward: wantPotentialReward, + NextTime: diff.GetTimestamp().Add(continuationPeriod), + Priority: txs.PrimaryNetworkValidatorCurrentPriority, + } + require.Equal(t, wantValidator, validator) + + stakingInfo, err := env.state.GetStakingInfo(constants.PrimaryNetworkID, nodeID) + require.NoError(t, err) + + wantStakingInfo := state.StakingInfo{ + DelegateeReward: 0, + AccruedRewards: 0, + AccruedDelegateeRewards: 0, + AutoCompoundRewardShares: 200_000, + Period: continuationPeriod, + } + require.Equal(t, wantStakingInfo, stakingInfo) + + inputIDs := addContVdrTx.InputIDs() + require.NotEmpty(t, inputIDs) + for utxoID := range inputIDs { + _, err := diff.GetUTXO(utxoID) + require.ErrorIs(t, err, database.ErrNotFound) + } + + baseTxOutputUTXOs := addContVdrTx.UTXOs() + require.NotEmpty(t, baseTxOutputUTXOs) + for _, wantUTXO := range baseTxOutputUTXOs { + utxoID := wantUTXO.InputID() + utxo, err := diff.GetUTXO(utxoID) + require.NoError(t, err) + require.Equal(t, wantUTXO, utxo) + } +} + +func TestStandardExecutorAddAutoRenewedValidatorTxErrors(t *testing.T) { + var ( + env = newEnvironment(t, upgradetest.Latest) + wallet = newWallet(t, env, walletConfig{}) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + ) + + it, err := env.state.GetCurrentStakerIterator() + require.NoError(t, err) + + validators := iterator.ToSlice(it) + require.NotEmpty(t, validators) + + existingNodeID := validators[0].NodeID + + tests := []struct { + name string + wantErr error + updateState func(state.Diff) + updateTx func(*txs.AddAutoRenewedValidatorTx) + }{ + { + name: "invalid upgrade", + updateState: func(diff state.Diff) { + diff.SetTimestamp(env.backend.Config.UpgradeConfig.HeliconTime.Add(-1 * time.Second)) + }, + wantErr: errHeliconUpgradeNotActive, + }, + { + name: "weight too small", + updateTx: func(tx *txs.AddAutoRenewedValidatorTx) { + tx.Wght = env.config.MinValidatorStake - 1 + tx.StakeOuts[0].Out.(*secp256k1fx.TransferOutput).Amt = env.config.MinValidatorStake - 1 + }, + wantErr: ErrWeightTooSmall, + }, + { + name: "weight too large", + updateTx: func(tx *txs.AddAutoRenewedValidatorTx) { + tx.Wght = env.config.MaxValidatorStake + 1 + tx.StakeOuts[0].Out.(*secp256k1fx.TransferOutput).Amt = env.config.MaxValidatorStake + 1 + }, + wantErr: ErrWeightTooLarge, + }, + { + name: "insufficient delegation fee", + updateTx: func(tx *txs.AddAutoRenewedValidatorTx) { + tx.DelegationShares = env.config.MinDelegationFee - 1 + }, + wantErr: ErrInsufficientDelegationFee, + }, + { + name: "stake too short", + updateTx: func(tx *txs.AddAutoRenewedValidatorTx) { + tx.Period = uint64(env.config.MinStakeDuration.Seconds()) - 1 + }, + wantErr: ErrStakeTooShort, + }, + { + name: "stake too long", + updateTx: func(tx *txs.AddAutoRenewedValidatorTx) { + tx.Period = uint64(env.config.MaxStakeDuration.Seconds()) + 1 + }, + wantErr: ErrStakeTooLong, + }, + { + name: "duplicate validator", + updateTx: func(tx *txs.AddAutoRenewedValidatorTx) { + tx.ValidatorNodeID = existingNodeID + }, + wantErr: ErrDuplicateValidator, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sk, err := localsigner.New() + require.NoError(t, err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(t, err) + + tx, err := wallet.IssueAddAutoRenewedValidatorTx( + ids.GenerateTestNodeID(), + env.config.MinValidatorStake, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + 500_000, + 300_000, + env.config.MinStakeDuration, + ) + require.NoError(t, err) + + diff, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + if tt.updateState != nil { + tt.updateState(diff) + } + + if tt.updateTx != nil { + tt.updateTx(tx.Unsigned.(*txs.AddAutoRenewedValidatorTx)) + } + + _, _, _, err = StandardTx( + &env.backend, + feeCalculator, + tx, + diff, + ) + + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestStandardExecutorSetAutoRenewedValidatorConfigTx(t *testing.T) { + var ( + env = newEnvironment(t, upgradetest.Latest) + wallet = newWallet(t, env, walletConfig{}) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + ) + + sk, err := localsigner.New() + require.NoError(t, err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(t, err) + + nodeID := ids.GenerateTestNodeID() + + addAutoRenewedValidatorTx, err := wallet.IssueAddAutoRenewedValidatorTx( + nodeID, + env.config.MinValidatorStake, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + 500_000, + 300_000, + 2*env.config.MinStakeDuration, + ) + require.NoError(t, err) + env.state.AddTx(addAutoRenewedValidatorTx, status.Committed) + + validatorTx := addAutoRenewedValidatorTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + + startTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + duration := time.Duration(validatorTx.Period) * time.Second + vdrStaker, err := state.NewStaker( + addAutoRenewedValidatorTx.ID(), + validatorTx, + startTime, + startTime.Add(duration), + validatorTx.Weight(), + uint64(1000000), + ) + require.NoError(t, err) + + require.NoError(t, env.state.PutCurrentValidator(vdrStaker)) + require.NoError(t, env.state.Commit()) + + tests := []struct { + name string + newPeriod time.Duration + newAutoCompoundRewardShares uint32 + }{ + { + name: "updated period and auto-compound reward shares", + newAutoCompoundRewardShares: uint32(300_000), + newPeriod: 30 * 24 * time.Hour, + }, + { + name: "period 0 (exit requested)", + newAutoCompoundRewardShares: uint32(300_000), + newPeriod: 0, + }, + } + + fundingKey := genesistest.DefaultFundedKeys[0] + fundingAddr := fundingKey.Address() + existingUTXOIDs, err := env.state.UTXOIDs(fundingAddr[:], ids.Empty, 1) + require.NoError(t, err) + require.NotEmpty(t, existingUTXOIDs) + + existingUTXO, err := env.state.GetUTXO(existingUTXOIDs[0]) + require.NoError(t, err) + + utxoOut := existingUTXO.Out.(*secp256k1fx.TransferOutput) + utxoAmount := utxoOut.Amt + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + unsignedTx, err := wallet.Builder().NewSetAutoRenewedValidatorConfigTx( + addAutoRenewedValidatorTx.TxID, + tt.newAutoCompoundRewardShares, + tt.newPeriod, + ) + require.NoError(t, err) + + unsignedTx.Ins = append(unsignedTx.Ins, &avax.TransferableInput{ + UTXOID: existingUTXO.UTXOID, + Asset: existingUTXO.Asset, + In: &secp256k1fx.TransferInput{ + Amt: utxoAmount, + Input: secp256k1fx.Input{ + SigIndices: []uint32{0}, + }, + }, + }) + unsignedTx.Outs = append(unsignedTx.Outs, &avax.TransferableOutput{ + Asset: existingUTXO.Asset, + Out: &secp256k1fx.TransferOutput{ + Amt: utxoAmount, + OutputOwners: utxoOut.OutputOwners, + }, + }) + + setAutoRenewedValidatorConfigTx, err := txs.NewSigned(unsignedTx, txs.Codec, [][]*secp256k1.PrivateKey{ + {fundingKey}, // credential for the input + {}, // credential for the auth (empty owner) + }) + require.NoError(t, err) + + diff, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + _, _, _, err = StandardTx( + &env.backend, + feeCalculator, + setAutoRenewedValidatorConfigTx, + diff, + ) + + require.NoError(t, err) + require.True(t, setAutoRenewedValidatorConfigTx.Unsigned.(*txs.SetAutoRenewedValidatorConfigTx).BaseTx.SyntacticallyVerified) + + stakingInfo, err := diff.GetStakingInfo(constants.PrimaryNetworkID, nodeID) + require.NoError(t, err) + + require.Equal(t, tt.newAutoCompoundRewardShares, stakingInfo.AutoCompoundRewardShares) + require.Equal(t, tt.newPeriod, stakingInfo.Period) + + inputIDs := setAutoRenewedValidatorConfigTx.InputIDs() + require.NotEmpty(t, inputIDs) + for inputID := range inputIDs { + _, err := diff.GetUTXO(inputID) + require.ErrorIs(t, err, database.ErrNotFound) + } + + outputUTXOs := setAutoRenewedValidatorConfigTx.UTXOs() + require.NotEmpty(t, outputUTXOs) + for _, wantUTXO := range outputUTXOs { + gotUTXO, err := diff.GetUTXO(wantUTXO.InputID()) + require.NoError(t, err) + require.Equal(t, wantUTXO, gotUTXO) + } + }) + } +} + +func TestStandardExecutorSetAutoRenewedValidatorConfigTxErrors(t *testing.T) { + var ( + env = newEnvironment(t, upgradetest.Latest) + wallet = newWallet(t, env, walletConfig{}) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + ) + + it, err := env.state.GetCurrentStakerIterator() + require.NoError(t, err) + + validators := iterator.ToSlice(it) + require.NotEmpty(t, validators) + fixedStakerTxID := validators[0].TxID + + sk, err := localsigner.New() + require.NoError(t, err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(t, err) + + nodeID := ids.GenerateTestNodeID() + + addPastContValidatorTx, err := wallet.IssueAddAutoRenewedValidatorTx( + nodeID, + env.config.MinValidatorStake, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + 0, + 0, + env.config.MinStakeDuration, + ) + require.NoError(t, err) + env.state.AddTx(addPastContValidatorTx, status.Committed) + + addAutoRenewedValidatorTx, err := wallet.IssueAddAutoRenewedValidatorTx( + nodeID, + env.config.MinValidatorStake, + pop, + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{Threshold: 1, Addrs: []ids.ShortID{genesistest.DefaultFundedKeys[0].Address()}}, + 500_000, + 300_000, + 2*env.config.MinStakeDuration, + ) + require.NoError(t, err) + env.state.AddTx(addAutoRenewedValidatorTx, status.Committed) + + validatorTx := addAutoRenewedValidatorTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + + startTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + duration := time.Duration(validatorTx.Period) * time.Second + staker, err := state.NewStaker( + addAutoRenewedValidatorTx.ID(), + validatorTx, + startTime, + startTime.Add(duration), + validatorTx.Weight(), + 0, + ) + require.NoError(t, err) + + require.NoError(t, env.state.PutCurrentValidator(staker)) + require.NoError(t, env.state.Commit()) + + tests := []struct { + name string + updateTx func(testing.TB, *txs.SetAutoRenewedValidatorConfigTx, *txs.Tx) + updateState func(testing.TB, state.Diff) + wantErr error + }{ + { + name: "invalid upgrade", + updateState: func(_ testing.TB, diff state.Diff) { + diff.SetTimestamp(env.backend.Config.UpgradeConfig.HeliconTime.Add(-1 * time.Second)) + }, + wantErr: errHeliconUpgradeNotActive, + }, + { + name: "stopped validator", + updateState: func(t testing.TB, diff state.Diff) { + require.NoError(t, diff.DeleteCurrentValidator(&state.Staker{ + TxID: addAutoRenewedValidatorTx.ID(), + NodeID: nodeID, + SubnetID: constants.PrimaryNetworkID, + })) + }, + wantErr: errMissingValidator, + }, + { + name: "missing staker tx", + updateTx: func(_ testing.TB, tx *txs.SetAutoRenewedValidatorConfigTx, _ *txs.Tx) { + tx.TxID = ids.GenerateTestID() + }, + wantErr: errMissingStakerTx, + }, + { + name: "invalid staker tx", + updateTx: func(_ testing.TB, tx *txs.SetAutoRenewedValidatorConfigTx, _ *txs.Tx) { + tx.TxID = addPastContValidatorTx.ID() + }, + wantErr: errInvalidStakerTx, + }, + { + name: "invalid staker tx type", + updateTx: func(_ testing.TB, tx *txs.SetAutoRenewedValidatorConfigTx, _ *txs.Tx) { + tx.TxID = fixedStakerTxID + }, + wantErr: errInvalidStakerTxType, + }, + { + name: "stake too short", + updateTx: func(_ testing.TB, tx *txs.SetAutoRenewedValidatorConfigTx, _ *txs.Tx) { + tx.Period = uint64(env.config.MinStakeDuration.Seconds()) - 1 + }, + wantErr: ErrStakeTooShort, + }, + { + name: "stake too long", + updateTx: func(_ testing.TB, tx *txs.SetAutoRenewedValidatorConfigTx, _ *txs.Tx) { + tx.Period = uint64(env.config.MaxStakeDuration.Seconds()) + 1 + }, + wantErr: ErrStakeTooLong, + }, + { + name: "invalid auth", + updateTx: func(t testing.TB, tx *txs.SetAutoRenewedValidatorConfigTx, sTx *txs.Tx) { + dummySig, err := genesistest.DefaultFundedKeys[1].SignHash([]byte{}) + require.NoError(t, err) + + tx.Auth = &secp256k1fx.Input{SigIndices: []uint32{0}} + sTx.Creds = []verify.Verifiable{&secp256k1fx.Credential{}, &secp256k1fx.Credential{ + Sigs: [][secp256k1.SignatureLen]byte{[secp256k1.SignatureLen]byte(dummySig)}, + }} + }, + wantErr: secp256k1fx.ErrWrongSig, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + tx, err := wallet.IssueSetAutoRenewedValidatorConfigTx(addAutoRenewedValidatorTx.ID(), 0, 0) + require.NoError(t, err) + + if tt.updateState != nil { + tt.updateState(t, diff) + } + + if tt.updateTx != nil { + tt.updateTx(t, tx.Unsigned.(*txs.SetAutoRenewedValidatorConfigTx), tx) + } + + _, _, _, err = StandardTx( + &env.backend, + feeCalculator, + tx, + diff, + ) + + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestStandardExecutorRewardAutoRenewedValidatorTx(t *testing.T) { + env := newEnvironment(t, upgradetest.Latest) + + diff, err := state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed) + require.NoError(t, err) + + _, _, _, err = StandardTx( + &env.backend, + state.PickFeeCalculator(env.config, env.state), + newRewardAutoRenewedValidatorTx(t, ids.GenerateTestID(), 1), + diff, + ) + require.ErrorIs(t, 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 f70f3717acf6..3d5ec2a0b6f5 100644 --- a/vms/platformvm/txs/executor/warp_verifier.go +++ b/vms/platformvm/txs/executor/warp_verifier.go @@ -122,6 +122,18 @@ func (w *warpVerifier) SetL1ValidatorWeightTx(tx *txs.SetL1ValidatorWeightTx) er return w.verify(tx.Message) } +func (*warpVerifier) AddAutoRenewedValidatorTx(*txs.AddAutoRenewedValidatorTx) error { + return nil +} + +func (*warpVerifier) SetAutoRenewedValidatorConfigTx(*txs.SetAutoRenewedValidatorConfigTx) error { + return nil +} + +func (*warpVerifier) RewardAutoRenewedValidatorTx(*txs.RewardAutoRenewedValidatorTx) 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/calculator_test.go b/vms/platformvm/txs/fee/calculator_test.go index 88d5dbb569a9..927cc619750f 100644 --- a/vms/platformvm/txs/fee/calculator_test.go +++ b/vms/platformvm/txs/fee/calculator_test.go @@ -253,5 +253,36 @@ var ( }, expectedDynamicFee: 166_347 * units.NanoAvax, }, + { + name: "AddAutoRenewedValidatorTx", + tx: "00000000002800003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520c676e000000000000000000000000001000000018e6f924ff3cf5ea371b09db6ed75915c42be833800000001043c91e9d508169329034e2a68110427a311f945efc53ed3f3493d335b393fd100000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f26fc10000000000010000000000000000c582872c37c81efa2c94ea347af49cdc23a830aa0000001ca93047d863dd5c9d5f95d87789e8b3f45ace039cd50a2ac04e4fee7cc6b9aa97de3d957d69bb49c1bcd013e73a206d6e8c1102319797a698caa3fca0f3a2af54a6010b3e629bb8eeca192289b72f48cb21f40bff49e8663aa9550c3715883fe311a5623fa4c4287f426d39246c0d599ad8d4481c993c6826db7ce6365dddbe81daedbd706237e242ec4ade9fced8eccf00000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007000001d1a94a2000000000000000000000000001000000018e6f924ff3cf5ea371b09db6ed75915c42be83380000000b000000000000000000000001000000018e6f924ff3cf5ea371b09db6ed75915c42be83380000000b000000000000000000000001000000018e6f924ff3cf5ea371b09db6ed75915c42be83380000000b000000000000000000000001000000018e6f924ff3cf5ea371b09db6ed75915c42be833800002710000001d1a94a20000007a1200000000000093a800000000100000009000000016864acd74d257e871126d98118237d1e7a2f6fd236bb39fad90d7a46f1c4d02c46b33fdbfaedcb17313da6af6c12060aea2b0702c0959bb0759cb2a2ed11475a00", + expectedStaticFeeErr: ErrUnsupportedTx, + expectedComplexity: gas.Dimensions{ + gas.Bandwidth: 695, // The length of the tx in bytes + gas.DBRead: IntrinsicAddAutoRenewedValidatorTxComplexities[gas.DBRead] + intrinsicInputDBRead, + gas.DBWrite: IntrinsicAddAutoRenewedValidatorTxComplexities[gas.DBWrite] + intrinsicInputDBWrite + 2*intrinsicOutputDBWrite, + gas.Compute: intrinsicBLSPoPVerifyCompute + intrinsicSECP256k1FxSignatureCompute, + }, + expectedDynamicFee: 135_195 * units.NanoAvax, + }, + { + name: "SetAutoRenewedValidatorConfigTx", + tx: "00000000002900003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db0000000700238520c676e000000000000000000000000001000000018e6f924ff3cf5ea371b09db6ed75915c42be833800000001043c91e9d508169329034e2a68110427a311f945efc53ed3f3493d335b393fd100000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f26fc100000000000100000000000000003d0ad12b8ee8928edf248ca91ca55600fb383f07c32bff1d6dec472b25cf59a70000000a0000000100000000000b71b0000000000012750000000002000000090000000199bb3ab84a60ff05bfab5a14c01db45bcd129c406c141794c561247890e821cf0e70200fc7ea4b5a67a460e7946169d6cbf326fa35062c557295f5c319c22dc601000000090000000199bb3ab84a60ff05bfab5a14c01db45bcd129c406c141794c561247890e821cf0e70200fc7ea4b5a67a460e7946169d6cbf326fa35062c557295f5c319c22dc601", + expectedStaticFeeErr: ErrUnsupportedTx, + expectedComplexity: gas.Dimensions{ + gas.Bandwidth: 428, // The length of the tx in bytes + gas.DBRead: IntrinsicSetAutoRenewedValidatorConfigTxComplexities[gas.DBRead] + intrinsicInputDBRead, + gas.DBWrite: IntrinsicSetAutoRenewedValidatorConfigTxComplexities[gas.DBWrite] + intrinsicInputDBWrite + intrinsicOutputDBWrite, + gas.Compute: 2 * intrinsicSECP256k1FxSignatureCompute, + }, + expectedDynamicFee: 68_428 * units.NanoAvax, + }, + { + name: "RewardAutoRenewedValidatorTx", + tx: "00000000002a3d0ad12b8ee8928edf248ca91ca55600fb383f07c32bff1d6dec472b25cf59a7000000006683fe8000000000", + expectedStaticFeeErr: ErrUnsupportedTx, + expectedComplexityErr: ErrUnsupportedTx, + expectedDynamicFeeErr: ErrUnsupportedTx, + }, } ) diff --git a/vms/platformvm/txs/fee/complexity.go b/vms/platformvm/txs/fee/complexity.go index 9182ee6e4c19..ad5df5bf09b8 100644 --- a/vms/platformvm/txs/fee/complexity.go +++ b/vms/platformvm/txs/fee/complexity.go @@ -223,6 +223,32 @@ var ( gas.DBWrite: 6, // write remaining balance utxo + weight diff + deactivated weight diff + public key diff + delete staker + write staker } + IntrinsicAddAutoRenewedValidatorTxComplexities = gas.Dimensions{ + gas.Bandwidth: IntrinsicBaseTxComplexities[gas.Bandwidth] + + ids.NodeIDLen + // nodeID + wrappers.IntLen + // signer typeID + wrappers.IntLen + // num stake outs + wrappers.IntLen + // validator rewards typeID + wrappers.IntLen + // delegator rewards typeID + wrappers.IntLen + // owner typeID + wrappers.IntLen + // delegation shares + wrappers.LongLen + // weight + wrappers.IntLen + // auto compound reward shares + wrappers.LongLen, // period + gas.DBWrite: 3, // put staker + write weight diff + write pk diff + } + + IntrinsicSetAutoRenewedValidatorConfigTxComplexities = gas.Dimensions{ + gas.Bandwidth: IntrinsicBaseTxComplexities[gas.Bandwidth] + + ids.IDLen + // txID + wrappers.IntLen + // auth typeID + wrappers.IntLen + // authCredential typeID + wrappers.IntLen + // auto compound reward shares + wrappers.LongLen, // period + gas.DBRead: 1, // read tx + gas.DBWrite: 1, // update staker + } + errUnsupportedOutput = errors.New("unsupported output type") errUnsupportedInput = errors.New("unsupported input type") errUnsupportedOwner = errors.New("unsupported owner type") @@ -795,6 +821,62 @@ func (c *complexityVisitor) DisableL1ValidatorTx(tx *txs.DisableL1ValidatorTx) e return err } +func (c *complexityVisitor) AddAutoRenewedValidatorTx(tx *txs.AddAutoRenewedValidatorTx) 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.Stake()...) + if err != nil { + return err + } + validatorOwnerComplexity, err := OwnerComplexity(tx.ValidationRewardsOwner()) + if err != nil { + return err + } + delegatorOwnerComplexity, err := OwnerComplexity(tx.DelegationRewardsOwner()) + if err != nil { + return err + } + configOwnerComplexity, err := OwnerComplexity(tx.Owner) + if err != nil { + return err + } + c.output, err = IntrinsicAddAutoRenewedValidatorTxComplexities.Add( + &baseTxComplexity, + &signerComplexity, + &outputsComplexity, + &validatorOwnerComplexity, + &delegatorOwnerComplexity, + &configOwnerComplexity, + ) + return err +} + +func (c *complexityVisitor) SetAutoRenewedValidatorConfigTx(tx *txs.SetAutoRenewedValidatorConfigTx) error { + baseTxComplexity, err := baseTxComplexity(&tx.BaseTx) + if err != nil { + return err + } + authComplexity, err := AuthComplexity(tx.Auth) + if err != nil { + return err + } + c.output, err = IntrinsicSetAutoRenewedValidatorConfigTxComplexities.Add( + &baseTxComplexity, + &authComplexity, + ) + return err +} + +func (*complexityVisitor) RewardAutoRenewedValidatorTx(*txs.RewardAutoRenewedValidatorTx) error { + return ErrUnsupportedTx +} + func baseTxComplexity(tx *txs.BaseTx) (gas.Dimensions, error) { outputsComplexity, err := OutputComplexity(tx.Outs...) if err != nil { diff --git a/vms/platformvm/txs/reward_auto_renewed_validator_tx.go b/vms/platformvm/txs/reward_auto_renewed_validator_tx.go new file mode 100644 index 000000000000..22a3f9208268 --- /dev/null +++ b/vms/platformvm/txs/reward_auto_renewed_validator_tx.go @@ -0,0 +1,72 @@ +// Copyright (C) 2019, 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 = (*RewardAutoRenewedValidatorTx)(nil) + _ RewardTx = (*RewardAutoRenewedValidatorTx)(nil) + + errMissingTxID = errors.New("missing tx id") + errMissingTimestamp = errors.New("missing timestamp") +) + +// RewardAutoRenewedValidatorTx is a transaction that represents a proposal to +// reward/remove an auto-renewed validator that is currently validating from the validator set. +type RewardAutoRenewedValidatorTx struct { + // ID of the tx that created the validator being removed/rewarded + TxID ids.ID `serialize:"true" json:"txID"` + + // End time of the validation cycle + Timestamp uint64 `serialize:"true" json:"timestamp"` + + unsignedBytes []byte // Unsigned byte representation of this data +} + +func (tx *RewardAutoRenewedValidatorTx) StakerTxID() ids.ID { + return tx.TxID +} + +func (tx *RewardAutoRenewedValidatorTx) SetBytes(unsignedBytes []byte) { + tx.unsignedBytes = unsignedBytes +} + +func (*RewardAutoRenewedValidatorTx) InitCtx(*snow.Context) {} + +func (tx *RewardAutoRenewedValidatorTx) Bytes() []byte { + return tx.unsignedBytes +} + +func (*RewardAutoRenewedValidatorTx) InputIDs() set.Set[ids.ID] { + return nil +} + +func (*RewardAutoRenewedValidatorTx) Outputs() []*avax.TransferableOutput { + return nil +} + +func (tx *RewardAutoRenewedValidatorTx) 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 *RewardAutoRenewedValidatorTx) Visit(visitor Visitor) error { + return visitor.RewardAutoRenewedValidatorTx(tx) +} diff --git a/vms/platformvm/txs/reward_auto_renewed_validator_tx_test.go b/vms/platformvm/txs/reward_auto_renewed_validator_tx_test.go new file mode 100644 index 000000000000..a16d1f645f8e --- /dev/null +++ b/vms/platformvm/txs/reward_auto_renewed_validator_tx_test.go @@ -0,0 +1,76 @@ +// Copyright (C) 2019, 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 TestRewardAutoRenewedValidatorTxSyntacticVerify(t *testing.T) { + type test struct { + name string + txFunc func(*gomock.Controller) *RewardAutoRenewedValidatorTx + err error + } + + ctx := &snow.Context{ + ChainID: ids.GenerateTestID(), + NetworkID: uint32(1337), + } + + tests := []test{ + { + name: "nil tx", + txFunc: func(*gomock.Controller) *RewardAutoRenewedValidatorTx { + return nil + }, + err: ErrNilTx, + }, + { + name: "missing timestamp", + txFunc: func(*gomock.Controller) *RewardAutoRenewedValidatorTx { + return &RewardAutoRenewedValidatorTx{ + TxID: ids.GenerateTestID(), + Timestamp: 0, + } + }, + err: errMissingTimestamp, + }, + { + name: "missing tx id", + txFunc: func(*gomock.Controller) *RewardAutoRenewedValidatorTx { + return &RewardAutoRenewedValidatorTx{ + Timestamp: 1, + } + }, + err: errMissingTxID, + }, + { + name: "valid auto-renewed validator", + txFunc: func(*gomock.Controller) *RewardAutoRenewedValidatorTx { + return &RewardAutoRenewedValidatorTx{ + 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(t, err, tt.err) + }) + } +} diff --git a/vms/platformvm/txs/reward_tx.go b/vms/platformvm/txs/reward_tx.go new file mode 100644 index 000000000000..e4514b9cc8a6 --- /dev/null +++ b/vms/platformvm/txs/reward_tx.go @@ -0,0 +1,12 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import "github.com/ava-labs/avalanchego/ids" + +// RewardTx is implemented by transactions that reward a staker +// (RewardValidatorTx and RewardAutoRenewedValidatorTx). +type RewardTx interface { + StakerTxID() ids.ID +} diff --git a/vms/platformvm/txs/reward_validator_tx.go b/vms/platformvm/txs/reward_validator_tx.go index 9f9d684f9ea1..e5d392834042 100644 --- a/vms/platformvm/txs/reward_validator_tx.go +++ b/vms/platformvm/txs/reward_validator_tx.go @@ -10,7 +10,10 @@ import ( "github.com/ava-labs/avalanchego/vms/components/avax" ) -var _ UnsignedTx = (*RewardValidatorTx)(nil) +var ( + _ UnsignedTx = (*RewardValidatorTx)(nil) + _ RewardTx = (*RewardValidatorTx)(nil) +) // RewardValidatorTx is a transaction that represents a proposal to // remove a validator that is currently validating from the validator set. @@ -29,6 +32,10 @@ type RewardValidatorTx struct { unsignedBytes []byte // Unsigned byte representation of this data } +func (tx *RewardValidatorTx) StakerTxID() ids.ID { + return tx.TxID +} + func (tx *RewardValidatorTx) SetBytes(unsignedBytes []byte) { tx.unsignedBytes = unsignedBytes } diff --git a/vms/platformvm/txs/set_auto_renewed_validator_config_tx.go b/vms/platformvm/txs/set_auto_renewed_validator_config_tx.go new file mode 100644 index 000000000000..fe29f7eba934 --- /dev/null +++ b/vms/platformvm/txs/set_auto_renewed_validator_config_tx.go @@ -0,0 +1,62 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/vms/components/verify" + "github.com/ava-labs/avalanchego/vms/platformvm/reward" +) + +type SetAutoRenewedValidatorConfigTx struct { + // Metadata, inputs and outputs + BaseTx `serialize:"true"` + + // ID of the tx that created the auto-renewed validator. + TxID ids.ID `serialize:"true" json:"txID"` + + // Authorizes this validator to be updated. + Auth verify.Verifiable `serialize:"true" json:"auth"` + + // Percentage of rewards to restake at the end of each cycle, expressed in millionths (percentage * 10,000). + // Range [0..1_000_000]: + // 0 = restake principal only; withdraw 100% of rewards + // 300_000 = restake 30% of rewards; withdraw 70% + // 1_000_000 = restake 100% of rewards; withdraw 0% + AutoCompoundRewardShares uint32 `serialize:"true" json:"autoCompoundRewardShares"` + + // Period for the next cycle (in seconds). Takes effect at cycle end. + // If 0, stop at the end of the current cycle and unlock funds. + Period uint64 `serialize:"true" json:"period"` +} + +func (tx *SetAutoRenewedValidatorConfigTx) 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 tx.AutoCompoundRewardShares > reward.PercentDenominator: + return errTooManyAutoCompoundRewardShares + } + + if err := tx.BaseTx.SyntacticVerify(ctx); err != nil { + return err + } + + if err := tx.Auth.Verify(); err != nil { + return err + } + + tx.SyntacticallyVerified = true + return nil +} + +func (tx *SetAutoRenewedValidatorConfigTx) Visit(visitor Visitor) error { + return visitor.SetAutoRenewedValidatorConfigTx(tx) +} diff --git a/vms/platformvm/txs/set_auto_renewed_validator_config_tx_test.go b/vms/platformvm/txs/set_auto_renewed_validator_config_tx_test.go new file mode 100644 index 000000000000..57ab02464a2b --- /dev/null +++ b/vms/platformvm/txs/set_auto_renewed_validator_config_tx_test.go @@ -0,0 +1,154 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "errors" + "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/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/components/verify/verifymock" + "github.com/ava-labs/avalanchego/vms/platformvm/reward" +) + +var errInvalidAuth = errors.New("invalid auth") + +func TestSetAutoRenewedValidatorConfigTxSyntacticVerify(t *testing.T) { + type test struct { + name string + txFunc func(*gomock.Controller) *SetAutoRenewedValidatorConfigTx + 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, + }, + } + + // A BaseTx that fails syntactic verification. + invalidBaseTx := BaseTx{} + + tests := []test{ + { + name: "nil tx", + txFunc: func(*gomock.Controller) *SetAutoRenewedValidatorConfigTx { + return nil + }, + err: ErrNilTx, + }, + { + name: "already verified", + txFunc: func(*gomock.Controller) *SetAutoRenewedValidatorConfigTx { + return &SetAutoRenewedValidatorConfigTx{ + BaseTx: verifiedBaseTx, + } + }, + err: nil, + }, + { + name: "empty txID", + txFunc: func(*gomock.Controller) *SetAutoRenewedValidatorConfigTx { + return &SetAutoRenewedValidatorConfigTx{ + BaseTx: validBaseTx, + } + }, + err: errMissingTxID, + }, + { + name: "too many restake shares", + txFunc: func(*gomock.Controller) *SetAutoRenewedValidatorConfigTx { + autoRestakeShares := uint32(2_000_000) + + return &SetAutoRenewedValidatorConfigTx{ + BaseTx: validBaseTx, + TxID: ids.GenerateTestID(), + AutoCompoundRewardShares: autoRestakeShares, + } + }, + err: errTooManyAutoCompoundRewardShares, + }, + { + name: "invalid auth", + txFunc: func(ctrl *gomock.Controller) *SetAutoRenewedValidatorConfigTx { + invalidAuth := verifymock.NewVerifiable(ctrl) + invalidAuth.EXPECT().Verify().Return(errInvalidAuth) + + return &SetAutoRenewedValidatorConfigTx{ + BaseTx: validBaseTx, + TxID: ids.GenerateTestID(), + Auth: invalidAuth, + AutoCompoundRewardShares: reward.PercentDenominator, + Period: 0, + } + }, + err: errInvalidAuth, + }, + { + name: "invalid BaseTx", + txFunc: func(*gomock.Controller) *SetAutoRenewedValidatorConfigTx { + autoRestakeShares := uint32(500_000) + + return &SetAutoRenewedValidatorConfigTx{ + BaseTx: invalidBaseTx, + TxID: ids.GenerateTestID(), + AutoCompoundRewardShares: autoRestakeShares, + } + }, + err: avax.ErrWrongNetworkID, + }, + { + name: "valid tx", + txFunc: func(ctrl *gomock.Controller) *SetAutoRenewedValidatorConfigTx { + validAuth := verifymock.NewVerifiable(ctrl) + validAuth.EXPECT().Verify().Return(nil) + + return &SetAutoRenewedValidatorConfigTx{ + BaseTx: validBaseTx, + TxID: ids.GenerateTestID(), + Auth: validAuth, + AutoCompoundRewardShares: reward.PercentDenominator, + Period: 0, + } + }, + 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(t, err, tt.err) + + if tx != nil { + require.Equal(t, tt.err == nil, tx.SyntacticallyVerified) + } + }) + } +} diff --git a/vms/platformvm/txs/staker_tx.go b/vms/platformvm/txs/staker_tx.go index 90f82124bdc0..9c8ac15fe31e 100644 --- a/vms/platformvm/txs/staker_tx.go +++ b/vms/platformvm/txs/staker_tx.go @@ -32,23 +32,22 @@ type DelegatorTx interface { type StakerTx interface { UnsignedTx - Staker + BaseStaker } type PermissionlessStaker interface { - Staker + BaseStaker Outputs() []*avax.TransferableOutput Stake() []*avax.TransferableOutput } -type Staker interface { +type BaseStaker interface { SubnetID() ids.ID NodeID() ids.NodeID // 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 } @@ -58,3 +57,8 @@ type ScheduledStaker interface { StartTime() time.Time PendingPriority() Priority } + +type Staker interface { + BaseStaker + EndTime() time.Time +} diff --git a/vms/platformvm/txs/visitor.go b/vms/platformvm/txs/visitor.go index 8f4caba8eede..7d146331730e 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 + + // Helicon Transactions: + AddAutoRenewedValidatorTx(*AddAutoRenewedValidatorTx) error + SetAutoRenewedValidatorConfigTx(*SetAutoRenewedValidatorConfigTx) error + RewardAutoRenewedValidatorTx(*RewardAutoRenewedValidatorTx) error } diff --git a/vms/platformvm/utxo/verifier.go b/vms/platformvm/utxo/verifier.go index 7da3c53c1c47..30908401a8fe 100644 --- a/vms/platformvm/utxo/verifier.go +++ b/vms/platformvm/utxo/verifier.go @@ -512,6 +512,23 @@ func (i *inputOutputGetter) DisableL1ValidatorTx(tx *txs.DisableL1ValidatorTx) e return nil } +func (i *inputOutputGetter) AddAutoRenewedValidatorTx(tx *txs.AddAutoRenewedValidatorTx) error { + i.getUTXOs(tx.BaseTx) + i.OutputUTXOs = append(i.OutputUTXOs, tx.StakeOuts...) + + return nil +} + +func (i *inputOutputGetter) SetAutoRenewedValidatorConfigTx(tx *txs.SetAutoRenewedValidatorConfigTx) error { + i.getUTXOs(tx.BaseTx) + + return nil +} + +func (*inputOutputGetter) RewardAutoRenewedValidatorTx(*txs.RewardAutoRenewedValidatorTx) error { + return fmt.Errorf("%w: RewardAutoRenewedValidatorTx", ErrUnsupportedTxType) +} + func (i *inputOutputGetter) getUTXOs(tx txs.BaseTx) { i.InputUTXOs = append(i.InputUTXOs, tx.Ins...) i.OutputUTXOs = append(i.OutputUTXOs, tx.Outs...) diff --git a/vms/platformvm/validators/BUILD.bazel b/vms/platformvm/validators/BUILD.bazel index d777460b0c54..787b0f44edc7 100644 --- a/vms/platformvm/validators/BUILD.bazel +++ b/vms/platformvm/validators/BUILD.bazel @@ -40,13 +40,16 @@ go_test( "//utils/logging", "//utils/timer/mockable", "//utils/units", + "//vms/components/avax", "//vms/platformvm/block", "//vms/platformvm/config", "//vms/platformvm/genesis/genesistest", "//vms/platformvm/metrics", "//vms/platformvm/state", "//vms/platformvm/state/statetest", + "//vms/platformvm/status", "//vms/platformvm/txs", + "//vms/secp256k1fx", "@com_github_prometheus_client_golang//prometheus", "@com_github_stretchr_testify//require", ], diff --git a/vms/platformvm/validators/manager_test.go b/vms/platformvm/validators/manager_test.go index f339dfc82559..30ac5f2459d1 100644 --- a/vms/platformvm/validators/manager_test.go +++ b/vms/platformvm/validators/manager_test.go @@ -18,12 +18,16 @@ import ( "github.com/ava-labs/avalanchego/utils/crypto/bls" "github.com/ava-labs/avalanchego/utils/crypto/bls/signer/localsigner" "github.com/ava-labs/avalanchego/utils/timer/mockable" + "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/platformvm/block" "github.com/ava-labs/avalanchego/vms/platformvm/config" "github.com/ava-labs/avalanchego/vms/platformvm/genesis/genesistest" "github.com/ava-labs/avalanchego/vms/platformvm/metrics" "github.com/ava-labs/avalanchego/vms/platformvm/state" "github.com/ava-labs/avalanchego/vms/platformvm/state/statetest" + "github.com/ava-labs/avalanchego/vms/platformvm/status" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" . "github.com/ava-labs/avalanchego/vms/platformvm/validators" ) @@ -72,6 +76,12 @@ func TestGetValidatorSet_AfterEtna(t *testing.T) { } ) + primaryStakerTx := &txs.Tx{TxID: primaryStaker.TxID, Unsigned: &txs.AddValidatorTx{}} + subnetStakerTx := &txs.Tx{TxID: subnetStaker.TxID, Unsigned: &txs.AddSubnetValidatorTx{}} + + s.AddTx(primaryStakerTx, status.Committed) + s.AddTx(subnetStakerTx, status.Committed) + // Add a subnet staker during the Etna upgrade { blk, err := block.NewBanffStandardBlock(upgradeTime, s.GetLastAccepted(), 1, nil) @@ -151,27 +161,65 @@ func TestGetWarpValidatorSets(t *testing.T) { pk0Bytes, pk1Bytes = pk1Bytes, pk0Bytes } + primaryStaker0Tx, err := txs.NewSigned( + &txs.AddValidatorTx{ + Validator: txs.Validator{}, + StakeOuts: []*avax.TransferableOutput{}, + RewardsOwner: &secp256k1fx.OutputOwners{}, + }, + txs.Codec, + nil, + ) + require.NoError(err) + subnetStaker0Tx, err := txs.NewSigned(&txs.AddSubnetValidatorTx{ + SubnetAuth: &secp256k1fx.Input{}, + }, txs.Codec, nil) + require.NoError(err) + primaryStaker1Tx, err := txs.NewSigned( + &txs.AddValidatorTx{ + Validator: txs.Validator{}, + StakeOuts: []*avax.TransferableOutput{}, + RewardsOwner: &secp256k1fx.OutputOwners{}, + }, + txs.Codec, + nil, + ) + require.NoError(err) + subnetStaker1Tx, err := txs.NewSigned(&txs.AddSubnetValidatorTx{ + SubnetAuth: &secp256k1fx.Input{}, + }, txs.Codec, nil) + require.NoError(err) + + s.AddTx(primaryStaker0Tx, status.Committed) + s.AddTx(subnetStaker0Tx, status.Committed) + s.AddTx(primaryStaker1Tx, status.Committed) + s.AddTx(subnetStaker1Tx, status.Committed) + var ( subnetID = ids.GenerateTestID() primaryStaker0 = &state.Staker{ + TxID: primaryStaker0Tx.TxID, NodeID: ids.GenerateTestNodeID(), PublicKey: pk0, SubnetID: constants.PrimaryNetworkID, Weight: 1, } subnetStaker0 = &state.Staker{ + TxID: subnetStaker0Tx.TxID, NodeID: primaryStaker0.NodeID, PublicKey: nil, // inherited from primaryStaker SubnetID: subnetID, Weight: 1, } primaryStaker1 = &state.Staker{ + TxID: primaryStaker1Tx.TxID, NodeID: ids.GenerateTestNodeID(), PublicKey: pk1, SubnetID: constants.PrimaryNetworkID, Weight: 1, } subnetStaker1 = &state.Staker{ + TxID: subnetStaker1Tx.TxID, NodeID: primaryStaker1.NodeID, PublicKey: nil, // inherited from primaryStaker1 SubnetID: subnetID, diff --git a/wallet/chain/p/builder/builder.go b/wallet/chain/p/builder/builder.go index b35c0f2388cd..ec252e7969cc 100644 --- a/wallet/chain/p/builder/builder.go +++ b/wallet/chain/p/builder/builder.go @@ -319,6 +319,54 @@ type Builder interface { rewardsOwner *secp256k1fx.OutputOwners, options ...common.Option, ) (*txs.AddPermissionlessDelegatorTx, error) + + // NewAddAutoRenewedValidatorTx creates an auto-renewed validator on the + // primary network. Auto-renewed validators automatically renew at the end + // of each validation period without requiring a new transaction. + // + // - validatorNodeID is the node ID of the validator. + // - weight is the amount of AVAX to stake. + // - signer is the BLS key for this validator. + // - assetID specifies the asset to stake. + // - validationRewardsOwner specifies the owner of all the validation + // rewards this validator earns. + // - delegationRewardsOwner specifies the owner of all the rewards this + // validator earns from delegations. + // - configOwner specifies the owner authorized to modify the validator's + // auto-renewal configuration. + // - delegationShares specifies the fraction (out of 1,000,000) that this + // validator will take from delegation rewards. + // - autoCompoundRewardShares specifies the fraction (out of 1,000,000) of + // rewards to automatically restake at the end of each period. + // - period is the duration of each validation cycle. + NewAddAutoRenewedValidatorTx( + validatorNodeID ids.NodeID, + weight uint64, + signer signer.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + configOwner *secp256k1fx.OutputOwners, + delegationShares uint32, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, + ) (*txs.AddAutoRenewedValidatorTx, error) + + // NewSetAutoRenewedValidatorConfigTx creates a transaction to modify the + // configuration of an existing auto-renewed validator. + // + // - txID is the transaction ID of the auto-renewed validator to modify. + // - autoRestakeShares specifies the new fraction (out of 1,000,000) of + // rewards to automatically restake. + // - period is the new duration of each validation cycle. Set to 0 to + // trigger a graceful exit at the end of the current period. + NewSetAutoRenewedValidatorConfigTx( + txID ids.ID, + autoRestakeShares uint32, + period time.Duration, + options ...common.Option, + ) (*txs.SetAutoRenewedValidatorConfigTx, error) } type Backend interface { @@ -1493,6 +1541,156 @@ func (b *builder) NewAddPermissionlessDelegatorTx( return tx, b.initCtx(tx) } +func (b *builder) NewAddAutoRenewedValidatorTx( + validatorNodeID ids.NodeID, + weight uint64, + signer signer.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + configOwner *secp256k1fx.OutputOwners, + delegationShares uint32, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, +) (*txs.AddAutoRenewedValidatorTx, error) { + toBurn := map[ids.ID]uint64{} + toStake := map[ids.ID]uint64{ + assetID: weight, + } + + 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 + } + configOwnerComplexity, err := fee.OwnerComplexity(configOwner) + if err != nil { + return nil, err + } + + complexity, err := fee.IntrinsicAddAutoRenewedValidatorTxComplexities.Add( + &memoComplexity, + &signerComplexity, + &validatorOwnerComplexity, + &delegatorOwnerComplexity, + &configOwnerComplexity, + ) + 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) + utils.Sort(configOwner.Addrs) + tx := &txs.AddAutoRenewedValidatorTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: b.context.NetworkID, + BlockchainID: constants.PlatformChainID, + Ins: inputs, + Outs: baseOutputs, + Memo: memo, + }}, + ValidatorNodeID: validatorNodeID, + Signer: signer, + StakeOuts: stakeOutputs, + ValidatorRewardsOwner: validationRewardsOwner, + DelegatorRewardsOwner: delegationRewardsOwner, + Owner: configOwner, + DelegationShares: delegationShares, + Wght: weight, + AutoCompoundRewardShares: autoCompoundRewardShares, + Period: uint64(period / time.Second), + } + + return tx, b.initCtx(tx) +} + +func (b *builder) NewSetAutoRenewedValidatorConfigTx( + txID ids.ID, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, +) (*txs.SetAutoRenewedValidatorConfigTx, error) { + toBurn := map[ids.ID]uint64{} + toStake := map[ids.ID]uint64{} + ops := common.NewOptions(options) + + auth, err := b.authorize(txID, ops) + if err != nil { + return nil, err + } + + authComplexity, err := fee.AuthComplexity(auth) + if err != nil { + return nil, err + } + + memo := ops.Memo() + memoComplexity := gas.Dimensions{ + gas.Bandwidth: uint64(len(memo)), + } + + complexity, err := fee.IntrinsicSetAutoRenewedValidatorConfigTxComplexities.Add( + &memoComplexity, + &authComplexity, + ) + 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.SetAutoRenewedValidatorConfigTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: b.context.NetworkID, + BlockchainID: constants.PlatformChainID, + Ins: inputs, + Outs: outputs, + Memo: memo, + }}, + TxID: txID, + Auth: auth, + AutoCompoundRewardShares: autoCompoundRewardShares, + Period: uint64(period / time.Second), + } + 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 b703aed95064..2050651b2622 100644 --- a/wallet/chain/p/builder/with_options.go +++ b/wallet/chain/p/builder/with_options.go @@ -310,3 +310,45 @@ func (w *withOptions) NewAddPermissionlessDelegatorTx( common.UnionOptions(w.options, options)..., ) } + +func (w *withOptions) NewAddAutoRenewedValidatorTx( + validatorNodeID ids.NodeID, + weight uint64, + signer signer.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + configOwner *secp256k1fx.OutputOwners, + delegationShares uint32, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, +) (*txs.AddAutoRenewedValidatorTx, error) { + return w.builder.NewAddAutoRenewedValidatorTx( + validatorNodeID, + weight, + signer, + assetID, + validationRewardsOwner, + delegationRewardsOwner, + configOwner, + delegationShares, + autoCompoundRewardShares, + period, + common.UnionOptions(w.options, options)..., + ) +} + +func (w *withOptions) NewSetAutoRenewedValidatorConfigTx( + txID ids.ID, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, +) (*txs.SetAutoRenewedValidatorConfigTx, error) { + return w.builder.NewSetAutoRenewedValidatorConfigTx( + txID, + autoCompoundRewardShares, + period, + common.UnionOptions(w.options, options)..., + ) +} diff --git a/wallet/chain/p/builder_test.go b/wallet/chain/p/builder_test.go index 1998d9df3d07..e3f29473b0df 100644 --- a/wallet/chain/p/builder_test.go +++ b/wallet/chain/p/builder_test.go @@ -905,6 +905,124 @@ func TestDisableL1ValidatorTx(t *testing.T) { } } +func TestAddAutoRenewedValidatorTx(t *testing.T) { + var utxosOffset uint64 = 2024 + makeUTXO := func(amount uint64) *avax.UTXO { + utxosOffset++ + return &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: ids.Empty.Prefix(utxosOffset), + OutputIndex: uint32(utxosOffset), + }, + Asset: avax.Asset{ID: avaxAssetID}, + Out: &secp256k1fx.TransferOutput{ + Amt: amount, + OutputOwners: utxoOwner, + }, + } + } + + var ( + utxos = []*avax.UTXO{ + makeUTXO(1 * units.NanoAvax), // small UTXO + makeUTXO(9 * units.Avax), // large UTXO + } + + require = require.New(t) + validationRewardsOwner = rewardsOwner + delegationRewardsOwner = rewardsOwner + configOwner = rewardsOwner + delegationShares uint32 = reward.PercentDenominator + autoCompoundShares uint32 = 500_000 + weight = 2 * units.Avax + period = 7 * 24 * time.Hour + + chainUTXOs = utxotest.NewDeterministicChainUTXOs(t, map[ids.ID][]*avax.UTXO{ + constants.PlatformChainID: utxos, + }) + backend = wallet.NewBackend(chainUTXOs, nil) + builder = builder.New(set.Of(utxoAddr, rewardAddr), testContextPostEtna, backend) + ) + + sk, err := localsigner.New() + require.NoError(err) + + pop, err := signer.NewProofOfPossession(sk) + require.NoError(err) + + utx, err := builder.NewAddAutoRenewedValidatorTx( + nodeID, + weight, + pop, + avaxAssetID, + validationRewardsOwner, + delegationRewardsOwner, + configOwner, + delegationShares, + autoCompoundShares, + period, + ) + require.NoError(err) + require.Equal(nodeID, utx.ValidatorNodeID) + require.Equal(pop, utx.Signer) + require.Equal(weight, utx.Wght) + require.Len(utx.StakeOuts, 1) + require.Equal( + map[ids.ID]uint64{ + avaxAssetID: weight, + }, + addOutputAmounts(utx.StakeOuts), + ) + require.Equal(validationRewardsOwner, utx.ValidatorRewardsOwner) + require.Equal(delegationRewardsOwner, utx.DelegatorRewardsOwner) + require.Equal(configOwner, utx.Owner) + require.Equal(delegationShares, utx.DelegationShares) + require.Equal(autoCompoundShares, utx.AutoCompoundRewardShares) + require.Equal(uint64(period/time.Second), utx.Period) + requireFeeIsCorrect( + require, + dynamicFeeCalculator, + utx, + &utx.BaseTx.BaseTx, + nil, + utx.StakeOuts, + nil, + ) +} + +func TestSetAutoRenewedValidatorConfigTx(t *testing.T) { + var ( + require = require.New(t) + autoCompoundShares uint32 = 750_000 + period = 14 * 24 * time.Hour + + chainUTXOs = utxotest.NewDeterministicChainUTXOs(t, map[ids.ID][]*avax.UTXO{ + constants.PlatformChainID: utxos, + }) + backend = wallet.NewBackend(chainUTXOs, validationOwners) + builder = builder.New(set.Of(utxoAddr, validationAuthAddr), testContextPostEtna, backend) + ) + + utx, err := builder.NewSetAutoRenewedValidatorConfigTx( + validationID, + autoCompoundShares, + period, + ) + require.NoError(err) + require.Equal(validationID, utx.TxID) + require.Equal(autoCompoundShares, utx.AutoCompoundRewardShares) + require.Equal(uint64(period/time.Second), utx.Period) + requireFeeIsCorrect( + require, + dynamicFeeCalculator, + utx, + &utx.BaseTx.BaseTx, + nil, + nil, + nil, + ) +} + func makeTestUTXOs(utxosKey *secp256k1.PrivateKey) []*avax.UTXO { // Note: we avoid ids.GenerateTestNodeID here to make sure that UTXO IDs // won't change run by run. This simplifies checking what utxos are included diff --git a/wallet/chain/p/signer/visitor.go b/wallet/chain/p/signer/visitor.go index 1ec4c5c0ded5..7d5ff91667d3 100644 --- a/wallet/chain/p/signer/visitor.go +++ b/wallet/chain/p/signer/visitor.go @@ -234,6 +234,31 @@ func (s *visitor) DisableL1ValidatorTx(tx *txs.DisableL1ValidatorTx) error { return sign(s.tx, txSigners) } +func (s *visitor) AddAutoRenewedValidatorTx(tx *txs.AddAutoRenewedValidatorTx) error { + txSigners, err := s.getSigners(constants.PlatformChainID, tx.Ins) + if err != nil { + return err + } + return sign(s.tx, txSigners) +} + +func (s *visitor) SetAutoRenewedValidatorConfigTx(tx *txs.SetAutoRenewedValidatorConfigTx) error { + txSigners, err := s.getSigners(constants.PlatformChainID, tx.Ins) + if err != nil { + return err + } + authSigners, err := s.getAuthSigners(tx.TxID, tx.Auth) + if err != nil { + return err + } + txSigners = append(txSigners, authSigners) + return sign(s.tx, txSigners) +} + +func (*visitor) RewardAutoRenewedValidatorTx(*txs.RewardAutoRenewedValidatorTx) error { + return ErrUnsupportedTxType +} + 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.go b/wallet/chain/p/wallet/backend.go index b85d25124219..18d1da9f56cf 100644 --- a/wallet/chain/p/wallet/backend.go +++ b/wallet/chain/p/wallet/backend.go @@ -33,7 +33,7 @@ type backend struct { common.ChainUTXOs ownersLock sync.RWMutex - owners map[ids.ID]fx.Owner // subnetID or validationID -> owner + owners map[ids.ID]fx.Owner } func NewBackend(utxos common.ChainUTXOs, owners map[ids.ID]fx.Owner) Backend { diff --git a/wallet/chain/p/wallet/backend_visitor.go b/wallet/chain/p/wallet/backend_visitor.go index 160e4b569b83..f6ca25a6913e 100644 --- a/wallet/chain/p/wallet/backend_visitor.go +++ b/wallet/chain/p/wallet/backend_visitor.go @@ -172,6 +172,22 @@ func (b *backendVisitor) DisableL1ValidatorTx(tx *txs.DisableL1ValidatorTx) erro return b.baseTx(&tx.BaseTx) } +func (b *backendVisitor) AddAutoRenewedValidatorTx(tx *txs.AddAutoRenewedValidatorTx) error { + b.b.setOwner( + b.txID, + tx.Owner, + ) + return b.baseTx(&tx.BaseTx) +} + +func (b *backendVisitor) SetAutoRenewedValidatorConfigTx(tx *txs.SetAutoRenewedValidatorConfigTx) error { + return b.baseTx(&tx.BaseTx) +} + +func (*backendVisitor) RewardAutoRenewedValidatorTx(*txs.RewardAutoRenewedValidatorTx) error { + return ErrUnsupportedTxType +} + 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 316d3f1d8db4..98d4922715de 100644 --- a/wallet/chain/p/wallet/wallet.go +++ b/wallet/chain/p/wallet/wallet.go @@ -308,6 +308,57 @@ type Wallet interface { options ...common.Option, ) (*txs.Tx, error) + // IssueAddAutoRenewedValidatorTx creates, signs, and issues a new + // auto-renewed validator on the primary network. Auto-renewed validators + // automatically renew at the end of each validation period without + // requiring a new transaction. + // + // - [validatorNodeID] is the node ID of the validator. + // - [weight] is the amount of nAVAX to stake. + // - [signer] is the BLS key for this validator. + // - [assetID] specifies the asset to stake. + // - [validationRewardsOwner] specifies the owner of all the validation + // rewards this validator earns. + // - [delegationRewardsOwner] specifies the owner of all the rewards this + // validator earns from delegations. + // - [configOwner] specifies the owner authorized to modify the validator's + // auto-renewal configuration. + // - [delegationShares] specifies the fraction (out of 1,000,000) that this + // validator will take from delegation rewards. + // - [autoCompoundRewardShares] specifies the fraction (out of 1,000,000) + // of rewards to automatically restake at the end of each period. + // - [period] is the duration of each validation cycle. + IssueAddAutoRenewedValidatorTx( + validatorNodeID ids.NodeID, + weight uint64, + signer vmsigner.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + configOwner *secp256k1fx.OutputOwners, + delegationShares uint32, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, + ) (*txs.Tx, error) + + // IssueSetAutoRenewedValidatorConfigTx creates, signs, and issues a + // transaction to modify the configuration of an existing auto-renewed + // validator. + // + // - [txID] is the transaction ID of the AddAutoRenewedValidatorTx that + // created the validator to modify. + // - [autoCompoundRewardShares] specifies the new fraction (out of + // 1,000,000) of rewards to automatically restake. + // - [period] is the new duration of each validation cycle. Set to 0 to + // stop the validator at the end of the current cycle and unlock funds. + IssueSetAutoRenewedValidatorConfigTx( + txID ids.ID, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, + ) (*txs.Tx, error) + // IssueUnsignedTx signs and issues the unsigned tx. IssueUnsignedTx( utx txs.UnsignedTx, @@ -605,6 +656,56 @@ func (w *wallet) IssueAddPermissionlessDelegatorTx( return w.IssueUnsignedTx(utx, options...) } +func (w *wallet) IssueAddAutoRenewedValidatorTx( + validatorNodeID ids.NodeID, + weight uint64, + signer vmsigner.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + configOwner *secp256k1fx.OutputOwners, + delegationShares uint32, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, +) (*txs.Tx, error) { + utx, err := w.builder.NewAddAutoRenewedValidatorTx( + validatorNodeID, + weight, + signer, + assetID, + validationRewardsOwner, + delegationRewardsOwner, + configOwner, + delegationShares, + autoCompoundRewardShares, + period, + options..., + ) + if err != nil { + return nil, err + } + return w.IssueUnsignedTx(utx, options...) +} + +func (w *wallet) IssueSetAutoRenewedValidatorConfigTx( + txID ids.ID, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, +) (*txs.Tx, error) { + utx, err := w.builder.NewSetAutoRenewedValidatorConfigTx( + txID, + autoCompoundRewardShares, + period, + 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 d23a1de9eba5..49a12c57bc7f 100644 --- a/wallet/chain/p/wallet/with_options.go +++ b/wallet/chain/p/wallet/with_options.go @@ -300,6 +300,48 @@ func (w *withOptions) IssueAddPermissionlessDelegatorTx( ) } +func (w *withOptions) IssueAddAutoRenewedValidatorTx( + validatorNodeID ids.NodeID, + weight uint64, + signer vmsigner.Signer, + assetID ids.ID, + validationRewardsOwner *secp256k1fx.OutputOwners, + delegationRewardsOwner *secp256k1fx.OutputOwners, + configOwner *secp256k1fx.OutputOwners, + delegationShares uint32, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, +) (*txs.Tx, error) { + return w.wallet.IssueAddAutoRenewedValidatorTx( + validatorNodeID, + weight, + signer, + assetID, + validationRewardsOwner, + delegationRewardsOwner, + configOwner, + delegationShares, + autoCompoundRewardShares, + period, + common.UnionOptions(w.options, options)..., + ) +} + +func (w *withOptions) IssueSetAutoRenewedValidatorConfigTx( + txID ids.ID, + autoCompoundRewardShares uint32, + period time.Duration, + options ...common.Option, +) (*txs.Tx, error) { + return w.wallet.IssueSetAutoRenewedValidatorConfigTx( + txID, + autoCompoundRewardShares, + period, + common.UnionOptions(w.options, options)..., + ) +} + func (w *withOptions) IssueUnsignedTx( utx txs.UnsignedTx, options ...common.Option, diff --git a/wallet/subnet/primary/wallet.go b/wallet/subnet/primary/wallet.go index 160dcf9d9d79..f3ea6b22a9ce 100644 --- a/wallet/subnet/primary/wallet.go +++ b/wallet/subnet/primary/wallet.go @@ -66,6 +66,9 @@ type WalletConfig struct { // Validation IDs that the wallet should know about to be able to generate // transactions. ValidationIDs []ids.ID // optional + // Auto-renewed validator tx IDs that the wallet should know about to + // be able to generate SetAutoRenewedValidatorConfigTx transactions. + AutoRenewedValidatorTxIDs []ids.ID // optional } // MakeWallet returns a wallet that supports issuing transactions to the chains @@ -97,7 +100,7 @@ func MakeWallet( return nil, err } - owners, err := platformvm.GetOwners(avaxState.PClient, ctx, config.SubnetIDs, config.ValidationIDs) + owners, err := platformvm.GetOwners(avaxState.PClient, ctx, config.SubnetIDs, config.ValidationIDs, config.AutoRenewedValidatorTxIDs) if err != nil { return nil, err } @@ -148,7 +151,7 @@ func MakePWallet( return nil, err } - owners, err := platformvm.GetOwners(client, ctx, config.SubnetIDs, config.ValidationIDs) + owners, err := platformvm.GetOwners(client, ctx, config.SubnetIDs, config.ValidationIDs, config.AutoRenewedValidatorTxIDs) if err != nil { return nil, err }