Skip to content
120 changes: 120 additions & 0 deletions example/convert_subnet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//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{
BaseParams: types.BaseParams{
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{
BaseParams: types.BaseParams{
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{
BaseParams: types.BaseParams{
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)
}
}
63 changes: 62 additions & 1 deletion wallet/chains/pchain/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion wallet/local/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
25 changes: 22 additions & 3 deletions wallet/txs/p-chain/convertSubnetToL1Tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down
Loading