diff --git a/blockchain/helper.go b/blockchain/helper.go index 0beac20..5c301c1 100644 --- a/blockchain/helper.go +++ b/blockchain/helper.go @@ -4,7 +4,10 @@ package blockchain import ( + "encoding/json" + "github.com/ava-labs/avalanchego/vms/platformvm" + "github.com/ava-labs/avalanchego/vms/platformvm/signer" "github.com/ava-labs/avalanche-tooling-sdk-go/network" "github.com/ava-labs/avalanche-tooling-sdk-go/utils" @@ -19,3 +22,24 @@ func GetSubnet(subnetID ids.ID, network network.Network) (platformvm.GetSubnetCl defer cancel() return pClient.GetSubnet(ctx, subnetID) } + +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/multisig/multisig.go b/multisig/multisig.go index 22548b4..a763a76 100644 --- a/multisig/multisig.go +++ b/multisig/multisig.go @@ -29,6 +29,7 @@ const ( PChainTransformSubnetTx PChainAddPermissionlessValidatorTx PChainTransferSubnetOwnershipTx + PChainConvertSubnetToL1Tx ) type Multisig struct { @@ -176,6 +177,8 @@ func (ms *Multisig) GetAuthSigners() ([]ids.ShortID, error) { subnetAuth = unsignedTx.SubnetAuth case *txs.TransferSubnetOwnershipTx: subnetAuth = unsignedTx.SubnetAuth + case *txs.ConvertSubnetToL1Tx: + subnetAuth = unsignedTx.SubnetAuth default: return nil, fmt.Errorf("unexpected unsigned tx type %T", unsignedTx) } @@ -215,6 +218,8 @@ func (ms *Multisig) GetTxKind() (TxKind, error) { return PChainAddPermissionlessValidatorTx, nil case *txs.TransferSubnetOwnershipTx: return PChainTransferSubnetOwnershipTx, nil + case *txs.ConvertSubnetToL1Tx: + return PChainConvertSubnetToL1Tx, nil default: return Undefined, fmt.Errorf("unexpected unsigned tx type %T", unsignedTx) } @@ -240,6 +245,8 @@ func (ms *Multisig) GetNetworkID() (uint32, error) { networkID = unsignedTx.NetworkID case *txs.TransferSubnetOwnershipTx: networkID = unsignedTx.NetworkID + case *txs.ConvertSubnetToL1Tx: + networkID = unsignedTx.NetworkID default: return 0, fmt.Errorf("unexpected unsigned tx type %T", unsignedTx) } @@ -281,6 +288,8 @@ func (ms *Multisig) GetBlockchainID() (ids.ID, error) { blockchainID = unsignedTx.BlockchainID case *txs.TransferSubnetOwnershipTx: blockchainID = unsignedTx.BlockchainID + case *txs.ConvertSubnetToL1Tx: + blockchainID = unsignedTx.BlockchainID default: return ids.Empty, fmt.Errorf("unexpected unsigned tx type %T", unsignedTx) } @@ -307,6 +316,8 @@ func (ms *Multisig) GetSubnetID() (ids.ID, error) { subnetID = unsignedTx.Subnet case *txs.TransferSubnetOwnershipTx: subnetID = unsignedTx.Subnet + case *txs.ConvertSubnetToL1Tx: + subnetID = unsignedTx.Subnet default: return ids.Empty, fmt.Errorf("unexpected unsigned tx type %T", unsignedTx) } diff --git a/transactions/examples/convertSubnetToL1Tx.go b/transactions/examples/convertSubnetToL1Tx.go new file mode 100644 index 0000000..c9b5629 --- /dev/null +++ b/transactions/examples/convertSubnetToL1Tx.go @@ -0,0 +1,148 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package main + +import ( + "context" + "fmt" + "os" + + "github.com/ava-labs/avalanche-tooling-sdk-go/blockchain" + "github.com/ava-labs/avalanche-tooling-sdk-go/keychain" + "github.com/ava-labs/avalanche-tooling-sdk-go/network" + "github.com/ava-labs/avalanche-tooling-sdk-go/transactions/p-chain/txs" + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/formatting/address" + avagoTxs "github.com/ava-labs/avalanchego/vms/platformvm/txs" + "github.com/ava-labs/avalanchego/vms/platformvm/warp/message" + "github.com/ava-labs/avalanchego/wallet/subnet/primary" + "github.com/ethereum/go-ethereum/common" +) + +func ConvertL1() error { + // Configuration - Replace these with your actual values + const ( + // Your private key file path + privateKeyFilePath = "" + + // Subnet and Chain IDs + subnetIDStr = "" + chainIDStr = "" + + // Validator information + nodeIDStr = "" + BLSPublicKey = "0x..." // Replace with actual BLS public key + BLSProofOfPossession = "0x..." // Replace with actual BLS proof + ChangeOwnerAddr = "P-fujixxx" // Address to receive remaining balance + Weight = 100 // Validator weight + Balance = 1000000000 // Validator balance in nAVAX + + // Validator manager contract address + validatorManagerAddr = "0x0FEEDC0DE0000000000000000000000000000000" // Replace with actual contract address + ) + + // Subnet auth keys (addresses that can sign the conversion tx) + subnetAuthKeysStrs := []string{ + "P-fujixxx", // Replace with actual addresses + } + + network := network.FujiNetwork() + keychain, err := keychain.NewKeychain(network, privateKeyFilePath, nil) + if err != nil { + return fmt.Errorf("failed to create keychain: %w", err) + } + + // Parse IDs + subnetID, err := ids.FromString(subnetIDStr) + if err != nil { + return fmt.Errorf("failed to parse subnet ID: %w", err) + } + + chainID, err := ids.FromString(chainIDStr) + if err != nil { + return fmt.Errorf("failed to parse chain ID: %w", err) + } + + deployer := txs.NewPublicDeployer(keychain, network) + + wallet, err := wallet.New( + context.Background(), + network.Endpoint, + keychain.Keychain, + primary.WalletConfig{ + SubnetIDs: []ids.ID{subnetID}, + }, + ) + if err != nil { + return fmt.Errorf("failed to create wallet: %w", err) + } + + subnetAuthKeys, err := address.ParseToIDs(subnetAuthKeysStrs) + if err != nil { + return fmt.Errorf("failure parsing auth keys: %w", err) + } + + bootstrapValidators := []*avagoTxs.ConvertSubnetToL1Validator{} + nodeID, err := ids.NodeIDFromString(nodeIDStr) + if err != nil { + return fmt.Errorf("failed to parse node ID: %w", err) + } + + blsInfo, err := blockchain.ConvertToBLSProofOfPossession(BLSPublicKey, BLSProofOfPossession) + if err != nil { + return fmt.Errorf("failure parsing BLS info: %w", err) + } + + addrs, err := address.ParseToIDs([]string{ChangeOwnerAddr}) + if err != nil { + return fmt.Errorf("failure parsing change owner address: %w", err) + } + + bootstrapValidator := &avagoTxs.ConvertSubnetToL1Validator{ + NodeID: nodeID[:], + Weight: Weight, + Balance: Balance, + Signer: blsInfo, + RemainingBalanceOwner: message.PChainOwner{ + Threshold: 1, + Addresses: addrs, + }, + } + bootstrapValidators = append(bootstrapValidators, bootstrapValidator) + + convertSubnetParams := txs.ConvertSubnetToL1TxParams{ + // SubnetAuthKeys are the keys used to sign `ConvertSubnetToL1Tx` + SubnetAuthKeys: subnetAuthKeys, + // SubnetID is Subnet ID of the subnet to convert to an L1. + SubnetID: subnetID, + // ChainID is Blockchain ID of the L1 where the validator manager contract is deployed. + ChainID: chainID, + // Address is address of the validator manager contract. + Address: common.HexToAddress(validatorManagerAddr).Bytes(), + // Validators are the initial set of L1 validators after the conversion. + Validators: bootstrapValidators, + // Wallet is the wallet used to sign `ConvertSubnetToL1Tx` + Wallet: &wallet, // Use the wallet wrapper + } + + tx, err := deployer.NewConvertSubnetToL1Tx(convertSubnetParams) + if err != nil { + return fmt.Errorf("failed to create convert subnet tx: %w", err) + } + + // Since it has the required signatures, we will now commit the transaction on chain + txID, err := deployer.Commit(*tx, wallet, true) + if err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + fmt.Printf("Convert subnet to L1 transaction submitted successfully! TX ID: %s\n", txID.String()) + return nil +} + +func main() { + if err := ConvertL1(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/transactions/p-chain/txs/baseTx.go b/transactions/p-chain/txs/baseTx.go new file mode 100644 index 0000000..c008fc4 --- /dev/null +++ b/transactions/p-chain/txs/baseTx.go @@ -0,0 +1,49 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package txs + +import ( + "errors" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/ava-labs/avalanchego/wallet/subnet/primary/common" + + "github.com/ava-labs/avalanche-tooling-sdk-go/keychain" + "github.com/ava-labs/avalanche-tooling-sdk-go/network" + "github.com/ava-labs/avalanchego/wallet/subnet/primary" +) + +var ErrNoSubnetAuthKeysInWallet = errors.New("auth wallet does not contain auth keys") + +type PublicDeployer struct { + kc *keychain.Keychain + network network.Network + wallet *primary.Wallet +} + +func NewPublicDeployer(kc *keychain.Keychain, network network.Network) *PublicDeployer { + return &PublicDeployer{ + kc: kc, + network: network, + } +} + +func (d *PublicDeployer) getMultisigTxOptions(subnetAuthKeys []ids.ShortID) []common.Option { + options := []common.Option{} + walletAddrs := d.kc.Addresses().List() + changeAddr := walletAddrs[0] + // addrs to use for signing + customAddrsSet := set.Set[ids.ShortID]{} + customAddrsSet.Add(walletAddrs...) + customAddrsSet.Add(subnetAuthKeys...) + options = append(options, common.WithCustomAddresses(customAddrsSet)) + // set change to go to wallet addr (instead of any other subnet auth key) + changeOwner := &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{changeAddr}, + } + options = append(options, common.WithChangeOwner(changeOwner)) + return options +} diff --git a/transactions/p-chain/txs/convertSubnetToL1Tx.go b/transactions/p-chain/txs/convertSubnetToL1Tx.go new file mode 100644 index 0000000..601dccc --- /dev/null +++ b/transactions/p-chain/txs/convertSubnetToL1Tx.go @@ -0,0 +1,49 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package txs + +import ( + "context" + "fmt" + + "github.com/ava-labs/avalanche-tooling-sdk-go/multisig" + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" +) + +// ConvertSubnetToL1TxParams contains all parameters needed to create a ConvertSubnetToL1Tx +type ConvertSubnetToL1TxParams struct { + // SubnetAuthKeys are the keys used to sign `ConvertSubnetToL1Tx` + SubnetAuthKeys []ids.ShortID + // SubnetID is Subnet ID of the subnet to convert to an L1. + SubnetID ids.ID + // ChainID is Blockchain ID of the L1 where the validator manager contract is deployed. + ChainID ids.ID + // Address is address of the validator manager contract. + Address []byte + // Validators are the initial set of L1 validators after the conversion. + Validators []*txs.ConvertSubnetToL1Validator + // Wallet is the wallet used to sign `ConvertSubnetToL1Tx` + Wallet *wallet.Wallet +} + +func (d *PublicDeployer) NewConvertSubnetToL1Tx(params ConvertSubnetToL1TxParams) (*multisig.Multisig, error) { + options := d.getMultisigTxOptions(params.SubnetAuthKeys) + unsignedTx, err := params.Wallet.P().Builder().NewConvertSubnetToL1Tx( + params.SubnetID, + params.ChainID, + params.Address, + params.Validators, + options..., + ) + if err != nil { + return nil, fmt.Errorf("error building tx: %w", err) + } + tx := txs.Tx{Unsigned: unsignedTx} + if err := params.Wallet.P().Signer().Sign(context.Background(), &tx); err != nil { + return nil, fmt.Errorf("error signing tx: %w", err) + } + return multisig.New(&tx), nil +} diff --git a/transactions/p-chain/txs/helper.go b/transactions/p-chain/txs/helper.go new file mode 100644 index 0000000..1c631ce --- /dev/null +++ b/transactions/p-chain/txs/helper.go @@ -0,0 +1,63 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package txs + +import ( + "errors" + "fmt" + "time" + + "github.com/ava-labs/avalanche-tooling-sdk-go/multisig" + utilsSDK "github.com/ava-labs/avalanche-tooling-sdk-go/utils" + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet" + "github.com/ava-labs/avalanchego/ids" + commonAvago "github.com/ava-labs/avalanchego/wallet/subnet/primary/common" +) + +func (d *PublicDeployer) Commit(ms multisig.Multisig, wallet wallet.Wallet, waitForTxAcceptance bool) (ids.ID, error) { + if ms.Undefined() { + return ids.Empty, multisig.ErrUndefinedTx + } + isReady, err := ms.IsReadyToCommit() + if err != nil { + return ids.Empty, err + } + if !isReady { + return ids.Empty, errors.New("tx is not fully signed so can't be committed") + } + tx, err := ms.GetWrappedPChainTx() + if err != nil { + return ids.Empty, err + } + const ( + repeats = 3 + sleepBetweenRepeats = 2 * time.Second + ) + var issueTxErr error + if err != nil { + return ids.Empty, err + } + for i := 0; i < repeats; i++ { + ctx, cancel := utilsSDK.GetAPILargeContext() + defer cancel() + options := []commonAvago.Option{commonAvago.WithContext(ctx)} + if !waitForTxAcceptance { + options = append(options, commonAvago.WithAssumeDecided()) + } + // TODO: split error checking and recovery between issuing and waiting for status + issueTxErr = wallet.P().IssueTx(tx, options...) + if issueTxErr == nil { + break + } + if ctx.Err() != nil { + issueTxErr = fmt.Errorf("timeout issuing/verifying tx with ID %s: %w", tx.ID(), issueTxErr) + } else { + issueTxErr = fmt.Errorf("error issuing tx with ID %s: %w", tx.ID(), issueTxErr) + } + time.Sleep(sleepBetweenRepeats) + } + if issueTxErr != nil { + return ids.Empty, fmt.Errorf("issue tx error %w", issueTxErr) + } + return tx.ID(), issueTxErr +} diff --git a/utils/common.go b/utils/common.go index fd0d410..6f7e488 100644 --- a/utils/common.go +++ b/utils/common.go @@ -90,3 +90,13 @@ func WrapContext[T any]( return ret, err } } + +func Filter[T any](input []T, f func(T) bool) []T { + output := make([]T, 0, len(input)) + for _, e := range input { + if f(e) { + output = append(output, e) + } + } + return output +} diff --git a/wallet/wallet.go b/wallet/wallet.go index 193c978..d5ce563 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -26,7 +26,7 @@ func New(ctx context.Context, uri string, avaxKeychain avagokeychain.Keychain, c ctx, uri, avaxKeychain, - nil, + secp256k1fx.NewKeychain(), config, ) return Wallet{