diff --git a/tests/e2e/p/l1.go b/tests/e2e/p/l1.go index 73c1e08eed1e..96156ddff36b 100644 --- a/tests/e2e/p/l1.go +++ b/tests/e2e/p/l1.go @@ -50,6 +50,7 @@ const ( genesisWeight = units.Schmeckle genesisBalance = units.Avax registerWeight = genesisWeight / 10 + updatedWeight = 2 * registerWeight registerBalance = 0 // Validator registration attempts expire 5 minutes after they are created @@ -300,6 +301,7 @@ var _ = e2e.DescribePChain("[L1]", func() { registerWeight, ) require.NoError(err) + registerValidationID := registerSubnetValidatorMessage.ValidationID() tc.By("registering the validator", func() { tc.By("creating the unsigned warp message") @@ -365,6 +367,97 @@ var _ = e2e.DescribePChain("[L1]", func() { }) }) + var nextNonce uint64 + setWeight := func(validationID ids.ID, weight uint64) { + tc.By("creating the unsigned SubnetValidatorWeightMessage") + unsignedSubnetValidatorWeight := must[*warp.UnsignedMessage](tc)(warp.NewUnsignedMessage( + networkID, + chainID, + must[*payload.AddressedCall](tc)(payload.NewAddressedCall( + address, + must[*warpmessage.SubnetValidatorWeight](tc)(warpmessage.NewSubnetValidatorWeight( + validationID, + nextNonce, + weight, + )).Bytes(), + )).Bytes(), + )) + + tc.By("sending the request to sign the warp message", func() { + setSubnetValidatorWeightRequest, err := wrapWarpSignatureRequest( + unsignedSubnetValidatorWeight, + nil, + ) + require.NoError(err) + + require.True(genesisPeer.Send(tc.DefaultContext(), setSubnetValidatorWeightRequest)) + }) + + tc.By("getting the signature response") + setSubnetValidatorWeightSignature, ok, err := findMessage(genesisPeerMessages, unwrapWarpSignature) + require.NoError(err) + require.True(ok) + + tc.By("creating the signed warp message to increase the weight of the validator") + setSubnetValidatorWeight, err := warp.NewMessage( + unsignedSubnetValidatorWeight, + &warp.BitSetSignature{ + Signers: set.NewBits(0).Bytes(), // [signers] has weight from the genesis peer + Signature: ([bls.SignatureLen]byte)( + bls.SignatureToBytes(setSubnetValidatorWeightSignature), + ), + }, + ) + require.NoError(err) + + tc.By("issuing a SetSubnetValidatorWeightTx", func() { + _, err := pWallet.IssueSetSubnetValidatorWeightTx( + setSubnetValidatorWeight.Bytes(), + ) + require.NoError(err) + }) + + nextNonce++ + } + + tc.By("increasing the weight of the validator", func() { + setWeight(registerValidationID, updatedWeight) + }) + + tc.By("verifying the validator weight was increased", func() { + tc.By("verifying the validator set was updated", func() { + verifyValidatorSet(map[ids.NodeID]*snowvalidators.GetValidatorOutput{ + subnetGenesisNode.NodeID: { + NodeID: subnetGenesisNode.NodeID, + PublicKey: genesisNodePK, + Weight: genesisWeight, + }, + ids.EmptyNodeID: { // The validator is not active + NodeID: ids.EmptyNodeID, + Weight: updatedWeight, + }, + }) + }) + }) + + tc.By("advancing the proposervm P-chain height", advanceProposerVMPChainHeight) + + tc.By("removing the registered validator", func() { + setWeight(registerValidationID, 0) + }) + + tc.By("verifying the validator was removed", func() { + tc.By("verifying the validator set was updated", func() { + verifyValidatorSet(map[ids.NodeID]*snowvalidators.GetValidatorOutput{ + subnetGenesisNode.NodeID: { + NodeID: subnetGenesisNode.NodeID, + PublicKey: genesisNodePK, + Weight: genesisWeight, + }, + }) + }) + }) + genesisPeerMessages.Close() genesisPeer.StartClose() require.NoError(genesisPeer.AwaitClosed(tc.DefaultContext())) diff --git a/vms/platformvm/metrics/tx_metrics.go b/vms/platformvm/metrics/tx_metrics.go index 1da84206cf09..c194f603f97b 100644 --- a/vms/platformvm/metrics/tx_metrics.go +++ b/vms/platformvm/metrics/tx_metrics.go @@ -152,3 +152,10 @@ func (m *txMetrics) RegisterSubnetValidatorTx(*txs.RegisterSubnetValidatorTx) er }).Inc() return nil } + +func (m *txMetrics) SetSubnetValidatorWeightTx(*txs.SetSubnetValidatorWeightTx) error { + m.numTxs.With(prometheus.Labels{ + txLabel: "set_subnet_validator_weight", + }).Inc() + return nil +} diff --git a/vms/platformvm/txs/codec.go b/vms/platformvm/txs/codec.go index 4c3892753555..51e56e380e99 100644 --- a/vms/platformvm/txs/codec.go +++ b/vms/platformvm/txs/codec.go @@ -124,5 +124,6 @@ func RegisterEtnaTypes(targetCodec linearcodec.Codec) error { return errors.Join( targetCodec.RegisterType(&ConvertSubnetTx{}), targetCodec.RegisterType(&RegisterSubnetValidatorTx{}), + targetCodec.RegisterType(&SetSubnetValidatorWeightTx{}), ) } diff --git a/vms/platformvm/txs/executor/atomic_tx_executor.go b/vms/platformvm/txs/executor/atomic_tx_executor.go index 95c56b24c4e6..a821cbfece1e 100644 --- a/vms/platformvm/txs/executor/atomic_tx_executor.go +++ b/vms/platformvm/txs/executor/atomic_tx_executor.go @@ -116,6 +116,10 @@ func (*atomicTxExecutor) RegisterSubnetValidatorTx(*txs.RegisterSubnetValidatorT return ErrWrongTxType } +func (*atomicTxExecutor) SetSubnetValidatorWeightTx(*txs.SetSubnetValidatorWeightTx) error { + return ErrWrongTxType +} + func (e *atomicTxExecutor) ImportTx(*txs.ImportTx) error { return e.atomicTx() } diff --git a/vms/platformvm/txs/executor/proposal_tx_executor.go b/vms/platformvm/txs/executor/proposal_tx_executor.go index aa2e949b312f..21bfc1d249e8 100644 --- a/vms/platformvm/txs/executor/proposal_tx_executor.go +++ b/vms/platformvm/txs/executor/proposal_tx_executor.go @@ -135,6 +135,10 @@ func (*proposalTxExecutor) RegisterSubnetValidatorTx(*txs.RegisterSubnetValidato return ErrWrongTxType } +func (*proposalTxExecutor) SetSubnetValidatorWeightTx(*txs.SetSubnetValidatorWeightTx) 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 diff --git a/vms/platformvm/txs/executor/standard_tx_executor.go b/vms/platformvm/txs/executor/standard_tx_executor.go index f6eef4df867c..b132409b12da 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor.go +++ b/vms/platformvm/txs/executor/standard_tx_executor.go @@ -28,6 +28,7 @@ import ( "github.com/ava-labs/avalanchego/vms/platformvm/warp" "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" "github.com/ava-labs/avalanchego/vms/platformvm/warp/payload" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" ) // TODO: Before Etna, ensure that the maximum number of expiries to track is @@ -55,6 +56,10 @@ var ( errWarpMessageExpired = errors.New("warp message expired") errWarpMessageNotYetAllowed = errors.New("warp message not yet allowed") errWarpMessageAlreadyIssued = errors.New("warp message already issued") + errCouldNotLoadSoV = errors.New("could not load SoV") + errWarpMessageContainsStaleNonce = errors.New("warp message contains stale nonce") + errRemovingLastValidator = errors.New("attempting to remove the last SoV from a converted subnet") + errStateCorruption = errors.New("state corruption") ) // StandardTx executes the standard transaction [tx]. @@ -859,15 +864,8 @@ func (e *standardTxExecutor) RegisterSubnetValidatorTx(tx *txs.RegisterSubnetVal // Verify that the warp message was sent from the expected chain and // address. - subnetConversion, err := e.state.GetSubnetConversion(msg.SubnetID) - if err != nil { - return fmt.Errorf("%w for %s with: %w", errCouldNotLoadSubnetConversion, msg.SubnetID, err) - } - if warpMessage.SourceChainID != subnetConversion.ChainID { - return fmt.Errorf("%w expected %s but had %s", errWrongWarpMessageSourceChainID, subnetConversion.ChainID, warpMessage.SourceChainID) - } - if !bytes.Equal(addressedCall.SourceAddress, subnetConversion.Addr) { - return fmt.Errorf("%w expected 0x%x but got 0x%x", errWrongWarpMessageSourceAddress, subnetConversion.Addr, addressedCall.SourceAddress) + if err := verifyL1Conversion(e.state, msg.SubnetID, warpMessage.SourceChainID, addressedCall.SourceAddress); err != nil { + return err } // Verify that the message contains a valid expiry time. @@ -959,6 +957,142 @@ func (e *standardTxExecutor) RegisterSubnetValidatorTx(tx *txs.RegisterSubnetVal return nil } +func (e *standardTxExecutor) SetSubnetValidatorWeightTx(tx *txs.SetSubnetValidatorWeightTx) error { + var ( + currentTimestamp = e.state.GetTimestamp() + upgrades = e.backend.Config.UpgradeConfig + ) + if !upgrades.IsEtnaActivated(currentTimestamp) { + return errEtnaUpgradeNotActive + } + + if err := e.tx.SyntacticVerify(e.backend.Ctx); err != nil { + return err + } + + if err := avax.VerifyMemoFieldLength(tx.Memo, true /*=isDurangoActive*/); err != nil { + return err + } + + // Verify the flowcheck + fee, err := e.feeCalculator.CalculateFee(tx) + if err != nil { + return err + } + + if err := e.backend.FlowChecker.VerifySpend( + tx, + e.state, + tx.Ins, + tx.Outs, + e.tx.Creds, + map[ids.ID]uint64{ + e.backend.Ctx.AVAXAssetID: fee, + }, + ); err != nil { + return err + } + + // Parse the warp message. + warpMessage, err := warp.ParseMessage(tx.Message) + if err != nil { + return err + } + addressedCall, err := payload.ParseAddressedCall(warpMessage.Payload) + if err != nil { + return err + } + msg, err := message.ParseSubnetValidatorWeight(addressedCall.Payload) + if err != nil { + return err + } + if err := msg.Verify(); err != nil { + return err + } + + // Verify that the message contains a valid nonce for a current validator. + sov, err := e.state.GetSubnetOnlyValidator(msg.ValidationID) + if err != nil { + return fmt.Errorf("%w: %w", errCouldNotLoadSoV, err) + } + if msg.Nonce < sov.MinNonce { + return fmt.Errorf("%w %d must be at least %d", errWarpMessageContainsStaleNonce, msg.Nonce, sov.MinNonce) + } + + // Verify that the warp message was sent from the expected chain and + // address. + if err := verifyL1Conversion(e.state, sov.SubnetID, warpMessage.SourceChainID, addressedCall.SourceAddress); err != nil { + return err + } + + txID := e.tx.ID() + + // Check if we are removing the validator. + if msg.Weight == 0 { + // Verify that we are not removing the last validator. + weight, err := e.state.WeightOfSubnetOnlyValidators(sov.SubnetID) + if err != nil { + return fmt.Errorf("could not load SoV weights: %w", err) + } + if weight == sov.Weight { + return errRemovingLastValidator + } + + // If the validator is currently active, we need to refund the remaining + // balance. + if sov.EndAccumulatedFee != 0 { + var remainingBalanceOwner message.PChainOwner + if _, err := txs.Codec.Unmarshal(sov.RemainingBalanceOwner, &remainingBalanceOwner); err != nil { + return fmt.Errorf("%w: remaining balance owner is malformed", errStateCorruption) + } + + accruedFees := e.state.GetAccruedFees() + if sov.EndAccumulatedFee <= accruedFees { + // This check should be unreachable. However, it prevents AVAX + // from being minted due to state corruption. This also prevents + // invalid UTXOs from being created (with 0 value). + return fmt.Errorf("%w: validator should have already been disabled", errStateCorruption) + } + remainingBalance := sov.EndAccumulatedFee - accruedFees + + utxo := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: uint32(len(tx.Outs)), + }, + Asset: avax.Asset{ + ID: e.backend.Ctx.AVAXAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: remainingBalance, + OutputOwners: secp256k1fx.OutputOwners{ + Threshold: remainingBalanceOwner.Threshold, + Addrs: remainingBalanceOwner.Addresses, + }, + }, + } + e.state.AddUTXO(utxo) + } + } + + // If the weight is being set to 0, it is possible for the nonce increment + // to overflow. However, the validator is being removed and the nonce + // doesn't matter. If weight is not 0, [msg.Nonce] is enforced by + // [msg.Verify()] to be less than MaxUInt64 and can therefore be incremented + // without overflow. + sov.MinNonce = msg.Nonce + 1 + sov.Weight = msg.Weight + if err := e.state.PutSubnetOnlyValidator(sov); err != nil { + return err + } + + // Consume the UTXOS + avax.Consume(e.state, tx.Ins) + // Produce the UTXOS + avax.Produce(e.state, txID, tx.Outs) + return nil +} + // Creates the staker as defined in [stakerTx] and adds it to [e.State]. func (e *standardTxExecutor) putStaker(stakerTx txs.Staker) error { var ( @@ -1030,3 +1164,24 @@ func (e *standardTxExecutor) putStaker(stakerTx txs.Staker) error { } return nil } + +// verifyL1Conversion verifies that the L1 conversion of [subnetID] references +// the [expectedChainID] and [expectedAddress]. +func verifyL1Conversion( + state state.Chain, + subnetID ids.ID, + expectedChainID ids.ID, + expectedAddress []byte, +) error { + subnetConversion, err := state.GetSubnetConversion(subnetID) + if err != nil { + return fmt.Errorf("%w for %s with: %w", errCouldNotLoadSubnetConversion, subnetID, err) + } + if expectedChainID != subnetConversion.ChainID { + return fmt.Errorf("%w expected %s but had %s", errWrongWarpMessageSourceChainID, subnetConversion.ChainID, expectedChainID) + } + if !bytes.Equal(expectedAddress, subnetConversion.Addr) { + return fmt.Errorf("%w expected 0x%x but got 0x%x", errWrongWarpMessageSourceAddress, subnetConversion.Addr, expectedAddress) + } + return nil +} diff --git a/vms/platformvm/txs/executor/standard_tx_executor_test.go b/vms/platformvm/txs/executor/standard_tx_executor_test.go index 2f43ca373613..853a5f861c76 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor_test.go +++ b/vms/platformvm/txs/executor/standard_tx_executor_test.go @@ -3212,6 +3212,509 @@ func TestStandardExecutorRegisterSubnetValidatorTx(t *testing.T) { } } +func TestStandardExecutorSetSubnetValidatorWeightTx(t *testing.T) { + var ( + fx = &secp256k1fx.Fx{} + vm = &secp256k1fx.TestVM{ + Log: logging.NoLog{}, + } + ) + require.NoError(t, fx.InitializeVM(vm)) + + var ( + ctx = snowtest.Context(t, constants.PlatformChainID) + defaultConfig = &config.Internal{ + DynamicFeeConfig: genesis.LocalParams.DynamicFeeConfig, + ValidatorFeeConfig: genesis.LocalParams.ValidatorFeeConfig, + UpgradeConfig: upgradetest.GetConfig(upgradetest.Latest), + } + baseState = statetest.New(t, statetest.Config{ + Upgrades: defaultConfig.UpgradeConfig, + Context: ctx, + }) + wallet = txstest.NewWallet( + t, + ctx, + defaultConfig, + baseState, + secp256k1fx.NewKeychain(genesistest.DefaultFundedKeys...), + nil, // subnetIDs + nil, // chainIDs + ) + flowChecker = utxo.NewVerifier( + ctx, + &vm.Clk, + fx, + ) + + backend = &Backend{ + Config: defaultConfig, + Bootstrapped: utils.NewAtomic(true), + Fx: fx, + FlowChecker: flowChecker, + Ctx: ctx, + } + feeCalculator = state.PickFeeCalculator(defaultConfig, baseState) + ) + + // Create the initial state + diff, err := state.NewDiffOn(baseState) + require.NoError(t, err) + + // Create the subnet + createSubnetTx, err := wallet.IssueCreateSubnetTx( + &secp256k1fx.OutputOwners{}, + ) + require.NoError(t, err) + + // Execute the subnet creation + _, _, _, err = StandardTx( + backend, + feeCalculator, + createSubnetTx, + diff, + ) + require.NoError(t, err) + + // Create the subnet conversion + sk, err := bls.NewSecretKey() + require.NoError(t, err) + + const ( + initialWeight = 1 + balance = units.Avax + ) + var ( + subnetID = createSubnetTx.ID() + chainID = ids.GenerateTestID() + address = utils.RandomBytes(32) + validator = &txs.ConvertSubnetValidator{ + NodeID: ids.GenerateTestNodeID().Bytes(), + Weight: initialWeight, + Balance: balance, + Signer: *signer.NewProofOfPossession(sk), + // RemainingBalanceOwner and DeactivationOwner are initialized so + // that later reflect based equality checks pass. + RemainingBalanceOwner: message.PChainOwner{ + Threshold: 0, + Addresses: []ids.ShortID{}, + }, + DeactivationOwner: message.PChainOwner{ + Threshold: 0, + Addresses: []ids.ShortID{}, + }, + } + validationID = subnetID.Append(0) + ) + + convertSubnetTx, err := wallet.IssueConvertSubnetTx( + subnetID, + chainID, + address, + []*txs.ConvertSubnetValidator{ + validator, + }, + ) + require.NoError(t, err) + + // Execute the subnet conversion + _, _, _, err = StandardTx( + backend, + feeCalculator, + convertSubnetTx, + diff, + ) + require.NoError(t, err) + require.NoError(t, diff.Apply(baseState)) + require.NoError(t, baseState.Commit()) + + initialSoV, err := baseState.GetSubnetOnlyValidator(validationID) + require.NoError(t, err) + + // Create the Warp messages + const ( + nonce = 1 + weight = initialWeight + 1 + ) + unsignedIncreaseWeightWarpMessage := must[*warp.UnsignedMessage](t)(warp.NewUnsignedMessage( + ctx.NetworkID, + chainID, + must[*payload.AddressedCall](t)(payload.NewAddressedCall( + address, + must[*message.SubnetValidatorWeight](t)(message.NewSubnetValidatorWeight( + validationID, + nonce, + weight, + )).Bytes(), + )).Bytes(), + )) + warpSignature := &warp.BitSetSignature{ + Signers: set.NewBits(0).Bytes(), + Signature: ([bls.SignatureLen]byte)(bls.SignatureToBytes( + bls.Sign( + sk, + unsignedIncreaseWeightWarpMessage.Bytes(), + ), + )), + } + increaseWeightWarpMessage := must[*warp.Message](t)(warp.NewMessage( + unsignedIncreaseWeightWarpMessage, + warpSignature, + )) + removeValidatorWarpMessage := must[*warp.Message](t)(warp.NewMessage( + must[*warp.UnsignedMessage](t)(warp.NewUnsignedMessage( + ctx.NetworkID, + chainID, + must[*payload.AddressedCall](t)(payload.NewAddressedCall( + address, + must[*message.SubnetValidatorWeight](t)(message.NewSubnetValidatorWeight( + validationID, + nonce, + 0, + )).Bytes(), + )).Bytes(), + )), + warpSignature, + )) + + increaseL1Weight := func(weight uint64) func(*standardTxExecutor) error { + return func(e *standardTxExecutor) error { + sov := initialSoV + sov.ValidationID = ids.GenerateTestID() + sov.NodeID = ids.GenerateTestNodeID() + sov.Weight = weight + return e.state.PutSubnetOnlyValidator(sov) + } + } + + tests := []struct { + name string + message []byte + builderOptions []common.Option + updateExecutor func(*standardTxExecutor) error + expectedNonce uint64 + expectedWeight uint64 + expectedRemainingFundsUTXO *avax.UTXO + expectedErr error + }{ + { + name: "invalid prior to E-Upgrade", + updateExecutor: func(e *standardTxExecutor) error { + e.backend.Config = &config.Internal{ + UpgradeConfig: upgradetest.GetConfig(upgradetest.Durango), + } + return nil + }, + expectedErr: errEtnaUpgradeNotActive, + }, + { + name: "tx fails syntactic verification", + updateExecutor: func(e *standardTxExecutor) error { + e.backend.Ctx = snowtest.Context(t, ids.GenerateTestID()) + return nil + }, + expectedErr: avax.ErrWrongChainID, + }, + { + name: "invalid memo length", + builderOptions: []common.Option{ + common.WithMemo([]byte("memo!")), + }, + expectedErr: avax.ErrMemoTooLarge, + }, + { + name: "invalid fee calculation", + updateExecutor: func(e *standardTxExecutor) error { + e.feeCalculator = txfee.NewStaticCalculator(e.backend.Config.StaticFeeConfig) + return nil + }, + expectedErr: txfee.ErrUnsupportedTx, + }, + { + name: "insufficient fee", + updateExecutor: func(e *standardTxExecutor) error { + e.feeCalculator = txfee.NewDynamicCalculator( + e.backend.Config.DynamicFeeConfig.Weights, + 100*genesis.LocalParams.DynamicFeeConfig.MinPrice, + ) + return nil + }, + expectedErr: utxo.ErrInsufficientUnlockedFunds, + }, + { + name: "invalid warp message", + message: []byte{}, + expectedErr: codec.ErrCantUnpackVersion, + }, + { + name: "invalid warp payload", + message: must[*warp.Message](t)(warp.NewMessage( + must[*warp.UnsignedMessage](t)(warp.NewUnsignedMessage( + ctx.NetworkID, + chainID, + must[*payload.Hash](t)(payload.NewHash(ids.Empty)).Bytes(), + )), + warpSignature, + )).Bytes(), + expectedErr: payload.ErrWrongType, + }, + { + name: "invalid addressed call", + message: must[*warp.Message](t)(warp.NewMessage( + must[*warp.UnsignedMessage](t)(warp.NewUnsignedMessage( + ctx.NetworkID, + chainID, + must[*payload.AddressedCall](t)(payload.NewAddressedCall( + address, + must[*message.SubnetConversion](t)(message.NewSubnetConversion(ids.Empty)).Bytes(), + )).Bytes(), + )), + warpSignature, + )).Bytes(), + expectedErr: message.ErrWrongType, + }, + { + name: "invalid addressed call payload", + message: must[*warp.Message](t)(warp.NewMessage( + must[*warp.UnsignedMessage](t)(warp.NewUnsignedMessage( + ctx.NetworkID, + chainID, + must[*payload.AddressedCall](t)(payload.NewAddressedCall( + address, + must[*message.SubnetValidatorWeight](t)(message.NewSubnetValidatorWeight( + validationID, + math.MaxUint64, + 1, + )).Bytes(), + )).Bytes(), + )), + warpSignature, + )).Bytes(), + expectedErr: message.ErrNonceReservedForRemoval, + }, + { + name: "SoV not found", + message: must[*warp.Message](t)(warp.NewMessage( + must[*warp.UnsignedMessage](t)(warp.NewUnsignedMessage( + ctx.NetworkID, + chainID, + must[*payload.AddressedCall](t)(payload.NewAddressedCall( + address, + must[*message.SubnetValidatorWeight](t)(message.NewSubnetValidatorWeight( + ids.GenerateTestID(), // invalid initialValidationID + nonce, + weight, + )).Bytes(), + )).Bytes(), + )), + warpSignature, + )).Bytes(), + expectedErr: errCouldNotLoadSoV, + }, + { + name: "nonce too low", + updateExecutor: func(e *standardTxExecutor) error { + sov := initialSoV + sov.MinNonce = nonce + 1 + return e.state.PutSubnetOnlyValidator(sov) + }, + expectedErr: errWarpMessageContainsStaleNonce, + }, + { + name: "invalid source chain", + updateExecutor: func(e *standardTxExecutor) error { + e.state.SetSubnetConversion(subnetID, state.SubnetConversion{}) + return nil + }, + expectedErr: errWrongWarpMessageSourceChainID, + }, + { + name: "invalid source address", + updateExecutor: func(e *standardTxExecutor) error { + e.state.SetSubnetConversion(subnetID, state.SubnetConversion{ + ChainID: chainID, + }) + return nil + }, + expectedErr: errWrongWarpMessageSourceAddress, + }, + { + name: "remove last validator", + message: removeValidatorWarpMessage.Bytes(), + expectedErr: errRemovingLastValidator, + }, + { + name: "remove deactivated validator", + message: removeValidatorWarpMessage.Bytes(), + updateExecutor: func(e *standardTxExecutor) error { + // Add another validator to allow removal + if err := increaseL1Weight(1)(e); err != nil { + return err + } + + sov := initialSoV + sov.EndAccumulatedFee = 0 // Deactivate the validator + return e.state.PutSubnetOnlyValidator(sov) + }, + }, + { + name: "remove deactivated validator with nonce overflow", + message: must[*warp.Message](t)(warp.NewMessage( + must[*warp.UnsignedMessage](t)(warp.NewUnsignedMessage( + ctx.NetworkID, + chainID, + must[*payload.AddressedCall](t)(payload.NewAddressedCall( + address, + must[*message.SubnetValidatorWeight](t)(message.NewSubnetValidatorWeight( + validationID, + math.MaxUint64, + 0, + )).Bytes(), + )).Bytes(), + )), + warpSignature, + )).Bytes(), + updateExecutor: func(e *standardTxExecutor) error { + // Add another validator to allow removal + if err := increaseL1Weight(1)(e); err != nil { + return err + } + + sov := initialSoV + sov.EndAccumulatedFee = 0 // Deactivate the validator + return e.state.PutSubnetOnlyValidator(sov) + }, + }, + { + name: "should have been previously deactivated", + message: removeValidatorWarpMessage.Bytes(), + updateExecutor: func(e *standardTxExecutor) error { + e.state.SetAccruedFees(initialSoV.EndAccumulatedFee) + return increaseL1Weight(1)(e) + }, + expectedErr: errStateCorruption, + }, + { + name: "remove active validator", + message: removeValidatorWarpMessage.Bytes(), + updateExecutor: increaseL1Weight(1), + expectedRemainingFundsUTXO: &avax.UTXO{ + Asset: avax.Asset{ + ID: ctx.AVAXAssetID, + }, + Out: &secp256k1fx.TransferOutput{ + Amt: balance, + OutputOwners: secp256k1fx.OutputOwners{ + Threshold: validator.RemainingBalanceOwner.Threshold, + Addrs: validator.RemainingBalanceOwner.Addresses, + }, + }, + }, + }, + { + name: "L1 weight overflow", + updateExecutor: increaseL1Weight(math.MaxUint64 - initialWeight), + expectedErr: safemath.ErrOverflow, + }, + { + name: "update validator", + expectedNonce: nonce + 1, + expectedWeight: weight, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + + // Create the SetSubnetValidatorWeightTx + wallet := txstest.NewWallet( + t, + ctx, + defaultConfig, + baseState, + secp256k1fx.NewKeychain(genesistest.DefaultFundedKeys...), + nil, // subnetIDs + nil, // chainIDs + ) + + message := test.message + if message == nil { + message = increaseWeightWarpMessage.Bytes() + } + setSubnetValidatorWeightTx, err := wallet.IssueSetSubnetValidatorWeightTx( + message, + test.builderOptions..., + ) + require.NoError(err) + + diff, err := state.NewDiffOn(baseState) + require.NoError(err) + + executor := &standardTxExecutor{ + backend: &Backend{ + Config: defaultConfig, + Bootstrapped: utils.NewAtomic(true), + Fx: fx, + FlowChecker: flowChecker, + Ctx: ctx, + }, + feeCalculator: state.PickFeeCalculator(defaultConfig, baseState), + tx: setSubnetValidatorWeightTx, + state: diff, + } + if test.updateExecutor != nil { + require.NoError(test.updateExecutor(executor)) + } + + err = setSubnetValidatorWeightTx.Unsigned.Visit(executor) + require.ErrorIs(err, test.expectedErr) + if err != nil { + return + } + + for utxoID := range setSubnetValidatorWeightTx.InputIDs() { + _, err := diff.GetUTXO(utxoID) + require.ErrorIs(err, database.ErrNotFound) + } + + baseTxOutputUTXOs := setSubnetValidatorWeightTx.UTXOs() + for _, expectedUTXO := range baseTxOutputUTXOs { + utxoID := expectedUTXO.InputID() + utxo, err := diff.GetUTXO(utxoID) + require.NoError(err) + require.Equal(expectedUTXO, utxo) + } + + sov, err := diff.GetSubnetOnlyValidator(validationID) + if test.expectedWeight != 0 { + require.NoError(err) + + expectedSoV := initialSoV + expectedSoV.MinNonce = test.expectedNonce + expectedSoV.Weight = test.expectedWeight + require.Equal(expectedSoV, sov) + return + } + require.ErrorIs(err, database.ErrNotFound) + + utxoID := avax.UTXOID{ + TxID: setSubnetValidatorWeightTx.ID(), + OutputIndex: uint32(len(baseTxOutputUTXOs)), + } + inputID := utxoID.InputID() + utxo, err := diff.GetUTXO(inputID) + if test.expectedRemainingFundsUTXO == nil { + require.ErrorIs(err, database.ErrNotFound) + return + } + require.NoError(err) + + test.expectedRemainingFundsUTXO.UTXOID = utxoID + require.Equal(test.expectedRemainingFundsUTXO, utxo) + }) + } +} + 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/fee/calculator_test.go b/vms/platformvm/txs/fee/calculator_test.go index dd71649fecd3..9a3a3f2d1508 100644 --- a/vms/platformvm/txs/fee/calculator_test.go +++ b/vms/platformvm/txs/fee/calculator_test.go @@ -243,5 +243,17 @@ var ( }, expectedDynamicFee: 151_000, }, + { + name: "SetSubnetValidatorWeightTx", + tx: "00000000002500003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f1f88b5100000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001389c41b6ed301e4c118bd23673268fd2054b772efcf25685a117b74bab7ae5e400000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f1f88b552a000000010000000000000000000000d7000000003039705f3d4415f990225d3df5ce437d7af2aa324b1bbce854ee34ab6f39882250d200000044000000000001000000000000003600000000000338e6e9fe31c6d070a8c792dbacf6d0aefb8eac2aded49cc0aa9f422d1fdd9ecd0000000000000001000000000000000500000000000000010187f4bb2c42869c56f023a1ca81045aff034acd490b8f15b5069025f982e605e077007fc588f7d56369a65df7574df3b70ff028ea173739c789525ab7eebfcb5c115b13cca8f02b362104b700c75bc95234109f3f1360ddcb4ec3caf6b0e821cb0000000100000009000000010a29f3c86d52908bf2efbc3f918a363df704c429d66c8d6615712a2a584a2a5f264a9e7b107c07122a06f31cadc2f51285884d36fe8df909a07467417f1d64cf00", + expectedStaticFeeErr: ErrUnsupportedTx, + expectedComplexity: gas.Dimensions{ + gas.Bandwidth: 518, // The length of the tx in bytes + gas.DBRead: IntrinsicSetSubnetValidatorWeightTxComplexities[gas.DBRead] + intrinsicInputDBRead, + gas.DBWrite: IntrinsicSetSubnetValidatorWeightTxComplexities[gas.DBWrite] + intrinsicInputDBWrite + intrinsicOutputDBWrite, + gas.Compute: 0, // TODO: implement + }, + expectedDynamicFee: 131_800, + }, } ) diff --git a/vms/platformvm/txs/fee/complexity.go b/vms/platformvm/txs/fee/complexity.go index 5083296d2936..9bf0b2e2b3c2 100644 --- a/vms/platformvm/txs/fee/complexity.go +++ b/vms/platformvm/txs/fee/complexity.go @@ -207,6 +207,13 @@ var ( gas.DBWrite: 0, // TODO gas.Compute: 0, // TODO: Include PoP verification time } + IntrinsicSetSubnetValidatorWeightTxComplexities = gas.Dimensions{ + gas.Bandwidth: IntrinsicBaseTxComplexities[gas.Bandwidth] + + wrappers.IntLen, // message length + gas.DBRead: 0, // TODO + gas.DBWrite: 0, // TODO + gas.Compute: 0, + } errUnsupportedOutput = errors.New("unsupported output type") errUnsupportedInput = errors.New("unsupported input type") @@ -721,6 +728,22 @@ func (c *complexityVisitor) RegisterSubnetValidatorTx(tx *txs.RegisterSubnetVali return err } +func (c *complexityVisitor) SetSubnetValidatorWeightTx(tx *txs.SetSubnetValidatorWeightTx) error { + baseTxComplexity, err := baseTxComplexity(&tx.BaseTx) + if err != nil { + return err + } + warpComplexity, err := WarpComplexity(tx.Message) + if err != nil { + return err + } + c.output, err = IntrinsicSetSubnetValidatorWeightTxComplexities.Add( + &baseTxComplexity, + &warpComplexity, + ) + return err +} + func baseTxComplexity(tx *txs.BaseTx) (gas.Dimensions, error) { outputsComplexity, err := OutputComplexity(tx.Outs...) if err != nil { diff --git a/vms/platformvm/txs/fee/static_calculator.go b/vms/platformvm/txs/fee/static_calculator.go index be4651cbb911..c3846708f6bc 100644 --- a/vms/platformvm/txs/fee/static_calculator.go +++ b/vms/platformvm/txs/fee/static_calculator.go @@ -55,6 +55,10 @@ func (*staticVisitor) RegisterSubnetValidatorTx(*txs.RegisterSubnetValidatorTx) return ErrUnsupportedTx } +func (*staticVisitor) SetSubnetValidatorWeightTx(*txs.SetSubnetValidatorWeightTx) error { + return ErrUnsupportedTx +} + func (c *staticVisitor) AddValidatorTx(*txs.AddValidatorTx) error { c.fee = c.config.AddPrimaryNetworkValidatorFee return nil diff --git a/vms/platformvm/txs/set_subnet_validator_weight_tx.go b/vms/platformvm/txs/set_subnet_validator_weight_tx.go new file mode 100644 index 000000000000..519e0bd3bd97 --- /dev/null +++ b/vms/platformvm/txs/set_subnet_validator_weight_tx.go @@ -0,0 +1,40 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/vms/types" +) + +var _ UnsignedTx = (*SetSubnetValidatorWeightTx)(nil) + +type SetSubnetValidatorWeightTx struct { + // Metadata, inputs and outputs + BaseTx `serialize:"true"` + // Message is expected to be a signed Warp message containing an + // AddressedCall payload with the SetSubnetValidatorWeight message. + Message types.JSONByteSlice `serialize:"true" json:"message"` +} + +func (tx *SetSubnetValidatorWeightTx) SyntacticVerify(ctx *snow.Context) error { + switch { + case tx == nil: + return ErrNilTx + case tx.SyntacticallyVerified: + // already passed syntactic verification + return nil + } + + if err := tx.BaseTx.SyntacticVerify(ctx); err != nil { + return err + } + + tx.SyntacticallyVerified = true + return nil +} + +func (tx *SetSubnetValidatorWeightTx) Visit(visitor Visitor) error { + return visitor.SetSubnetValidatorWeightTx(tx) +} diff --git a/vms/platformvm/txs/set_subnet_validator_weight_tx_test.go b/vms/platformvm/txs/set_subnet_validator_weight_tx_test.go new file mode 100644 index 000000000000..13dd4afc7766 --- /dev/null +++ b/vms/platformvm/txs/set_subnet_validator_weight_tx_test.go @@ -0,0 +1,365 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package txs + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + _ "embed" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/snowtest" + "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/units" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/platformvm/stakeable" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/ava-labs/avalanchego/vms/types" +) + +//go:embed set_subnet_validator_weight_tx_test.json +var setSubnetValidatorWeightTxJSON []byte + +func TestSetSubnetValidatorWeightTxSerialization(t *testing.T) { + require := require.New(t) + + var ( + message = []byte("message") + addr = ids.ShortID{ + 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0x44, 0x55, 0x66, 0x77, + } + avaxAssetID = ids.ID{ + 0x21, 0xe6, 0x73, 0x17, 0xcb, 0xc4, 0xbe, 0x2a, + 0xeb, 0x00, 0x67, 0x7a, 0xd6, 0x46, 0x27, 0x78, + 0xa8, 0xf5, 0x22, 0x74, 0xb9, 0xd6, 0x05, 0xdf, + 0x25, 0x91, 0xb2, 0x30, 0x27, 0xa8, 0x7d, 0xff, + } + customAssetID = ids.ID{ + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + } + txID = ids.ID{ + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + } + ) + + var unsignedTx UnsignedTx = &SetSubnetValidatorWeightTx{ + BaseTx: BaseTx{ + BaseTx: avax.BaseTx{ + NetworkID: constants.UnitTestID, + BlockchainID: constants.PlatformChainID, + Outs: []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: avaxAssetID, + }, + Out: &stakeable.LockOut{ + Locktime: 87654321, + TransferableOut: &secp256k1fx.TransferOutput{ + Amt: 1, + OutputOwners: secp256k1fx.OutputOwners{ + Locktime: 12345678, + Threshold: 0, + Addrs: []ids.ShortID{}, + }, + }, + }, + }, + { + Asset: avax.Asset{ + ID: customAssetID, + }, + Out: &stakeable.LockOut{ + Locktime: 876543210, + TransferableOut: &secp256k1fx.TransferOutput{ + Amt: 0xffffffffffffffff, + OutputOwners: secp256k1fx.OutputOwners{ + Locktime: 0, + Threshold: 1, + Addrs: []ids.ShortID{ + addr, + }, + }, + }, + }, + }, + }, + Ins: []*avax.TransferableInput{ + { + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: 1, + }, + Asset: avax.Asset{ + ID: avaxAssetID, + }, + In: &secp256k1fx.TransferInput{ + Amt: units.Avax, + Input: secp256k1fx.Input{ + SigIndices: []uint32{2, 5}, + }, + }, + }, + { + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: 2, + }, + Asset: avax.Asset{ + ID: customAssetID, + }, + In: &stakeable.LockIn{ + Locktime: 876543210, + TransferableIn: &secp256k1fx.TransferInput{ + Amt: 0xefffffffffffffff, + Input: secp256k1fx.Input{ + SigIndices: []uint32{0}, + }, + }, + }, + }, + { + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: 3, + }, + Asset: avax.Asset{ + ID: customAssetID, + }, + In: &secp256k1fx.TransferInput{ + Amt: 0x1000000000000000, + Input: secp256k1fx.Input{ + SigIndices: []uint32{}, + }, + }, + }, + }, + Memo: types.JSONByteSlice("😅\nwell that's\x01\x23\x45!"), + }, + }, + Message: message, + } + txBytes, err := Codec.Marshal(CodecVersion, &unsignedTx) + require.NoError(err) + + expectedBytes := []byte{ + // Codec version + 0x00, 0x00, + // SetSubnetValidatorWeightTx Type ID + 0x00, 0x00, 0x00, 0x25, + // Network ID + 0x00, 0x00, 0x00, 0x0a, + // P-chain blockchain ID + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Number of outputs + 0x00, 0x00, 0x00, 0x02, + // Outputs[0] + // AVAX assetID + 0x21, 0xe6, 0x73, 0x17, 0xcb, 0xc4, 0xbe, 0x2a, + 0xeb, 0x00, 0x67, 0x7a, 0xd6, 0x46, 0x27, 0x78, + 0xa8, 0xf5, 0x22, 0x74, 0xb9, 0xd6, 0x05, 0xdf, + 0x25, 0x91, 0xb2, 0x30, 0x27, 0xa8, 0x7d, 0xff, + // Stakeable locked output type ID + 0x00, 0x00, 0x00, 0x16, + // Locktime + 0x00, 0x00, 0x00, 0x00, 0x05, 0x39, 0x7f, 0xb1, + // secp256k1fx transfer output type ID + 0x00, 0x00, 0x00, 0x07, + // amount + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + // secp256k1fx output locktime + 0x00, 0x00, 0x00, 0x00, 0x00, 0xbc, 0x61, 0x4e, + // threshold + 0x00, 0x00, 0x00, 0x00, + // number of addresses + 0x00, 0x00, 0x00, 0x00, + // Outputs[1] + // custom asset ID + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + // Stakeable locked output type ID + 0x00, 0x00, 0x00, 0x16, + // Locktime + 0x00, 0x00, 0x00, 0x00, 0x34, 0x3e, 0xfc, 0xea, + // secp256k1fx transfer output type ID + 0x00, 0x00, 0x00, 0x07, + // amount + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + // secp256k1fx output locktime + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // threshold + 0x00, 0x00, 0x00, 0x01, + // number of addresses + 0x00, 0x00, 0x00, 0x01, + // address[0] + 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0x44, 0x55, 0x66, 0x77, + // number of inputs + 0x00, 0x00, 0x00, 0x03, + // inputs[0] + // TxID + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + // Tx output index + 0x00, 0x00, 0x00, 0x01, + // AVAX assetID + 0x21, 0xe6, 0x73, 0x17, 0xcb, 0xc4, 0xbe, 0x2a, + 0xeb, 0x00, 0x67, 0x7a, 0xd6, 0x46, 0x27, 0x78, + 0xa8, 0xf5, 0x22, 0x74, 0xb9, 0xd6, 0x05, 0xdf, + 0x25, 0x91, 0xb2, 0x30, 0x27, 0xa8, 0x7d, 0xff, + // secp256k1fx transfer input type ID + 0x00, 0x00, 0x00, 0x05, + // input amount = 1 Avax + 0x00, 0x00, 0x00, 0x00, 0x3b, 0x9a, 0xca, 0x00, + // number of signatures needed in input + 0x00, 0x00, 0x00, 0x02, + // index of first signer + 0x00, 0x00, 0x00, 0x02, + // index of second signer + 0x00, 0x00, 0x00, 0x05, + // inputs[1] + // TxID + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + // Tx output index + 0x00, 0x00, 0x00, 0x02, + // Custom asset ID + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + // Stakeable locked input type ID + 0x00, 0x00, 0x00, 0x15, + // Locktime + 0x00, 0x00, 0x00, 0x00, 0x34, 0x3e, 0xfc, 0xea, + // secp256k1fx transfer input type ID + 0x00, 0x00, 0x00, 0x05, + // input amount + 0xef, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + // number of signatures needed in input + 0x00, 0x00, 0x00, 0x01, + // index of signer + 0x00, 0x00, 0x00, 0x00, + // inputs[2] + // TxID + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, + // Tx output index + 0x00, 0x00, 0x00, 0x03, + // custom asset ID + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + 0x99, 0x77, 0x55, 0x77, 0x11, 0x33, 0x55, 0x31, + // secp256k1fx transfer input type ID + 0x00, 0x00, 0x00, 0x05, + // input amount + 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // number of signatures needed in input + 0x00, 0x00, 0x00, 0x00, + // length of memo + 0x00, 0x00, 0x00, 0x14, + // memo + 0xf0, 0x9f, 0x98, 0x85, 0x0a, 0x77, 0x65, 0x6c, + 0x6c, 0x20, 0x74, 0x68, 0x61, 0x74, 0x27, 0x73, + 0x01, 0x23, 0x45, 0x21, + // length of message + 0x00, 0x00, 0x00, 0x07, + // message + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + } + require.Equal(expectedBytes, txBytes) + + ctx := snowtest.Context(t, constants.PlatformChainID) + unsignedTx.InitCtx(ctx) + + txJSON, err := json.MarshalIndent(unsignedTx, "", "\t") + require.NoError(err) + require.Equal( + // Normalize newlines for Windows + strings.ReplaceAll(string(setSubnetValidatorWeightTxJSON), "\r\n", "\n"), + string(txJSON), + ) +} + +func TestSetSubnetValidatorWeightTxSyntacticVerify(t *testing.T) { + ctx := snowtest.Context(t, ids.GenerateTestID()) + tests := []struct { + name string + tx *SetSubnetValidatorWeightTx + expectedErr error + }{ + { + name: "nil tx", + tx: nil, + expectedErr: ErrNilTx, + }, + { + name: "already verified", + // The tx includes invalid data to verify that a cached result is + // returned. + tx: &SetSubnetValidatorWeightTx{ + BaseTx: BaseTx{ + SyntacticallyVerified: true, + }, + }, + expectedErr: nil, + }, + { + name: "invalid BaseTx", + tx: &SetSubnetValidatorWeightTx{ + BaseTx: BaseTx{}, + }, + expectedErr: avax.ErrWrongNetworkID, + }, + { + name: "passes verification", + tx: &SetSubnetValidatorWeightTx{ + BaseTx: BaseTx{ + BaseTx: avax.BaseTx{ + NetworkID: ctx.NetworkID, + BlockchainID: ctx.ChainID, + }, + }, + }, + expectedErr: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + + err := test.tx.SyntacticVerify(ctx) + require.ErrorIs(err, test.expectedErr) + if test.expectedErr != nil { + return + } + require.True(test.tx.SyntacticallyVerified) + }) + } +} diff --git a/vms/platformvm/txs/set_subnet_validator_weight_tx_test.json b/vms/platformvm/txs/set_subnet_validator_weight_tx_test.json new file mode 100644 index 000000000000..7f81afa155ac --- /dev/null +++ b/vms/platformvm/txs/set_subnet_validator_weight_tx_test.json @@ -0,0 +1,76 @@ +{ + "networkID": 10, + "blockchainID": "11111111111111111111111111111111LpoYY", + "outputs": [ + { + "assetID": "FvwEAhmxKfeiG8SnEvq42hc6whRyY3EFYAvebMqDNDGCgxN5Z", + "fxID": "spdxUxVJQbX85MGxMHbKw1sHxMnSqJ3QBzDyDYEP3h6TLuxqQ", + "output": { + "locktime": 87654321, + "output": { + "addresses": [], + "amount": 1, + "locktime": 12345678, + "threshold": 0 + } + } + }, + { + "assetID": "2Ab62uWwJw1T6VvmKD36ufsiuGZuX1pGykXAvPX1LtjTRHxwcc", + "fxID": "spdxUxVJQbX85MGxMHbKw1sHxMnSqJ3QBzDyDYEP3h6TLuxqQ", + "output": { + "locktime": 876543210, + "output": { + "addresses": [ + "P-testing1g32kvaugnx4tk3z4vemc3xd2hdz92enhgrdu9n" + ], + "amount": 18446744073709551615, + "locktime": 0, + "threshold": 1 + } + } + } + ], + "inputs": [ + { + "txID": "2wiU5PnFTjTmoAXGZutHAsPF36qGGyLHYHj9G1Aucfmb3JFFGN", + "outputIndex": 1, + "assetID": "FvwEAhmxKfeiG8SnEvq42hc6whRyY3EFYAvebMqDNDGCgxN5Z", + "fxID": "spdxUxVJQbX85MGxMHbKw1sHxMnSqJ3QBzDyDYEP3h6TLuxqQ", + "input": { + "amount": 1000000000, + "signatureIndices": [ + 2, + 5 + ] + } + }, + { + "txID": "2wiU5PnFTjTmoAXGZutHAsPF36qGGyLHYHj9G1Aucfmb3JFFGN", + "outputIndex": 2, + "assetID": "2Ab62uWwJw1T6VvmKD36ufsiuGZuX1pGykXAvPX1LtjTRHxwcc", + "fxID": "spdxUxVJQbX85MGxMHbKw1sHxMnSqJ3QBzDyDYEP3h6TLuxqQ", + "input": { + "locktime": 876543210, + "input": { + "amount": 17293822569102704639, + "signatureIndices": [ + 0 + ] + } + } + }, + { + "txID": "2wiU5PnFTjTmoAXGZutHAsPF36qGGyLHYHj9G1Aucfmb3JFFGN", + "outputIndex": 3, + "assetID": "2Ab62uWwJw1T6VvmKD36ufsiuGZuX1pGykXAvPX1LtjTRHxwcc", + "fxID": "spdxUxVJQbX85MGxMHbKw1sHxMnSqJ3QBzDyDYEP3h6TLuxqQ", + "input": { + "amount": 1152921504606846976, + "signatureIndices": [] + } + } + ], + "memo": "0xf09f98850a77656c6c2074686174277301234521", + "message": "0x6d657373616765" +} \ No newline at end of file diff --git a/vms/platformvm/txs/visitor.go b/vms/platformvm/txs/visitor.go index 02627c198f16..6aa766e1e3ea 100644 --- a/vms/platformvm/txs/visitor.go +++ b/vms/platformvm/txs/visitor.go @@ -29,4 +29,5 @@ type Visitor interface { // Etna Transactions: ConvertSubnetTx(*ConvertSubnetTx) error RegisterSubnetValidatorTx(*RegisterSubnetValidatorTx) error + SetSubnetValidatorWeightTx(*SetSubnetValidatorWeightTx) error } diff --git a/wallet/chain/p/builder/builder.go b/wallet/chain/p/builder/builder.go index 8a429fbe24c3..8ca24ba8f7d9 100644 --- a/wallet/chain/p/builder/builder.go +++ b/wallet/chain/p/builder/builder.go @@ -179,6 +179,15 @@ type Builder interface { options ...common.Option, ) (*txs.RegisterSubnetValidatorTx, error) + // NewSetSubnetValidatorWeightTx sets the weight of a validator on an L1. + // + // - [message] is the Warp message that authorizes this validator's weight + // to be changed + NewSetSubnetValidatorWeightTx( + message []byte, + options ...common.Option, + ) (*txs.SetSubnetValidatorWeightTx, error) + // NewImportTx creates an import transaction that attempts to consume all // the available UTXOs and import the funds to [to]. // @@ -935,6 +944,56 @@ func (b *builder) NewRegisterSubnetValidatorTx( return tx, b.initCtx(tx) } +func (b *builder) NewSetSubnetValidatorWeightTx( + message []byte, + options ...common.Option, +) (*txs.SetSubnetValidatorWeightTx, error) { + var ( + toBurn = map[ids.ID]uint64{} + toStake = map[ids.ID]uint64{} + ops = common.NewOptions(options) + memo = ops.Memo() + memoComplexity = gas.Dimensions{ + gas.Bandwidth: uint64(len(memo)), + } + ) + warpComplexity, err := fee.WarpComplexity(message) + if err != nil { + return nil, err + } + complexity, err := fee.IntrinsicSetSubnetValidatorWeightTxComplexities.Add( + &memoComplexity, + &warpComplexity, + ) + 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.SetSubnetValidatorWeightTx{ + BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ + NetworkID: b.context.NetworkID, + BlockchainID: constants.PlatformChainID, + Ins: inputs, + Outs: outputs, + Memo: memo, + }}, + Message: message, + } + return tx, b.initCtx(tx) +} + func (b *builder) NewImportTx( sourceChainID ids.ID, to *secp256k1fx.OutputOwners, diff --git a/wallet/chain/p/builder/with_options.go b/wallet/chain/p/builder/with_options.go index 89fb35e9bd26..86dab193c07b 100644 --- a/wallet/chain/p/builder/with_options.go +++ b/wallet/chain/p/builder/with_options.go @@ -185,6 +185,16 @@ func (w *withOptions) NewRegisterSubnetValidatorTx( ) } +func (w *withOptions) NewSetSubnetValidatorWeightTx( + message []byte, + options ...common.Option, +) (*txs.SetSubnetValidatorWeightTx, error) { + return w.builder.NewSetSubnetValidatorWeightTx( + message, + common.UnionOptions(w.options, options)..., + ) +} + func (w *withOptions) NewImportTx( sourceChainID ids.ID, to *secp256k1fx.OutputOwners, diff --git a/wallet/chain/p/builder_test.go b/wallet/chain/p/builder_test.go index bc03f30814e9..9a3cb1001401 100644 --- a/wallet/chain/p/builder_test.go +++ b/wallet/chain/p/builder_test.go @@ -849,6 +849,88 @@ func TestRegisterSubnetValidatorTx(t *testing.T) { } } +func TestSetSubnetValidatorWeightTx(t *testing.T) { + const ( + nonce = 1 + weight = 7905001371 + ) + var ( + validationID = ids.GenerateTestID() + chainID = ids.GenerateTestID() + address = utils.RandomBytes(20) + ) + + addressedCallPayload, err := message.NewSubnetValidatorWeight( + validationID, + nonce, + weight, + ) + require.NoError(t, err) + + addressedCall, err := payload.NewAddressedCall( + address, + addressedCallPayload.Bytes(), + ) + require.NoError(t, err) + + unsignedWarp, err := warp.NewUnsignedMessage( + constants.UnitTestID, + chainID, + addressedCall.Bytes(), + ) + require.NoError(t, err) + + sk, err := bls.NewSecretKey() + require.NoError(t, err) + + warp, err := warp.NewMessage( + unsignedWarp, + &warp.BitSetSignature{ + Signers: set.NewBits(0).Bytes(), + Signature: ([bls.SignatureLen]byte)( + bls.SignatureToBytes( + bls.Sign( + sk, + unsignedWarp.Bytes(), + ), + ), + ), + }, + ) + require.NoError(t, err) + + warpMessageBytes := warp.Bytes() + for _, e := range testEnvironmentPostEtna { + t.Run(e.name, func(t *testing.T) { + var ( + require = require.New(t) + chainUTXOs = utxotest.NewDeterministicChainUTXOs(t, map[ids.ID][]*avax.UTXO{ + constants.PlatformChainID: utxos, + }) + backend = wallet.NewBackend(e.context, chainUTXOs, nil) + builder = builder.New(set.Of(utxoAddr), e.context, backend) + ) + + utx, err := builder.NewSetSubnetValidatorWeightTx( + warpMessageBytes, + common.WithMemo(e.memo), + ) + require.NoError(err) + require.Equal(types.JSONByteSlice(warpMessageBytes), utx.Message) + require.Equal(types.JSONByteSlice(e.memo), utx.Memo) + requireFeeIsCorrect( + require, + e.feeCalculator, + 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 bb4f6d37f87f..c595ab82a30a 100644 --- a/wallet/chain/p/signer/visitor.go +++ b/wallet/chain/p/signer/visitor.go @@ -206,6 +206,14 @@ func (s *visitor) RegisterSubnetValidatorTx(tx *txs.RegisterSubnetValidatorTx) e return sign(s.tx, true, txSigners) } +func (s *visitor) SetSubnetValidatorWeightTx(tx *txs.SetSubnetValidatorWeightTx) error { + txSigners, err := s.getSigners(constants.PlatformChainID, tx.Ins) + if err != nil { + return err + } + return sign(s.tx, true, txSigners) +} + func (s *visitor) getSigners(sourceChainID ids.ID, ins []*avax.TransferableInput) ([][]keychain.Signer, error) { txSigners := make([][]keychain.Signer, len(ins)) for credIndex, transferInput := range ins { diff --git a/wallet/chain/p/wallet/backend_visitor.go b/wallet/chain/p/wallet/backend_visitor.go index 733eaa505259..fdd3c1a0e40a 100644 --- a/wallet/chain/p/wallet/backend_visitor.go +++ b/wallet/chain/p/wallet/backend_visitor.go @@ -127,6 +127,10 @@ func (b *backendVisitor) RegisterSubnetValidatorTx(tx *txs.RegisterSubnetValidat return b.baseTx(&tx.BaseTx) } +func (b *backendVisitor) SetSubnetValidatorWeightTx(tx *txs.SetSubnetValidatorWeightTx) error { + return b.baseTx(&tx.BaseTx) +} + func (b *backendVisitor) baseTx(tx *txs.BaseTx) error { return b.b.removeUTXOs( b.ctx, diff --git a/wallet/chain/p/wallet/wallet.go b/wallet/chain/p/wallet/wallet.go index fd6fd9a782e0..26ad5a4b5a97 100644 --- a/wallet/chain/p/wallet/wallet.go +++ b/wallet/chain/p/wallet/wallet.go @@ -166,6 +166,16 @@ type Wallet interface { options ...common.Option, ) (*txs.Tx, error) + // IssueSetSubnetValidatorWeightTx creates, signs, and issues a transaction + // that sets the weight of a validator on an L1. + // + // - [message] is the Warp message that authorizes this validator's weight + // to be changed + IssueSetSubnetValidatorWeightTx( + message []byte, + options ...common.Option, + ) (*txs.Tx, error) + // IssueImportTx creates, signs, and issues an import transaction that // attempts to consume all the available UTXOs and import the funds to [to]. // @@ -433,6 +443,17 @@ func (w *wallet) IssueRegisterSubnetValidatorTx( return w.IssueUnsignedTx(utx, options...) } +func (w *wallet) IssueSetSubnetValidatorWeightTx( + message []byte, + options ...common.Option, +) (*txs.Tx, error) { + utx, err := w.builder.NewSetSubnetValidatorWeightTx(message, options...) + if err != nil { + return nil, err + } + return w.IssueUnsignedTx(utx, options...) +} + func (w *wallet) IssueImportTx( sourceChainID ids.ID, to *secp256k1fx.OutputOwners, diff --git a/wallet/chain/p/wallet/with_options.go b/wallet/chain/p/wallet/with_options.go index c3e9527e9478..1b43e3832b15 100644 --- a/wallet/chain/p/wallet/with_options.go +++ b/wallet/chain/p/wallet/with_options.go @@ -174,6 +174,16 @@ func (w *withOptions) IssueRegisterSubnetValidatorTx( ) } +func (w *withOptions) IssueSetSubnetValidatorWeightTx( + message []byte, + options ...common.Option, +) (*txs.Tx, error) { + return w.wallet.IssueSetSubnetValidatorWeightTx( + message, + common.UnionOptions(w.options, options)..., + ) +} + func (w *withOptions) IssueImportTx( sourceChainID ids.ID, to *secp256k1fx.OutputOwners, diff --git a/wallet/subnet/primary/examples/set-subnet-validator-weight/main.go b/wallet/subnet/primary/examples/set-subnet-validator-weight/main.go new file mode 100644 index 000000000000..a796ae9b51e7 --- /dev/null +++ b/wallet/subnet/primary/examples/set-subnet-validator-weight/main.go @@ -0,0 +1,120 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "log" + "time" + + "github.com/ava-labs/avalanchego/genesis" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/payload" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/ava-labs/avalanchego/wallet/subnet/primary" +) + +func main() { + key := genesis.EWOQKey + uri := primary.LocalAPIURI + kc := secp256k1fx.NewKeychain(key) + chainID := ids.FromStringOrPanic("2BMFrJ9xeh5JdwZEx6uuFcjfZC2SV2hdbMT8ee5HrvjtfJb5br") + address := []byte{} + validationID := ids.FromStringOrPanic("2Y3ZZZXxpzm46geqVuqFXeSFVbeKihgrfeXRDaiF4ds6R2N8M5") + nonce := uint64(1) + weight := uint64(2) + blsSKHex := "3f783929b295f16cd1172396acb23b20eed057b9afb1caa419e9915f92860b35" + + blsSKBytes, err := hex.DecodeString(blsSKHex) + if err != nil { + log.Fatalf("failed to decode secret key: %s\n", err) + } + + sk, err := bls.SecretKeyFromBytes(blsSKBytes) + if err != nil { + log.Fatalf("failed to parse secret key: %s\n", err) + } + + // MakeWallet fetches the available UTXOs owned by [kc] on the network that + // [uri] is hosting. + walletSyncStartTime := time.Now() + ctx := context.Background() + wallet, err := primary.MakeWallet(ctx, &primary.WalletConfig{ + URI: uri, + AVAXKeychain: kc, + EthKeychain: kc, + }) + if err != nil { + log.Fatalf("failed to initialize wallet: %s\n", err) + } + log.Printf("synced wallet in %s\n", time.Since(walletSyncStartTime)) + + // Get the P-chain wallet + pWallet := wallet.P() + context := pWallet.Builder().Context() + + addressedCallPayload, err := message.NewSubnetValidatorWeight( + validationID, + nonce, + weight, + ) + if err != nil { + log.Fatalf("failed to create SubnetValidatorWeight message: %s\n", err) + } + addressedCallPayloadJSON, err := json.MarshalIndent(addressedCallPayload, "", "\t") + if err != nil { + log.Fatalf("failed to marshal SubnetValidatorWeight message: %s\n", err) + } + log.Println(string(addressedCallPayloadJSON)) + + addressedCall, err := payload.NewAddressedCall( + address, + addressedCallPayload.Bytes(), + ) + if err != nil { + log.Fatalf("failed to create AddressedCall message: %s\n", err) + } + + unsignedWarp, err := warp.NewUnsignedMessage( + context.NetworkID, + chainID, + addressedCall.Bytes(), + ) + if err != nil { + log.Fatalf("failed to create unsigned Warp message: %s\n", err) + } + + warp, err := warp.NewMessage( + unsignedWarp, + &warp.BitSetSignature{ + Signers: set.NewBits(0).Bytes(), + Signature: ([bls.SignatureLen]byte)( + bls.SignatureToBytes( + bls.Sign( + sk, + unsignedWarp.Bytes(), + ), + ), + ), + }, + ) + if err != nil { + log.Fatalf("failed to create Warp message: %s\n", err) + } + + setWeightStartTime := time.Now() + setWeightTx, err := pWallet.IssueSetSubnetValidatorWeightTx( + warp.Bytes(), + ) + if err != nil { + log.Fatalf("failed to issue set subnet validator weight transaction: %s\n", err) + } + log.Printf("issued set weight of validationID %s to %d with nonce %d and txID %s in %s\n", validationID, weight, nonce, setWeightTx.ID(), time.Since(setWeightStartTime)) +}