From c13c57681a0d92c860a7a6e356870265366f44f7 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 6 Nov 2024 09:22:17 -0500 Subject: [PATCH] ACP-77: Update `ConvertSubnetTx` (#3397) Signed-off-by: Joshua Kim <20001595+joshua-kim@users.noreply.github.com> Co-authored-by: Joshua Kim <20001595+joshua-kim@users.noreply.github.com> --- tests/e2e/p/l1.go | 86 ++++++ vms/platformvm/txs/convert_subnet_tx.go | 73 ++++- vms/platformvm/txs/convert_subnet_tx_test.go | 264 +++++++++++++++++- .../txs/convert_subnet_tx_test_complex.json | 23 ++ .../txs/convert_subnet_tx_test_simple.json | 1 + .../txs/executor/standard_tx_executor.go | 78 +++++- .../txs/executor/standard_tx_executor_test.go | 143 ++++++++-- vms/platformvm/txs/fee/calculator_test.go | 8 +- vms/platformvm/txs/fee/complexity.go | 70 ++++- vms/platformvm/txs/fee/complexity_test.go | 80 ++++++ wallet/chain/p/builder/builder.go | 28 +- .../chain/p/builder/builder_with_options.go | 2 + wallet/chain/p/builder_test.go | 46 ++- wallet/chain/p/wallet/wallet.go | 5 +- wallet/chain/p/wallet/with_options.go | 2 + .../primary/examples/convert-subnet/main.go | 106 +++++++ 16 files changed, 970 insertions(+), 45 deletions(-) create mode 100644 wallet/subnet/primary/examples/convert-subnet/main.go diff --git a/tests/e2e/p/l1.go b/tests/e2e/p/l1.go index 544f970f4f8f..e9c8ec89a6c3 100644 --- a/tests/e2e/p/l1.go +++ b/tests/e2e/p/l1.go @@ -11,13 +11,26 @@ import ( "github.com/stretchr/testify/require" "github.com/ava-labs/avalanchego/api/info" + "github.com/ava-labs/avalanchego/config" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/tests/fixture/e2e" + "github.com/ava-labs/avalanchego/tests/fixture/tmpnet" "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/crypto/bls" "github.com/ava-labs/avalanchego/utils/crypto/secp256k1" + "github.com/ava-labs/avalanchego/utils/units" "github.com/ava-labs/avalanchego/vms/example/xsvm/genesis" "github.com/ava-labs/avalanchego/vms/platformvm" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" "github.com/ava-labs/avalanchego/vms/secp256k1fx" + + snowvalidators "github.com/ava-labs/avalanchego/snow/validators" + warpmessage "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" +) + +const ( + genesisWeight = units.Schmeckle + genesisBalance = units.Avax ) var _ = e2e.DescribePChain("[L1]", func() { @@ -111,18 +124,80 @@ var _ = e2e.DescribePChain("[L1]", func() { chainID = chainTx.ID() }) + verifyValidatorSet := func(expectedValidators map[ids.NodeID]*snowvalidators.GetValidatorOutput) { + height, err := pClient.GetHeight(tc.DefaultContext()) + require.NoError(err) + + subnetValidators, err := pClient.GetValidatorsAt(tc.DefaultContext(), subnetID, height) + require.NoError(err) + require.Equal(expectedValidators, subnetValidators) + } + tc.By("verifying the Permissioned Subnet is configured as expected", func() { + tc.By("verifying the subnet reports as permissioned", func() { + subnet, err := pClient.GetSubnet(tc.DefaultContext(), subnetID) + require.NoError(err) + require.Equal( + platformvm.GetSubnetClientResponse{ + IsPermissioned: true, + ControlKeys: []ids.ShortID{ + keychain.Keys[0].Address(), + }, + Threshold: 1, + }, + subnet, + ) + }) + + tc.By("verifying the validator set is empty", func() { + verifyValidatorSet(map[ids.NodeID]*snowvalidators.GetValidatorOutput{}) + }) + }) + + tc.By("creating the genesis validator") + subnetGenesisNode := e2e.AddEphemeralNode(tc, env.GetNetwork(), tmpnet.FlagsMap{ + config.TrackSubnetsKey: subnetID.String(), + }) + + genesisNodePoP, err := subnetGenesisNode.GetProofOfPossession() + require.NoError(err) + + genesisNodePK, err := bls.PublicKeyFromCompressedBytes(genesisNodePoP.PublicKey[:]) + require.NoError(err) + address := []byte{} tc.By("issuing a ConvertSubnetTx", func() { _, err := pWallet.IssueConvertSubnetTx( subnetID, chainID, address, + []*txs.ConvertSubnetValidator{ + { + NodeID: subnetGenesisNode.NodeID.Bytes(), + Weight: genesisWeight, + Balance: genesisBalance, + Signer: *genesisNodePoP, + }, + }, tc.WithDefaultContext(), ) require.NoError(err) }) tc.By("verifying the Permissioned Subnet was converted to an L1", func() { + expectedConversionID, err := warpmessage.SubnetConversionID(warpmessage.SubnetConversionData{ + SubnetID: subnetID, + ManagerChainID: chainID, + ManagerAddress: address, + Validators: []warpmessage.SubnetConversionValidatorData{ + { + NodeID: subnetGenesisNode.NodeID.Bytes(), + BLSPublicKey: genesisNodePoP.PublicKey, + Weight: genesisWeight, + }, + }, + }) + require.NoError(err) + tc.By("verifying the subnet reports as being converted", func() { subnet, err := pClient.GetSubnet(tc.DefaultContext(), subnetID) require.NoError(err) @@ -133,12 +208,23 @@ var _ = e2e.DescribePChain("[L1]", func() { keychain.Keys[0].Address(), }, Threshold: 1, + ConversionID: expectedConversionID, ManagerChainID: chainID, ManagerAddress: address, }, subnet, ) }) + + tc.By("verifying the validator set was updated", func() { + verifyValidatorSet(map[ids.NodeID]*snowvalidators.GetValidatorOutput{ + subnetGenesisNode.NodeID: { + NodeID: subnetGenesisNode.NodeID, + PublicKey: genesisNodePK, + Weight: genesisWeight, + }, + }) + }) }) _ = e2e.CheckBootstrapIsPossible(tc, env.GetNetwork()) diff --git a/vms/platformvm/txs/convert_subnet_tx.go b/vms/platformvm/txs/convert_subnet_tx.go index 3fa4193faf3c..7f76c962a207 100644 --- a/vms/platformvm/txs/convert_subnet_tx.go +++ b/vms/platformvm/txs/convert_subnet_tx.go @@ -4,22 +4,31 @@ package txs import ( + "bytes" "errors" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/utils" "github.com/ava-labs/avalanchego/utils/constants" "github.com/ava-labs/avalanchego/vms/components/verify" + "github.com/ava-labs/avalanchego/vms/platformvm/signer" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" "github.com/ava-labs/avalanchego/vms/types" ) const MaxSubnetAddressLength = 4096 var ( - _ UnsignedTx = (*TransferSubnetOwnershipTx)(nil) + _ UnsignedTx = (*ConvertSubnetTx)(nil) + _ utils.Sortable[*ConvertSubnetValidator] = (*ConvertSubnetValidator)(nil) - ErrConvertPermissionlessSubnet = errors.New("cannot convert a permissionless subnet") - ErrAddressTooLong = errors.New("address is too long") + ErrConvertPermissionlessSubnet = errors.New("cannot convert a permissionless subnet") + ErrAddressTooLong = errors.New("address is too long") + ErrConvertMustIncludeValidators = errors.New("conversion must include at least one validator") + ErrConvertValidatorsNotSortedAndUnique = errors.New("conversion validators must be sorted and unique") + ErrZeroWeight = errors.New("validator weight must be non-zero") ) type ConvertSubnetTx struct { @@ -31,6 +40,8 @@ type ConvertSubnetTx struct { ChainID ids.ID `serialize:"true" json:"chainID"` // Address of the Subnet manager Address types.JSONByteSlice `serialize:"true" json:"address"` + // Initial pay-as-you-go validators for the Subnet + Validators []*ConvertSubnetValidator `serialize:"true" json:"validators"` // Authorizes this conversion SubnetAuth verify.Verifiable `serialize:"true" json:"subnetAuthorization"` } @@ -46,11 +57,20 @@ func (tx *ConvertSubnetTx) SyntacticVerify(ctx *snow.Context) error { return ErrConvertPermissionlessSubnet case len(tx.Address) > MaxSubnetAddressLength: return ErrAddressTooLong + case len(tx.Validators) == 0: + return ErrConvertMustIncludeValidators + case !utils.IsSortedAndUnique(tx.Validators): + return ErrConvertValidatorsNotSortedAndUnique } if err := tx.BaseTx.SyntacticVerify(ctx); err != nil { return err } + for _, vdr := range tx.Validators { + if err := vdr.Verify(); err != nil { + return err + } + } if err := tx.SubnetAuth.Verify(); err != nil { return err } @@ -62,3 +82,50 @@ func (tx *ConvertSubnetTx) SyntacticVerify(ctx *snow.Context) error { func (tx *ConvertSubnetTx) Visit(visitor Visitor) error { return visitor.ConvertSubnetTx(tx) } + +type ConvertSubnetValidator struct { + // NodeID of this validator + NodeID types.JSONByteSlice `serialize:"true" json:"nodeID"` + // Weight of this validator used when sampling + Weight uint64 `serialize:"true" json:"weight"` + // Initial balance for this validator + Balance uint64 `serialize:"true" json:"balance"` + // [Signer] is the BLS key for this validator. + // Note: We do not enforce that the BLS key is unique across all validators. + // This means that validators can share a key if they so choose. + // However, a NodeID + Subnet does uniquely map to a BLS key + Signer signer.ProofOfPossession `serialize:"true" json:"signer"` + // Leftover $AVAX from the [Balance] will be issued to this owner once it is + // removed from the validator set. + RemainingBalanceOwner message.PChainOwner `serialize:"true" json:"remainingBalanceOwner"` + // This owner has the authority to manually deactivate this validator. + DeactivationOwner message.PChainOwner `serialize:"true" json:"deactivationOwner"` +} + +func (v *ConvertSubnetValidator) Compare(o *ConvertSubnetValidator) int { + return bytes.Compare(v.NodeID, o.NodeID) +} + +func (v *ConvertSubnetValidator) Verify() error { + if v.Weight == 0 { + return ErrZeroWeight + } + nodeID, err := ids.ToNodeID(v.NodeID) + if err != nil { + return err + } + if nodeID == ids.EmptyNodeID { + return errEmptyNodeID + } + return verify.All( + &v.Signer, + &secp256k1fx.OutputOwners{ + Threshold: v.RemainingBalanceOwner.Threshold, + Addrs: v.RemainingBalanceOwner.Addresses, + }, + &secp256k1fx.OutputOwners{ + Threshold: v.DeactivationOwner.Threshold, + Addrs: v.DeactivationOwner.Addresses, + }, + ) +} diff --git a/vms/platformvm/txs/convert_subnet_tx_test.go b/vms/platformvm/txs/convert_subnet_tx_test.go index 42a392f0181e..d2e91acae0af 100644 --- a/vms/platformvm/txs/convert_subnet_tx_test.go +++ b/vms/platformvm/txs/convert_subnet_tx_test.go @@ -4,6 +4,7 @@ package txs import ( + "encoding/hex" "encoding/json" "strings" "testing" @@ -14,10 +15,15 @@ import ( "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/snow/snowtest" + "github.com/ava-labs/avalanchego/utils" "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/hashing" "github.com/ava-labs/avalanchego/utils/units" "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/platformvm/signer" "github.com/ava-labs/avalanchego/vms/platformvm/stakeable" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" "github.com/ava-labs/avalanchego/vms/secp256k1fx" "github.com/ava-labs/avalanchego/vms/types" ) @@ -30,6 +36,11 @@ var ( ) func TestConvertSubnetTxSerialization(t *testing.T) { + skBytes, err := hex.DecodeString("6668fecd4595b81e4d568398c820bbf3f073cb222902279fa55ebb84764ed2e3") + require.NoError(t, err) + sk, err := bls.SecretKeyFromBytes(skBytes) + require.NoError(t, err) + var ( addr = ids.ShortID{ 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, @@ -71,6 +82,11 @@ func TestConvertSubnetTxSerialization(t *testing.T) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xde, 0xad, } + nodeID = ids.BuildTestNodeID([]byte{ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x11, 0x22, 0x33, 0x44, + }) ) tests := []struct { @@ -107,9 +123,10 @@ func TestConvertSubnetTxSerialization(t *testing.T) { Memo: types.JSONByteSlice{}, }, }, - Subnet: subnetID, - ChainID: managerChainID, - Address: managerAddress, + Subnet: subnetID, + ChainID: managerChainID, + Address: managerAddress, + Validators: []*ConvertSubnetValidator{}, SubnetAuth: &secp256k1fx.Input{ SigIndices: []uint32{3}, }, @@ -169,6 +186,8 @@ func TestConvertSubnetTxSerialization(t *testing.T) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xde, 0xad, + // number of validators + 0x00, 0x00, 0x00, 0x00, // secp256k1fx authorization type ID 0x00, 0x00, 0x00, 0x0a, // number of signatures needed in authorization @@ -277,6 +296,26 @@ func TestConvertSubnetTxSerialization(t *testing.T) { Subnet: subnetID, ChainID: managerChainID, Address: managerAddress, + Validators: []*ConvertSubnetValidator{ + { + NodeID: nodeID[:], + Weight: 0x0102030405060708, + Balance: units.Avax, + Signer: *signer.NewProofOfPossession(sk), + RemainingBalanceOwner: message.PChainOwner{ + Threshold: 1, + Addresses: []ids.ShortID{ + addr, + }, + }, + DeactivationOwner: message.PChainOwner{ + Threshold: 1, + Addresses: []ids.ShortID{ + addr, + }, + }, + }, + }, SubnetAuth: &secp256k1fx.Input{ SigIndices: []uint32{}, }, @@ -430,6 +469,55 @@ func TestConvertSubnetTxSerialization(t *testing.T) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xde, 0xad, + // number of validators + 0x00, 0x00, 0x00, 0x01, + // Validators[0] + // node ID length + 0x00, 0x00, 0x00, 0x14, + // node ID + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x11, 0x22, 0x33, 0x44, + // weight + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + // balance + 0x00, 0x00, 0x00, 0x00, 0x3b, 0x9a, 0xca, 0x00, + // BLS compressed public key + 0xaf, 0xf4, 0xac, 0xb4, 0xc5, 0x43, 0x9b, 0x5d, + 0x42, 0x6c, 0xad, 0xf9, 0xe9, 0x46, 0xd3, 0xa4, + 0x52, 0xf7, 0xde, 0x34, 0x14, 0xd1, 0xad, 0x27, + 0x33, 0x61, 0x33, 0x21, 0x1d, 0x8b, 0x90, 0xcf, + 0x49, 0xfb, 0x97, 0xee, 0xbc, 0xde, 0xee, 0xf7, + 0x14, 0xdc, 0x20, 0xf5, 0x4e, 0xd0, 0xd4, 0xd1, + // BLS compressed signature + 0x8c, 0xfd, 0x79, 0x09, 0xd1, 0x53, 0xb9, 0x60, + 0x4b, 0x62, 0xb1, 0x43, 0xba, 0x36, 0x20, 0x7b, + 0xb7, 0xe6, 0x48, 0x67, 0x42, 0x44, 0x80, 0x20, + 0x2a, 0x67, 0xdc, 0x68, 0x76, 0x83, 0x46, 0xd9, + 0x5c, 0x90, 0x98, 0x3c, 0x2d, 0x27, 0x9c, 0x64, + 0xc4, 0x3c, 0x51, 0x13, 0x6b, 0x2a, 0x05, 0xe0, + 0x16, 0x02, 0xd5, 0x2a, 0xa6, 0x37, 0x6f, 0xda, + 0x17, 0xfa, 0x6e, 0x2a, 0x18, 0xa0, 0x83, 0xe4, + 0x9d, 0x9c, 0x45, 0x0e, 0xab, 0x7b, 0x89, 0xb1, + 0xd5, 0x55, 0x5d, 0xa5, 0xc4, 0x89, 0x87, 0x2e, + 0x02, 0xb7, 0xe5, 0x22, 0x7b, 0x77, 0x55, 0x0a, + 0xf1, 0x33, 0x0e, 0x5a, 0x71, 0xf8, 0xc3, 0x68, + // RemainingBalanceOwner threshold + 0x00, 0x00, 0x00, 0x01, + // RemainingBalanceOwner number of addresses + 0x00, 0x00, 0x00, 0x01, + // RemainingBalanceOwner Addrs[0] + 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0x44, 0x55, 0x66, 0x77, + // DeactivationOwner threshold + 0x00, 0x00, 0x00, 0x01, + // DeactivationOwner number of addresses + 0x00, 0x00, 0x00, 0x01, + // DeactivationOwner Addrs[0] + 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0x44, 0x55, 0x66, 0x77, // secp256k1fx authorization type ID 0x00, 0x00, 0x00, 0x0a, // number of signatures needed in authorization @@ -462,6 +550,9 @@ func TestConvertSubnetTxSerialization(t *testing.T) { } func TestConvertSubnetTxSyntacticVerify(t *testing.T) { + sk, err := bls.NewSecretKey() + require.NoError(t, err) + var ( ctx = snowtest.Context(t, ids.GenerateTestID()) validBaseTx = BaseTx{ @@ -470,8 +561,18 @@ func TestConvertSubnetTxSyntacticVerify(t *testing.T) { BlockchainID: ctx.ChainID, }, } - validSubnetID = ids.GenerateTestID() - invalidAddress = make(types.JSONByteSlice, MaxSubnetAddressLength+1) + validSubnetID = ids.GenerateTestID() + invalidAddress = make(types.JSONByteSlice, MaxSubnetAddressLength+1) + validValidators = []*ConvertSubnetValidator{ + { + NodeID: utils.RandomBytes(ids.NodeIDLen), + Weight: 1, + Balance: 1, + Signer: *signer.NewProofOfPossession(sk), + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{}, + }, + } validSubnetAuth = &secp256k1fx.Input{} invalidSubnetAuth = &secp256k1fx.Input{ SigIndices: []uint32{1, 0}, @@ -498,6 +599,7 @@ func TestConvertSubnetTxSyntacticVerify(t *testing.T) { }, Subnet: constants.PrimaryNetworkID, Address: invalidAddress, + Validators: nil, SubnetAuth: invalidSubnetAuth, }, expectedErr: nil, @@ -507,6 +609,7 @@ func TestConvertSubnetTxSyntacticVerify(t *testing.T) { tx: &ConvertSubnetTx{ BaseTx: validBaseTx, Subnet: constants.PrimaryNetworkID, + Validators: validValidators, SubnetAuth: validSubnetAuth, }, expectedErr: ErrConvertPermissionlessSubnet, @@ -517,15 +620,164 @@ func TestConvertSubnetTxSyntacticVerify(t *testing.T) { BaseTx: validBaseTx, Subnet: validSubnetID, Address: invalidAddress, + Validators: validValidators, SubnetAuth: validSubnetAuth, }, expectedErr: ErrAddressTooLong, }, + { + name: "invalid number of validators", + tx: &ConvertSubnetTx{ + BaseTx: validBaseTx, + Subnet: validSubnetID, + Validators: nil, + SubnetAuth: validSubnetAuth, + }, + expectedErr: ErrConvertMustIncludeValidators, + }, + { + name: "invalid validator order", + tx: &ConvertSubnetTx{ + BaseTx: validBaseTx, + Subnet: validSubnetID, + Validators: []*ConvertSubnetValidator{ + { + NodeID: []byte{ + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + }, + }, + { + NodeID: []byte{ + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + }, + }, + }, + SubnetAuth: validSubnetAuth, + }, + expectedErr: ErrConvertValidatorsNotSortedAndUnique, + }, + { + name: "invalid validator weight", + tx: &ConvertSubnetTx{ + BaseTx: validBaseTx, + Subnet: validSubnetID, + Validators: []*ConvertSubnetValidator{ + { + NodeID: utils.RandomBytes(ids.NodeIDLen), + Weight: 0, + Signer: *signer.NewProofOfPossession(sk), + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{}, + }, + }, + SubnetAuth: validSubnetAuth, + }, + expectedErr: ErrZeroWeight, + }, + { + name: "invalid validator nodeID length", + tx: &ConvertSubnetTx{ + BaseTx: validBaseTx, + Subnet: validSubnetID, + Validators: []*ConvertSubnetValidator{ + { + NodeID: utils.RandomBytes(ids.NodeIDLen + 1), + Weight: 1, + Signer: *signer.NewProofOfPossession(sk), + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{}, + }, + }, + SubnetAuth: validSubnetAuth, + }, + expectedErr: hashing.ErrInvalidHashLen, + }, + { + name: "invalid validator nodeID", + tx: &ConvertSubnetTx{ + BaseTx: validBaseTx, + Subnet: validSubnetID, + Validators: []*ConvertSubnetValidator{ + { + NodeID: ids.EmptyNodeID[:], + Weight: 1, + Signer: *signer.NewProofOfPossession(sk), + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{}, + }, + }, + SubnetAuth: validSubnetAuth, + }, + expectedErr: errEmptyNodeID, + }, + { + name: "invalid validator pop", + tx: &ConvertSubnetTx{ + BaseTx: validBaseTx, + Subnet: validSubnetID, + Validators: []*ConvertSubnetValidator{ + { + NodeID: utils.RandomBytes(ids.NodeIDLen), + Weight: 1, + Signer: signer.ProofOfPossession{}, + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{}, + }, + }, + SubnetAuth: validSubnetAuth, + }, + expectedErr: bls.ErrFailedPublicKeyDecompress, + }, + { + name: "invalid validator remaining balance owner", + tx: &ConvertSubnetTx{ + BaseTx: validBaseTx, + Subnet: validSubnetID, + Validators: []*ConvertSubnetValidator{ + { + NodeID: utils.RandomBytes(ids.NodeIDLen), + Weight: 1, + Signer: *signer.NewProofOfPossession(sk), + RemainingBalanceOwner: message.PChainOwner{ + Threshold: 1, + }, + DeactivationOwner: message.PChainOwner{}, + }, + }, + SubnetAuth: validSubnetAuth, + }, + expectedErr: secp256k1fx.ErrOutputUnspendable, + }, + { + name: "invalid validator deactivation owner", + tx: &ConvertSubnetTx{ + BaseTx: validBaseTx, + Subnet: validSubnetID, + Validators: []*ConvertSubnetValidator{ + { + NodeID: utils.RandomBytes(ids.NodeIDLen), + Weight: 1, + Signer: *signer.NewProofOfPossession(sk), + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{ + Threshold: 1, + }, + }, + }, + SubnetAuth: validSubnetAuth, + }, + expectedErr: secp256k1fx.ErrOutputUnspendable, + }, { name: "invalid BaseTx", tx: &ConvertSubnetTx{ BaseTx: BaseTx{}, Subnet: validSubnetID, + Validators: validValidators, SubnetAuth: validSubnetAuth, }, expectedErr: avax.ErrWrongNetworkID, @@ -535,6 +787,7 @@ func TestConvertSubnetTxSyntacticVerify(t *testing.T) { tx: &ConvertSubnetTx{ BaseTx: validBaseTx, Subnet: validSubnetID, + Validators: validValidators, SubnetAuth: invalidSubnetAuth, }, expectedErr: secp256k1fx.ErrInputIndicesNotSortedUnique, @@ -544,6 +797,7 @@ func TestConvertSubnetTxSyntacticVerify(t *testing.T) { tx: &ConvertSubnetTx{ BaseTx: validBaseTx, Subnet: validSubnetID, + Validators: validValidators, SubnetAuth: validSubnetAuth, }, expectedErr: nil, diff --git a/vms/platformvm/txs/convert_subnet_tx_test_complex.json b/vms/platformvm/txs/convert_subnet_tx_test_complex.json index b5178db2f9cb..926c475fec31 100644 --- a/vms/platformvm/txs/convert_subnet_tx_test_complex.json +++ b/vms/platformvm/txs/convert_subnet_tx_test_complex.json @@ -75,6 +75,29 @@ "subnetID": "SkB92YpWm4UpburLz9tEKZw2i67H3FF6YkjaU4BkFUDTG9Xm", "chainID": "NfebWJbJMmUpduqFCF8i1m5pstbVYLP1gGHbacrevXZMhpVMy", "address": "0x000000000000000000000000000000000000dead", + "validators": [ + { + "nodeID": "0x1122334455667788112233445566778811223344", + "weight": 72623859790382856, + "balance": 1000000000, + "signer": { + "publicKey": "0xaff4acb4c5439b5d426cadf9e946d3a452f7de3414d1ad27336133211d8b90cf49fb97eebcdeeef714dc20f54ed0d4d1", + "proofOfPossession": "0x8cfd7909d153b9604b62b143ba36207bb7e64867424480202a67dc68768346d95c90983c2d279c64c43c51136b2a05e01602d52aa6376fda17fa6e2a18a083e49d9c450eab7b89b1d5555da5c489872e02b7e5227b77550af1330e5a71f8c368" + }, + "remainingBalanceOwner": { + "threshold": 1, + "addresses": [ + "7EKFm18KvWqcxMCNgpBSN51pJnEr1cVUb" + ] + }, + "deactivationOwner": { + "threshold": 1, + "addresses": [ + "7EKFm18KvWqcxMCNgpBSN51pJnEr1cVUb" + ] + } + } + ], "subnetAuthorization": { "signatureIndices": [] } diff --git a/vms/platformvm/txs/convert_subnet_tx_test_simple.json b/vms/platformvm/txs/convert_subnet_tx_test_simple.json index 8463a748141f..5d6848a53f23 100644 --- a/vms/platformvm/txs/convert_subnet_tx_test_simple.json +++ b/vms/platformvm/txs/convert_subnet_tx_test_simple.json @@ -20,6 +20,7 @@ "subnetID": "SkB92YpWm4UpburLz9tEKZw2i67H3FF6YkjaU4BkFUDTG9Xm", "chainID": "NfebWJbJMmUpduqFCF8i1m5pstbVYLP1gGHbacrevXZMhpVMy", "address": "0x000000000000000000000000000000000000dead", + "validators": [], "subnetAuthorization": { "signatureIndices": [ 3 diff --git a/vms/platformvm/txs/executor/standard_tx_executor.go b/vms/platformvm/txs/executor/standard_tx_executor.go index 1c08880a94e0..d30049cc92c3 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor.go +++ b/vms/platformvm/txs/executor/standard_tx_executor.go @@ -14,12 +14,16 @@ import ( "github.com/ava-labs/avalanchego/chains/atomic" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/math" "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/components/gas" "github.com/ava-labs/avalanchego/vms/components/verify" "github.com/ava-labs/avalanchego/vms/platformvm/state" "github.com/ava-labs/avalanchego/vms/platformvm/txs" "github.com/ava-labs/avalanchego/vms/platformvm/txs/fee" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" ) var ( @@ -30,6 +34,7 @@ var ( errMissingStartTimePreDurango = errors.New("staker transactions must have a StartTime pre-Durango") errEtnaUpgradeNotActive = errors.New("attempting to use an Etna-upgrade feature prior to activation") errTransformSubnetTxPostEtna = errors.New("TransformSubnetTx is not permitted post-Etna") + errMaxNumActiveValidators = errors.New("already at the max number of active validators") ) type StandardTxExecutor struct { @@ -522,6 +527,71 @@ func (e *StandardTxExecutor) ConvertSubnetTx(tx *txs.ConvertSubnetTx) error { if err != nil { return err } + + var ( + startTime = uint64(currentTimestamp.Unix()) + currentFees = e.State.GetAccruedFees() + subnetConversionData = message.SubnetConversionData{ + SubnetID: tx.Subnet, + ManagerChainID: tx.ChainID, + ManagerAddress: tx.Address, + Validators: make([]message.SubnetConversionValidatorData, len(tx.Validators)), + } + ) + for i, vdr := range tx.Validators { + nodeID, err := ids.ToNodeID(vdr.NodeID) + if err != nil { + return err + } + + remainingBalanceOwner, err := txs.Codec.Marshal(txs.CodecVersion, &vdr.RemainingBalanceOwner) + if err != nil { + return err + } + deactivationOwner, err := txs.Codec.Marshal(txs.CodecVersion, &vdr.DeactivationOwner) + if err != nil { + return err + } + + sov := state.SubnetOnlyValidator{ + ValidationID: tx.Subnet.Append(uint32(i)), + SubnetID: tx.Subnet, + NodeID: nodeID, + PublicKey: bls.PublicKeyToUncompressedBytes(vdr.Signer.Key()), + RemainingBalanceOwner: remainingBalanceOwner, + DeactivationOwner: deactivationOwner, + StartTime: startTime, + Weight: vdr.Weight, + MinNonce: 0, + EndAccumulatedFee: 0, // If Balance is 0, this is 0 + } + if vdr.Balance != 0 { + // We are attempting to add an active validator + if gas.Gas(e.State.NumActiveSubnetOnlyValidators()) >= e.Backend.Config.ValidatorFeeConfig.Capacity { + return errMaxNumActiveValidators + } + + sov.EndAccumulatedFee, err = math.Add(vdr.Balance, currentFees) + if err != nil { + return err + } + + fee, err = math.Add(fee, vdr.Balance) + if err != nil { + return err + } + } + + if err := e.State.PutSubnetOnlyValidator(sov); err != nil { + return err + } + + subnetConversionData.Validators[i] = message.SubnetConversionValidatorData{ + NodeID: vdr.NodeID, + BLSPublicKey: vdr.Signer.PublicKey, + Weight: vdr.Weight, + } + } if err := e.Backend.FlowChecker.VerifySpend( tx, e.State, @@ -535,6 +605,11 @@ func (e *StandardTxExecutor) ConvertSubnetTx(tx *txs.ConvertSubnetTx) error { return err } + conversionID, err := message.SubnetConversionID(subnetConversionData) + if err != nil { + return err + } + txID := e.Tx.ID() // Consume the UTXOS @@ -545,8 +620,7 @@ func (e *StandardTxExecutor) ConvertSubnetTx(tx *txs.ConvertSubnetTx) error { e.State.SetSubnetConversion( tx.Subnet, state.SubnetConversion{ - // TODO: Populate the conversionID - ConversionID: ids.Empty, + ConversionID: conversionID, ChainID: tx.ChainID, Addr: tx.Address, }, diff --git a/vms/platformvm/txs/executor/standard_tx_executor_test.go b/vms/platformvm/txs/executor/standard_tx_executor_test.go index 43a603ac66e1..1080013ed776 100644 --- a/vms/platformvm/txs/executor/standard_tx_executor_test.go +++ b/vms/platformvm/txs/executor/standard_tx_executor_test.go @@ -37,12 +37,15 @@ import ( "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/platformvm/txs/fee" "github.com/ava-labs/avalanchego/vms/platformvm/txs/txstest" "github.com/ava-labs/avalanchego/vms/platformvm/utxo" "github.com/ava-labs/avalanchego/vms/platformvm/utxo/utxomock" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" "github.com/ava-labs/avalanchego/vms/secp256k1fx" "github.com/ava-labs/avalanchego/wallet/subnet/primary/common" + + txfee "github.com/ava-labs/avalanchego/vms/platformvm/txs/fee" + validatorfee "github.com/ava-labs/avalanchego/vms/platformvm/validators/fee" ) // This tests that the math performed during TransformSubnetTx execution can @@ -2384,8 +2387,9 @@ func TestStandardExecutorConvertSubnetTx(t *testing.T) { var ( ctx = snowtest.Context(t, constants.PlatformChainID) defaultConfig = &config.Config{ - DynamicFeeConfig: genesis.LocalParams.DynamicFeeConfig, - UpgradeConfig: upgradetest.GetConfig(upgradetest.Latest), + DynamicFeeConfig: genesis.LocalParams.DynamicFeeConfig, + ValidatorFeeConfig: genesis.LocalParams.ValidatorFeeConfig, + UpgradeConfig: upgradetest.GetConfig(upgradetest.Latest), } baseState = statetest.New(t, statetest.Config{ Upgrades: defaultConfig.UpgradeConfig, @@ -2430,26 +2434,31 @@ func TestStandardExecutorConvertSubnetTx(t *testing.T) { require.NoError(t, diff.Apply(baseState)) require.NoError(t, baseState.Commit()) - subnetID := createSubnetTx.ID() + var ( + subnetID = createSubnetTx.ID() + nodeID = ids.GenerateTestNodeID() + ) tests := []struct { name string builderOptions []common.Option - updateExecutor func(executor *StandardTxExecutor) + updateExecutor func(executor *StandardTxExecutor) error expectedErr error }{ { name: "invalid prior to E-Upgrade", - updateExecutor: func(e *StandardTxExecutor) { + updateExecutor: func(e *StandardTxExecutor) error { e.Backend.Config = &config.Config{ UpgradeConfig: upgradetest.GetConfig(upgradetest.Durango), } + return nil }, expectedErr: errEtnaUpgradeNotActive, }, { name: "tx fails syntactic verification", - updateExecutor: func(e *StandardTxExecutor) { + updateExecutor: func(e *StandardTxExecutor) error { e.Backend.Ctx = snowtest.Context(t, ids.GenerateTestID()) + return nil }, expectedErr: avax.ErrWrongChainID, }, @@ -2462,28 +2471,30 @@ func TestStandardExecutorConvertSubnetTx(t *testing.T) { }, { name: "fail subnet authorization", - updateExecutor: func(e *StandardTxExecutor) { + updateExecutor: func(e *StandardTxExecutor) error { e.State.SetSubnetOwner(subnetID, &secp256k1fx.OutputOwners{ Threshold: 1, Addrs: []ids.ShortID{ ids.GenerateTestShortID(), }, }) + return nil }, expectedErr: errUnauthorizedSubnetModification, }, { name: "invalid if subnet is transformed", - updateExecutor: func(e *StandardTxExecutor) { + updateExecutor: func(e *StandardTxExecutor) error { e.State.AddSubnetTransformation(&txs.Tx{Unsigned: &txs.TransformSubnetTx{ Subnet: subnetID, }}) + return nil }, expectedErr: errIsImmutable, }, { name: "invalid if subnet is converted", - updateExecutor: func(e *StandardTxExecutor) { + updateExecutor: func(e *StandardTxExecutor) error { e.State.SetSubnetConversion( subnetID, state.SubnetConversion{ @@ -2492,23 +2503,55 @@ func TestStandardExecutorConvertSubnetTx(t *testing.T) { Addr: utils.RandomBytes(32), }, ) + return nil }, expectedErr: errIsImmutable, }, { name: "invalid fee calculation", - updateExecutor: func(e *StandardTxExecutor) { - e.FeeCalculator = fee.NewStaticCalculator(e.Config.StaticFeeConfig) + updateExecutor: func(e *StandardTxExecutor) error { + e.FeeCalculator = txfee.NewStaticCalculator(e.Config.StaticFeeConfig) + return nil + }, + expectedErr: txfee.ErrUnsupportedTx, + }, + { + name: "too many active validators", + updateExecutor: func(e *StandardTxExecutor) error { + e.Backend.Config = &config.Config{ + DynamicFeeConfig: genesis.LocalParams.DynamicFeeConfig, + ValidatorFeeConfig: validatorfee.Config{ + Capacity: 0, + Target: genesis.LocalParams.ValidatorFeeConfig.Target, + MinPrice: genesis.LocalParams.ValidatorFeeConfig.MinPrice, + ExcessConversionConstant: genesis.LocalParams.ValidatorFeeConfig.ExcessConversionConstant, + }, + UpgradeConfig: upgradetest.GetConfig(upgradetest.Latest), + } + return nil }, - expectedErr: fee.ErrUnsupportedTx, + expectedErr: errMaxNumActiveValidators, + }, + { + name: "invalid subnet only validator", + updateExecutor: func(e *StandardTxExecutor) error { + return e.State.PutSubnetOnlyValidator(state.SubnetOnlyValidator{ + ValidationID: ids.GenerateTestID(), + SubnetID: subnetID, + NodeID: nodeID, + Weight: 1, + }) + }, + expectedErr: state.ErrDuplicateSubnetOnlyValidator, }, { name: "insufficient fee", - updateExecutor: func(e *StandardTxExecutor) { - e.FeeCalculator = fee.NewDynamicCalculator( + updateExecutor: func(e *StandardTxExecutor) error { + e.FeeCalculator = txfee.NewDynamicCalculator( e.Config.DynamicFeeConfig.Weights, 100*genesis.LocalParams.DynamicFeeConfig.MinPrice, ) + return nil }, expectedErr: utxo.ErrInsufficientUnlockedFunds, }, @@ -2520,7 +2563,14 @@ func TestStandardExecutorConvertSubnetTx(t *testing.T) { t.Run(test.name, func(t *testing.T) { require := require.New(t) + sk, err := bls.NewSecretKey() + require.NoError(err) + // Create the ConvertSubnetTx + const ( + weight = 1 + balance = 1 + ) var ( wallet = txstest.NewWallet( t, @@ -2531,13 +2581,25 @@ func TestStandardExecutorConvertSubnetTx(t *testing.T) { []ids.ID{subnetID}, nil, // chainIDs ) - chainID = ids.GenerateTestID() - address = utils.RandomBytes(32) + chainID = ids.GenerateTestID() + address = utils.RandomBytes(32) + pop = signer.NewProofOfPossession(sk) + validator = &txs.ConvertSubnetValidator{ + NodeID: nodeID.Bytes(), + Weight: weight, + Balance: balance, + Signer: *pop, + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{}, + } ) convertSubnetTx, err := wallet.IssueConvertSubnetTx( subnetID, chainID, address, + []*txs.ConvertSubnetValidator{ + validator, + }, test.builderOptions..., ) require.NoError(err) @@ -2558,7 +2620,7 @@ func TestStandardExecutorConvertSubnetTx(t *testing.T) { State: diff, } if test.updateExecutor != nil { - test.updateExecutor(executor) + require.NoError(test.updateExecutor(executor)) } err = convertSubnetTx.Unsigned.Visit(executor) @@ -2579,17 +2641,58 @@ func TestStandardExecutorConvertSubnetTx(t *testing.T) { require.Equal(expectedUTXO, utxo) } + expectedConversionID, err := message.SubnetConversionID(message.SubnetConversionData{ + SubnetID: subnetID, + ManagerChainID: chainID, + ManagerAddress: address, + Validators: []message.SubnetConversionValidatorData{ + { + NodeID: nodeID.Bytes(), + BLSPublicKey: pop.PublicKey, + Weight: weight, + }, + }, + }) + require.NoError(err) + stateConversion, err := diff.GetSubnetConversion(subnetID) require.NoError(err) require.Equal( state.SubnetConversion{ - // TODO: Specify the correct conversionID - ConversionID: ids.Empty, + ConversionID: expectedConversionID, ChainID: chainID, Addr: address, }, stateConversion, ) + + var ( + validationID = subnetID.Append(0) + pkBytes = bls.PublicKeyToUncompressedBytes(bls.PublicFromSecretKey(sk)) + ) + remainingBalanceOwner, err := txs.Codec.Marshal(txs.CodecVersion, &validator.RemainingBalanceOwner) + require.NoError(err) + + deactivationOwner, err := txs.Codec.Marshal(txs.CodecVersion, &validator.DeactivationOwner) + require.NoError(err) + + sov, err := diff.GetSubnetOnlyValidator(validationID) + require.NoError(err) + require.Equal( + state.SubnetOnlyValidator{ + ValidationID: validationID, + SubnetID: subnetID, + NodeID: nodeID, + PublicKey: pkBytes, + RemainingBalanceOwner: remainingBalanceOwner, + DeactivationOwner: deactivationOwner, + StartTime: uint64(diff.GetTimestamp().Unix()), + Weight: validator.Weight, + MinNonce: 0, + EndAccumulatedFee: validator.Balance + diff.GetAccruedFees(), + }, + sov, + ) }) } } diff --git a/vms/platformvm/txs/fee/calculator_test.go b/vms/platformvm/txs/fee/calculator_test.go index 172f927e9796..5039dc557345 100644 --- a/vms/platformvm/txs/fee/calculator_test.go +++ b/vms/platformvm/txs/fee/calculator_test.go @@ -221,15 +221,15 @@ var ( }, { name: "ConvertSubnetTx", - tx: "00000000002300015b380000000000000000000000000000000000000000000000000000000000000000000000012a16b813b6a4a64d8e9b3f11460b782fcc319364bc038915af56834b72043ce80000000700470de4d97cdcc00000000000000000000000010000000180fa21568b6a2ef338a773ba18bfc0cb493af926000000018d65db2676f4733a7d263ad14606ddbc2f1996bb2998358f4b6f1e01297d1da5000000002a16b813b6a4a64d8e9b3f11460b782fcc319364bc038915af56834b72043ce80000000500470de4d98c1f000000000100000000000000008d65db2676f4733a7d263ad14606ddbc2f1996bb2998358f4b6f1e01297d1da55fa29ed4356903dac2364713c60f57d8472c7dda4a5e08d88a88ad8ea71aed6000000007616464726573730000000a0000000100000000000000020000000900000001c990ecf3f39646c4c90cb1f5cc2a9a98c33df1a9a41a084e7f3e7b2afe10fd853068a20ad4ddf83c087b6311ab0fdab339ca529f57cda3329ca31b142987c223000000000900000001c990ecf3f39646c4c90cb1f5cc2a9a98c33df1a9a41a084e7f3e7b2afe10fd853068a20ad4ddf83c087b6311ab0fdab339ca529f57cda3329ca31b142987c22300", + tx: "00000000002300003039000000000000000000000000000000000000000000000000000000000000000000000001dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000007002386f234262960000000000000000000000001000000013cb7d3842e8cee6a0ebd09f1fe884f6861e1b29c00000001705f3d4415f990225d3df5ce437d7af2aa324b1bbce854ee34ab6f39882250d200000000dbcf890f77f49b96857648b72b77f9f82937f28a68704af05da0dc12ba53f2db00000005002386f26fc0f94e000000010000000000000000a0673b4ee5ec44e57c8ab250dd7cd7b68d04421f64bd6559a4284a3ee358ff2b705f3d4415f990225d3df5ce437d7af2aa324b1bbce854ee34ab6f39882250d2000000000000000100000014c582872c37c81efa2c94ea347af49cdc23a830aa000000000000c137000000003b9aca00a3783a891cb41cadbfcf456da149f30e7af972677a162b984bef0779f254baac51ec042df1781d1295df80fb41c801269731fc6c25e1e5940dc3cb8509e30348fa712742cfdc83678acc9f95908eb98b89b28802fb559b4a2a6ff3216707c07f0ceb0b45a95f4f9a9540bbd3331d8ab4f233bffa4abb97fad9d59a1695f31b92a2b89e365facf7ab8c30de7c4a496d1e000000000000000000000000000000000000000a00000001000000000000000200000009000000011430759900fdf516cdeff6a1390dd7438585568a89c06142c44b3bf1178c4cae4bff44e955b19da08f0359d396a7a738b989bb46377e7465cd858ddd1e8dd3790100000009000000011430759900fdf516cdeff6a1390dd7438585568a89c06142c44b3bf1178c4cae4bff44e955b19da08f0359d396a7a738b989bb46377e7465cd858ddd1e8dd37901", expectedStaticFeeErr: ErrUnsupportedTx, expectedComplexity: gas.Dimensions{ - gas.Bandwidth: 459, // The length of the tx in bytes + gas.Bandwidth: 656, // The length of the tx in bytes gas.DBRead: IntrinsicConvertSubnetTxComplexities[gas.DBRead] + intrinsicInputDBRead, - gas.DBWrite: IntrinsicConvertSubnetTxComplexities[gas.DBWrite] + intrinsicInputDBWrite + intrinsicOutputDBWrite, + gas.DBWrite: IntrinsicConvertSubnetTxComplexities[gas.DBWrite] + intrinsicInputDBWrite + intrinsicOutputDBWrite + intrinsicConvertSubnetValidatorDBWrite, gas.Compute: 0, // TODO: implement }, - expectedDynamicFee: 175_900, + expectedDynamicFee: 365_600, }, } ) diff --git a/vms/platformvm/txs/fee/complexity.go b/vms/platformvm/txs/fee/complexity.go index 02942e09b76f..5683ac6b3b6a 100644 --- a/vms/platformvm/txs/fee/complexity.go +++ b/vms/platformvm/txs/fee/complexity.go @@ -62,13 +62,22 @@ const ( intrinsicSECP256k1FxSignatureBandwidth = wrappers.IntLen + // signature index secp256k1.SignatureLen // signature length + intrinsicConvertSubnetValidatorBandwidth = wrappers.IntLen + // nodeID length + wrappers.LongLen + // weight + wrappers.LongLen + // balance + wrappers.IntLen + // remaining balance owner threshold + wrappers.IntLen + // remaining balance owner num addresses + wrappers.IntLen + // deactivation owner threshold + wrappers.IntLen // deactivation owner num addresses + intrinsicPoPBandwidth = bls.PublicKeyLen + // public key bls.SignatureLen // signature intrinsicInputDBRead = 1 - intrinsicInputDBWrite = 1 - intrinsicOutputDBWrite = 1 + intrinsicInputDBWrite = 1 + intrinsicOutputDBWrite = 1 + intrinsicConvertSubnetValidatorDBWrite = 4 // weight diff + pub key diff + subnetID/nodeID + validationID ) var ( @@ -180,10 +189,11 @@ var ( ids.IDLen + // subnetID ids.IDLen + // chainID wrappers.IntLen + // address length + wrappers.IntLen + // validators length wrappers.IntLen + // subnetAuth typeID wrappers.IntLen, // subnetAuthCredential typeID - gas.DBRead: 1, - gas.DBWrite: 1, + gas.DBRead: 2, // subnet auth + manager lookup + gas.DBWrite: 2, // manager + weight gas.Compute: 0, } @@ -305,6 +315,53 @@ func inputComplexity(in *avax.TransferableInput) (gas.Dimensions, error) { return complexity, err } +// ConvertSubnetValidatorComplexity returns the complexity the validators add to +// a transaction. +func ConvertSubnetValidatorComplexity(sovs ...*txs.ConvertSubnetValidator) (gas.Dimensions, error) { + var complexity gas.Dimensions + for _, sov := range sovs { + sovComplexity, err := convertSubnetValidatorComplexity(sov) + if err != nil { + return gas.Dimensions{}, err + } + + complexity, err = complexity.Add(&sovComplexity) + if err != nil { + return gas.Dimensions{}, err + } + } + return complexity, nil +} + +func convertSubnetValidatorComplexity(sov *txs.ConvertSubnetValidator) (gas.Dimensions, error) { + complexity := gas.Dimensions{ + gas.Bandwidth: intrinsicConvertSubnetValidatorBandwidth, + gas.DBRead: 0, + gas.DBWrite: intrinsicConvertSubnetValidatorDBWrite, + gas.Compute: 0, // TODO: Add compute complexity + } + + signerComplexity, err := SignerComplexity(&sov.Signer) + if err != nil { + return gas.Dimensions{}, err + } + + numAddresses := uint64(len(sov.RemainingBalanceOwner.Addresses) + len(sov.DeactivationOwner.Addresses)) + addressBandwidth, err := math.Mul(numAddresses, ids.ShortIDLen) + if err != nil { + return gas.Dimensions{}, err + } + return complexity.Add( + &gas.Dimensions{ + gas.Bandwidth: uint64(len(sov.NodeID)), + }, + &signerComplexity, + &gas.Dimensions{ + gas.Bandwidth: addressBandwidth, + }, + ) +} + // OwnerComplexity returns the complexity an owner adds to a transaction. // It does not include the typeID of the owner. func OwnerComplexity(ownerIntf fx.Owner) (gas.Dimensions, error) { @@ -610,12 +667,17 @@ func (c *complexityVisitor) ConvertSubnetTx(tx *txs.ConvertSubnetTx) error { if err != nil { return err } + validatorComplexity, err := ConvertSubnetValidatorComplexity(tx.Validators...) + if err != nil { + return err + } authComplexity, err := AuthComplexity(tx.SubnetAuth) if err != nil { return err } c.output, err = IntrinsicConvertSubnetTxComplexities.Add( &baseTxComplexity, + &validatorComplexity, &authComplexity, &gas.Dimensions{ gas.Bandwidth: uint64(len(tx.Address)), diff --git a/vms/platformvm/txs/fee/complexity_test.go b/vms/platformvm/txs/fee/complexity_test.go index 0dd9ba90b3f0..d0767317df3b 100644 --- a/vms/platformvm/txs/fee/complexity_test.go +++ b/vms/platformvm/txs/fee/complexity_test.go @@ -20,6 +20,7 @@ import ( "github.com/ava-labs/avalanchego/vms/platformvm/signer" "github.com/ava-labs/avalanchego/vms/platformvm/stakeable" "github.com/ava-labs/avalanchego/vms/platformvm/txs" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" "github.com/ava-labs/avalanchego/vms/secp256k1fx" ) @@ -367,6 +368,85 @@ func TestInputComplexity(t *testing.T) { } } +func TestConvertSubnetValidatorComplexity(t *testing.T) { + tests := []struct { + name string + vdr txs.ConvertSubnetValidator + expected gas.Dimensions + }{ + { + name: "any can spend", + vdr: txs.ConvertSubnetValidator{ + NodeID: make([]byte, ids.NodeIDLen), + Signer: signer.ProofOfPossession{}, + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{}, + }, + expected: gas.Dimensions{ + gas.Bandwidth: 200, + gas.DBRead: 0, + gas.DBWrite: 4, + gas.Compute: 0, // TODO: implement + }, + }, + { + name: "single remaining balance owner", + vdr: txs.ConvertSubnetValidator{ + NodeID: make([]byte, ids.NodeIDLen), + Signer: signer.ProofOfPossession{}, + RemainingBalanceOwner: message.PChainOwner{ + Threshold: 1, + Addresses: []ids.ShortID{ + ids.GenerateTestShortID(), + }, + }, + DeactivationOwner: message.PChainOwner{}, + }, + expected: gas.Dimensions{ + gas.Bandwidth: 220, + gas.DBRead: 0, + gas.DBWrite: 4, + gas.Compute: 0, // TODO: implement + }, + }, + { + name: "single deactivation owner", + vdr: txs.ConvertSubnetValidator{ + NodeID: make([]byte, ids.NodeIDLen), + Signer: signer.ProofOfPossession{}, + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{ + Threshold: 1, + Addresses: []ids.ShortID{ + ids.GenerateTestShortID(), + }, + }, + }, + expected: gas.Dimensions{ + gas.Bandwidth: 220, + gas.DBRead: 0, + gas.DBWrite: 4, + gas.Compute: 0, // TODO: implement + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + + actual, err := ConvertSubnetValidatorComplexity(&test.vdr) + require.NoError(err) + require.Equal(test.expected, actual) + + vdrBytes, err := txs.Codec.Marshal(txs.CodecVersion, test.vdr) + require.NoError(err) + + numBytesWithoutCodecVersion := uint64(len(vdrBytes) - codec.VersionSize) + require.Equal(numBytesWithoutCodecVersion, actual[gas.Bandwidth]) + }) + } +} + func TestOwnerComplexity(t *testing.T) { tests := []struct { name string diff --git a/wallet/chain/p/builder/builder.go b/wallet/chain/p/builder/builder.go index 5a0b063280fd..e8762d7b2a68 100644 --- a/wallet/chain/p/builder/builder.go +++ b/wallet/chain/p/builder/builder.go @@ -155,10 +155,12 @@ type Builder interface { // - [subnetID] specifies the subnet to be converted // - [chainID] specifies which chain the manager is deployed on // - [address] specifies the address of the manager + // - [validators] specifies the initial SoVs of the L1 NewConvertSubnetTx( subnetID ids.ID, chainID ids.ID, address []byte, + validators []*txs.ConvertSubnetValidator, options ...common.Option, ) (*txs.ConvertSubnetTx, error) @@ -782,12 +784,25 @@ func (b *builder) NewConvertSubnetTx( subnetID ids.ID, chainID ids.ID, address []byte, + validators []*txs.ConvertSubnetValidator, options ...common.Option, ) (*txs.ConvertSubnetTx, error) { - toBurn := map[ids.ID]uint64{} - toStake := map[ids.ID]uint64{} + var avaxToBurn uint64 + for _, vdr := range validators { + var err error + avaxToBurn, err = math.Add(avaxToBurn, vdr.Balance) + if err != nil { + return nil, err + } + } - ops := common.NewOptions(options) + var ( + toBurn = map[ids.ID]uint64{ + b.context.AVAXAssetID: avaxToBurn, + } + toStake = map[ids.ID]uint64{} + ops = common.NewOptions(options) + ) subnetAuth, err := b.authorizeSubnet(subnetID, ops) if err != nil { return nil, err @@ -801,12 +816,17 @@ func (b *builder) NewConvertSubnetTx( bytesComplexity := gas.Dimensions{ gas.Bandwidth: additionalBytes, } + validatorComplexity, err := fee.ConvertSubnetValidatorComplexity(validators...) + if err != nil { + return nil, err + } authComplexity, err := fee.AuthComplexity(subnetAuth) if err != nil { return nil, err } complexity, err := fee.IntrinsicConvertSubnetTxComplexities.Add( &bytesComplexity, + &validatorComplexity, &authComplexity, ) if err != nil { @@ -825,6 +845,7 @@ func (b *builder) NewConvertSubnetTx( return nil, err } + utils.Sort(validators) tx := &txs.ConvertSubnetTx{ BaseTx: txs.BaseTx{BaseTx: avax.BaseTx{ NetworkID: b.context.NetworkID, @@ -836,6 +857,7 @@ func (b *builder) NewConvertSubnetTx( Subnet: subnetID, ChainID: chainID, Address: address, + Validators: validators, SubnetAuth: subnetAuth, } return tx, b.initCtx(tx) diff --git a/wallet/chain/p/builder/builder_with_options.go b/wallet/chain/p/builder/builder_with_options.go index 4a473cf50bac..8432944e3542 100644 --- a/wallet/chain/p/builder/builder_with_options.go +++ b/wallet/chain/p/builder/builder_with_options.go @@ -159,12 +159,14 @@ func (b *builderWithOptions) NewConvertSubnetTx( subnetID ids.ID, chainID ids.ID, address []byte, + validators []*txs.ConvertSubnetValidator, options ...common.Option, ) (*txs.ConvertSubnetTx, error) { return b.builder.NewConvertSubnetTx( subnetID, chainID, address, + validators, common.UnionOptions(b.options, options)..., ) } diff --git a/wallet/chain/p/builder_test.go b/wallet/chain/p/builder_test.go index 26ebfac36238..f56a0eb0896b 100644 --- a/wallet/chain/p/builder_test.go +++ b/wallet/chain/p/builder_test.go @@ -4,6 +4,7 @@ package p import ( + "math/rand" "slices" "testing" "time" @@ -25,6 +26,7 @@ import ( "github.com/ava-labs/avalanchego/vms/platformvm/stakeable" "github.com/ava-labs/avalanchego/vms/platformvm/txs" "github.com/ava-labs/avalanchego/vms/platformvm/txs/fee" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" "github.com/ava-labs/avalanchego/vms/secp256k1fx" "github.com/ava-labs/avalanchego/vms/types" "github.com/ava-labs/avalanchego/wallet/chain/p/builder" @@ -668,9 +670,42 @@ func TestAddPermissionlessDelegatorTx(t *testing.T) { } func TestConvertSubnetTx(t *testing.T) { + sk0, err := bls.NewSecretKey() + require.NoError(t, err) + sk1, err := bls.NewSecretKey() + require.NoError(t, err) + var ( - chainID = ids.GenerateTestID() - address = utils.RandomBytes(32) + chainID = ids.GenerateTestID() + address = utils.RandomBytes(32) + validators = []*txs.ConvertSubnetValidator{ + { + NodeID: utils.RandomBytes(ids.NodeIDLen), + Weight: rand.Uint64(), //#nosec G404 + Balance: units.Avax, + Signer: *signer.NewProofOfPossession(sk0), + RemainingBalanceOwner: message.PChainOwner{ + Threshold: 1, + Addresses: []ids.ShortID{ + ids.GenerateTestShortID(), + }, + }, + DeactivationOwner: message.PChainOwner{ + Threshold: 1, + Addresses: []ids.ShortID{ + ids.GenerateTestShortID(), + }, + }, + }, + { + NodeID: utils.RandomBytes(ids.NodeIDLen), + Weight: rand.Uint64(), //#nosec G404 + Balance: 2 * units.Avax, + Signer: *signer.NewProofOfPossession(sk1), + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{}, + }, + } ) for _, e := range testEnvironmentPostEtna { t.Run(e.name, func(t *testing.T) { @@ -687,6 +722,7 @@ func TestConvertSubnetTx(t *testing.T) { subnetID, chainID, address, + validators, common.WithMemo(e.memo), ) require.NoError(err) @@ -694,6 +730,8 @@ func TestConvertSubnetTx(t *testing.T) { require.Equal(chainID, utx.ChainID) require.Equal(types.JSONByteSlice(address), utx.Address) require.Equal(types.JSONByteSlice(e.memo), utx.Memo) + require.True(utils.IsSortedAndUnique(utx.Validators)) + require.Equal(validators, utx.Validators) requireFeeIsCorrect( require, e.feeCalculator, @@ -701,7 +739,9 @@ func TestConvertSubnetTx(t *testing.T) { &utx.BaseTx.BaseTx, nil, nil, - nil, + map[ids.ID]uint64{ + e.context.AVAXAssetID: 3 * units.Avax, // Balance of the validators + }, ) }) } diff --git a/wallet/chain/p/wallet/wallet.go b/wallet/chain/p/wallet/wallet.go index 4e2886d41e88..a36522cc6b3a 100644 --- a/wallet/chain/p/wallet/wallet.go +++ b/wallet/chain/p/wallet/wallet.go @@ -141,10 +141,12 @@ type Wallet interface { // - [subnetID] specifies the subnet to be converted // - [chainID] specifies which chain the manager is deployed on // - [address] specifies the address of the manager + // - [validators] specifies the initial SoVs of the L1 IssueConvertSubnetTx( subnetID ids.ID, chainID ids.ID, address []byte, + validators []*txs.ConvertSubnetValidator, options ...common.Option, ) (*txs.Tx, error) @@ -392,9 +394,10 @@ func (w *wallet) IssueConvertSubnetTx( subnetID ids.ID, chainID ids.ID, address []byte, + validators []*txs.ConvertSubnetValidator, options ...common.Option, ) (*txs.Tx, error) { - utx, err := w.builder.NewConvertSubnetTx(subnetID, chainID, address, options...) + utx, err := w.builder.NewConvertSubnetTx(subnetID, chainID, address, validators, options...) if err != nil { return nil, err } diff --git a/wallet/chain/p/wallet/with_options.go b/wallet/chain/p/wallet/with_options.go index 7082764aa18d..ae6b4b4d8fa9 100644 --- a/wallet/chain/p/wallet/with_options.go +++ b/wallet/chain/p/wallet/with_options.go @@ -147,12 +147,14 @@ func (w *withOptions) IssueConvertSubnetTx( subnetID ids.ID, chainID ids.ID, address []byte, + validators []*txs.ConvertSubnetValidator, options ...common.Option, ) (*txs.Tx, error) { return w.wallet.IssueConvertSubnetTx( subnetID, chainID, address, + validators, common.UnionOptions(w.options, options)..., ) } diff --git a/wallet/subnet/primary/examples/convert-subnet/main.go b/wallet/subnet/primary/examples/convert-subnet/main.go new file mode 100644 index 000000000000..8602c26ffb64 --- /dev/null +++ b/wallet/subnet/primary/examples/convert-subnet/main.go @@ -0,0 +1,106 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package main + +import ( + "context" + "encoding/hex" + "log" + "time" + + "github.com/ava-labs/avalanchego/api/info" + "github.com/ava-labs/avalanchego/genesis" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/units" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" + "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) + subnetID := ids.FromStringOrPanic("2DeHa7Qb6sufPkmQcFWG2uCd4pBPv9WB6dkzroiMQhd1NSRtof") + chainID := ids.FromStringOrPanic("E8nTR9TtRwfkS7XFjTYUYHENQ91mkPMtDUwwCeu7rNgBBtkqu") + addressHex := "" + weight := units.Schmeckle + + address, err := hex.DecodeString(addressHex) + if err != nil { + log.Fatalf("failed to decode address %q: %s\n", addressHex, err) + } + + ctx := context.Background() + infoClient := info.NewClient(uri) + + nodeInfoStartTime := time.Now() + nodeID, nodePoP, err := infoClient.GetNodeID(ctx) + if err != nil { + log.Fatalf("failed to fetch node IDs: %s\n", err) + } + log.Printf("fetched node ID %s in %s\n", nodeID, time.Since(nodeInfoStartTime)) + + validationID := subnetID.Append(0) + conversionID, err := message.SubnetConversionID(message.SubnetConversionData{ + SubnetID: subnetID, + ManagerChainID: chainID, + ManagerAddress: address, + Validators: []message.SubnetConversionValidatorData{ + { + NodeID: nodeID.Bytes(), + BLSPublicKey: nodePoP.PublicKey, + Weight: weight, + }, + }, + }) + if err != nil { + log.Fatalf("failed to calculate conversionID: %s\n", err) + } + + // MakeWallet fetches the available UTXOs owned by [kc] on the network that + // [uri] is hosting and registers [subnetID]. + walletSyncStartTime := time.Now() + wallet, err := primary.MakeWallet(ctx, &primary.WalletConfig{ + URI: uri, + AVAXKeychain: kc, + EthKeychain: kc, + SubnetIDs: []ids.ID{subnetID}, + }) + 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() + + convertSubnetStartTime := time.Now() + convertSubnetTx, err := pWallet.IssueConvertSubnetTx( + subnetID, + chainID, + address, + []*txs.ConvertSubnetValidator{ + { + NodeID: nodeID.Bytes(), + Weight: weight, + Balance: units.Avax, + Signer: *nodePoP, + RemainingBalanceOwner: message.PChainOwner{}, + DeactivationOwner: message.PChainOwner{}, + }, + }, + ) + if err != nil { + log.Fatalf("failed to issue subnet conversion transaction: %s\n", err) + } + log.Printf("converted subnet %s with transactionID %s, validationID %s, and conversionID %s in %s\n", + subnetID, + convertSubnetTx.ID(), + validationID, + conversionID, + time.Since(convertSubnetStartTime), + ) +}