diff --git a/example/convert_subnet.go b/example/convert_subnet.go new file mode 100644 index 0000000..ecd0a28 --- /dev/null +++ b/example/convert_subnet.go @@ -0,0 +1,114 @@ +//go:build convert_subnet +// +build convert_subnet + +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package main + +import ( + "fmt" + "os" + "time" + + "github.com/ava-labs/avalanche-tooling-sdk-go/network" + "github.com/ava-labs/avalanche-tooling-sdk-go/utils" + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet/local" + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet/types" + + pchainTxs "github.com/ava-labs/avalanche-tooling-sdk-go/wallet/txs/p-chain" + avagoTxs "github.com/ava-labs/avalanchego/vms/platformvm/txs" +) + +func ConvertSubnet(subnetID, chainID string) error { + ctx, cancel := utils.GetTimedContext(120 * time.Second) + defer cancel() + network := network.FujiNetwork() + + localWallet, err := local.NewLocalWallet() + if err != nil { + return fmt.Errorf("failed to create wallet: %w", err) + } + + existingAccount, err := localWallet.ImportAccount("EXISTING_KEY_PATH") + if err != nil { + return fmt.Errorf("failed to ImportAccount: %w", err) + } + + // Validator information + nodeIDStr := "NodeID-xxxxx" + BLSPublicKey := "0x....,." // Replace with actual BLS public key + BLSProofOfPossession := "0x....." // Replace with actual BLS proof of possession + ChangeOwnerAddr := "P-fujixxxx" + ValidatorManagerAddress := "0x0FEEDC0DE0000000000000000000000000000000" // default validator manager address + Weight := 100 // Validator weight + Balance := 1000000000 // Validator balance in nAVAX + + bootstrapValidators := []*pchainTxs.ConvertSubnetToL1Validator{} + bootstrapValidator := &pchainTxs.ConvertSubnetToL1Validator{ + NodeID: nodeIDStr, + Weight: uint64(Weight), + Balance: uint64(Balance), + BLSPublicKey: BLSPublicKey, + BLSProofOfPossession: BLSProofOfPossession, + RemainingBalanceOwner: ChangeOwnerAddr, + } + bootstrapValidators = append(bootstrapValidators, bootstrapValidator) + + convertSubnetParams := &pchainTxs.ConvertSubnetToL1TxParams{ + SubnetAuthKeys: []string{"P-fujixxxxxx"}, + SubnetID: subnetID, + // ChainID is Blockchain ID of the L1 where the validator manager contract is deployed. + ChainID: chainID, + // Validators are the initial set of L1 validators after the conversion. + Validators: bootstrapValidators, + Address: ValidatorManagerAddress, + } + buildTxParams := types.BuildTxParams{ + Account: *existingAccount, + Network: network, + BuildTxInput: convertSubnetParams, + } + buildTxResult, err := localWallet.BuildTx(ctx, buildTxParams) + if err != nil { + return fmt.Errorf("failed to BuildTx: %w", err) + } + + signTxParams := types.SignTxParams{ + Account: *existingAccount, + Network: network, + BuildTxResult: &buildTxResult, + } + signTxResult, err := localWallet.SignTx(ctx, signTxParams) + if err != nil { + return fmt.Errorf("failed to signTx: %w", err) + } + + sendTxParams := types.SendTxParams{ + Account: *existingAccount, + Network: network, + SignTxResult: &signTxResult, + } + sendTxResult, err := localWallet.SendTx(ctx, sendTxParams) + if err != nil { + return fmt.Errorf("failed to sendTx: %w", err) + } + if tx := sendTxResult.GetTx(); tx != nil { + if pChainTx, ok := tx.(*avagoTxs.Tx); ok { + fmt.Printf("sendTxResult %s \n", pChainTx.ID()) + } else { + fmt.Printf("sendTxResult %s transaction \n", sendTxResult.GetChainType()) + } + } + return nil +} + +func main() { + // Use a hardcoded subnet ID & chain ID for this example + // In a real scenario, you would get this from creating a subnet & creating a blockchain first + subnetID := "SUBNET_ID" + chainID := "CHAIN_ID" + if err := ConvertSubnet(subnetID, chainID); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/wallet/chains/pchain/builder.go b/wallet/chains/pchain/builder.go index 78969ce..51d244e 100644 --- a/wallet/chains/pchain/builder.go +++ b/wallet/chains/pchain/builder.go @@ -3,11 +3,14 @@ package pchain import ( + "encoding/json" "fmt" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/formatting/address" "github.com/ava-labs/avalanchego/utils/set" + "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/wallet/subnet/primary" "github.com/ava-labs/avalanchego/wallet/subnet/primary/common" @@ -102,12 +105,16 @@ func buildConvertSubnetToL1Tx(wallet *primary.Wallet, account account.Account, p return types.BuildTxResult{}, fmt.Errorf("failed to parse chain ID: %w", err) } addressBytes := []byte(params.Address) + avagoValidators, err := convertSubnetValidatorsToAvagoValidators(params.Validators) + if err != nil { + return types.BuildTxResult{}, fmt.Errorf("failed to convert address to bytes: %w", err) + } options := getMultisigTxOptions(account, subnetAuthKeys) unsignedTx, err := wallet.P().Builder().NewConvertSubnetToL1Tx( subnetID, chainID, addressBytes, - params.Validators, + avagoValidators, options..., ) if err != nil { @@ -150,3 +157,57 @@ func convertSubnetAuthKeys(subnetAuthKeys []string) ([]ids.ShortID, error) { } return subnetAuthKeyIDs, nil } + +func convertSubnetValidatorsToAvagoValidators(validators []*pchainTxs.ConvertSubnetToL1Validator) ([]*avagoTxs.ConvertSubnetToL1Validator, error) { + bootstrapValidators := []*avagoTxs.ConvertSubnetToL1Validator{} + for _, validator := range validators { + nodeID, err := ids.NodeIDFromString(validator.NodeID) + if err != nil { + return nil, fmt.Errorf("failed to parse node ID: %w", err) + } + + blsInfo, err := convertToBLSProofOfPossession(validator.BLSPublicKey, validator.BLSProofOfPossession) + if err != nil { + return nil, fmt.Errorf("failure parsing BLS info: %w", err) + } + + addrs, err := address.ParseToIDs([]string{validator.RemainingBalanceOwner}) + if err != nil { + return nil, fmt.Errorf("failure parsing change owner address: %w", err) + } + bootstrapValidator := &avagoTxs.ConvertSubnetToL1Validator{ + NodeID: nodeID[:], + Weight: validator.Weight, + Balance: validator.Balance, + Signer: blsInfo, + RemainingBalanceOwner: message.PChainOwner{ + Threshold: 1, + Addresses: addrs, + }, + } + bootstrapValidators = append(bootstrapValidators, bootstrapValidator) + } + + return bootstrapValidators, nil +} + +func convertToBLSProofOfPossession(publicKey, proofOfPossesion string) (signer.ProofOfPossession, error) { + type jsonProofOfPossession struct { + PublicKey string + ProofOfPossession string + } + jsonPop := jsonProofOfPossession{ + PublicKey: publicKey, + ProofOfPossession: proofOfPossesion, + } + popBytes, err := json.Marshal(jsonPop) + if err != nil { + return signer.ProofOfPossession{}, err + } + pop := &signer.ProofOfPossession{} + err = pop.UnmarshalJSON(popBytes) + if err != nil { + return signer.ProofOfPossession{}, err + } + return *pop, nil +} diff --git a/wallet/local/wallet.go b/wallet/local/wallet.go index b367b57..e978b04 100644 --- a/wallet/local/wallet.go +++ b/wallet/local/wallet.go @@ -127,7 +127,7 @@ func extractSubnetIDFromTx(tx interface{}) (ids.ID, error) { // For CreateChainTx, the subnet ID field is SubnetID return unsignedTx.SubnetID, nil case *avagoTxs.ConvertSubnetToL1Tx: - // For CreateChainTx, the subnet ID field is SubnetID + // For ConvertSubnetToL1Tx, the subnet ID field is Subnet return unsignedTx.Subnet, nil } } diff --git a/wallet/txs/p-chain/convertSubnetToL1Tx.go b/wallet/txs/p-chain/convertSubnetToL1Tx.go index 5baa2bb..b77e6a4 100644 --- a/wallet/txs/p-chain/convertSubnetToL1Tx.go +++ b/wallet/txs/p-chain/convertSubnetToL1Tx.go @@ -6,10 +6,29 @@ import ( "fmt" "github.com/ava-labs/avalanche-tooling-sdk-go/constants" - - avagoTxs "github.com/ava-labs/avalanchego/vms/platformvm/txs" ) +// ConvertSubnetToL1TxParams contains all parameters needed to create a ConvertSubnetToL1Tx +type ConvertSubnetToL1Validator struct { + // NodeID of this validator + NodeID string `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 + BLSPublicKey string `serialize:"true" json:"signer"` + BLSProofOfPossession string + // Leftover $AVAX from the [Balance] will be issued to this owner once it is + // removed from the validator set. + RemainingBalanceOwner string `serialize:"true" json:"remainingBalanceOwner"` + // This owner has the authority to manually deactivate this validator. + DeactivationOwner string `serialize:"true" json:"deactivationOwner"` +} + // ConvertSubnetToL1TxParams contains all parameters needed to create a ConvertSubnetToL1Tx type ConvertSubnetToL1TxParams struct { // SubnetAuthKeys are the keys used to sign `ConvertSubnetToL1Tx` @@ -21,7 +40,7 @@ type ConvertSubnetToL1TxParams struct { // Address is address of the validator manager contract. Address string // Validators are the initial set of L1 validators after the conversion. - Validators []*avagoTxs.ConvertSubnetToL1Validator + Validators []*ConvertSubnetToL1Validator } // Validate validates the parameters