Don't miss Build Games$1M Builder Competition
Guides

Testing L1 Conversion

Learn how to convert subnets to L1s with validator managers in your tests

This guide shows how to test converting a subnet to an L1 (Layer 1) blockchain with a validator manager. L1 conversion is the process of upgrading a subnet to use Proof of Stake consensus with custom validator management.

Pattern guide only. These snippets mirror helpers in icm-services (for example icm-contracts/tests/network) and rely on shared utilities for genesis creation, contract bindings, and Warp signing. Copy from those source files for runnable code.

Overview

Converting a subnet to an L1 involves:

  1. Deploying a validator manager contract
  2. Issuing a ConvertSubnetToL1Tx on the P-Chain
  3. Initializing the validator set with a Warp message
  4. Managing validator registration and removal

Prerequisites

  • Complete the Getting Started guide
  • Understand basic Ginkgo test setup
  • Have a network with at least one L1 created

Validator Manager Types

Choose one of three validator manager types:

TypeDescriptionUse Case
PoAProof of AuthorityPermissioned networks with owner-controlled validators
Native Token StakingStake native chain tokensPublic networks using chain's native currency
ERC20 Token StakingStake ERC20 tokensNetworks with custom governance tokens

Basic L1 Conversion Flow

Complete Test Example

l1_conversion_test.go
package conversion_test

import (
    "context"
    "flag"
    "math/big"
    "os"
    "testing"
    "time"

    "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/units"
    "github.com/ava-labs/avalanchego/utils/crypto/secp256k1"
    "github.com/ethereum/go-ethereum/common"
    "github.com/ethereum/go-ethereum/crypto"
    "github.com/ethereum/go-ethereum/ethclient"
    "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
)

var (
    network      *tmpnet.Network
    e2eFlags     *e2e.FlagVars
    fundedKey    *secp256k1.PrivateKey
    l1Info       L1TestInfo
)

func TestMain(m *testing.M) {
    e2eFlags = e2e.RegisterFlags()
    flag.Parse()
    os.Exit(m.Run())
}

func TestL1Conversion(t *testing.T) {
    if os.Getenv("RUN_E2E") == "" {
        t.Skip("RUN_E2E not set")
    }

    RegisterFailHandler(ginkgo.Fail)
    ginkgo.RunSpecs(t, "L1 Conversion Test Suite")
}

var _ = ginkgo.BeforeSuite(func() {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
    defer cancel()

    // Create network with one L1
    network = createNetworkWithL1(ctx)

    // Get funded key
    fundedKey = network.PreFundedKeys[0]

    // Get L1 info
    l1Info = getL1Info(network.Subnets[0])
})

var _ = ginkgo.AfterSuite(func() {
    if network != nil {
        network.Stop(context.Background())
    }
})

var _ = ginkgo.Describe("[L1 Conversion]", func() {
    ginkgo.It("should convert subnet to L1 with native staking",
        ginkgo.Label("conversion", "native-staking"),
        func() {
            ctx := context.Background()

            // Convert subnet to L1
            nodes, validationIDs := convertSubnetToL1(
                ctx,
                network,
                l1Info,
                NativeTokenStakingManager,
                fundedKey,
            )

            Expect(nodes).To(HaveLen(2))
            Expect(validationIDs).To(HaveLen(2))

            // Verify validators are active
            for _, validationID := range validationIDs {
                status := getValidatorStatus(ctx, l1Info, validationID)
                Expect(status).To(Equal(ValidatorStatusActive))
            }
        })
})

ConvertSubnet Implementation

The ConvertSubnet Function

Here's the core conversion logic based on ICM Services:

conversion.go
package conversion

import (
    "context"
    "math/big"

    "github.com/ava-labs/avalanchego/ids"
    "github.com/ava-labs/avalanchego/tests/fixture/tmpnet"
    "github.com/ava-labs/avalanchego/vms/platformvm/warp"
    "github.com/ethereum/go-ethereum/common"
)

type ValidatorManagerType int

const (
    PoAValidatorManager ValidatorManagerType = iota
    NativeTokenStakingManager
    ERC20TokenStakingManager
)

// ConvertSubnet converts a subnet to an L1 with validator manager
func ConvertSubnet(
    ctx context.Context,
    network *tmpnet.Network,
    l1 L1TestInfo,
    managerType ValidatorManagerType,
    weights []uint64,
    fundedKey *ecdsa.PrivateKey,
) ([]*tmpnet.Node, []ids.ID) {

    // Step 1: Deploy validator manager
    validatorManagerAddress := deployValidatorManager(
        ctx,
        l1,
        managerType,
        fundedKey,
    )

    // Step 2: Initialize validator manager settings
    initializeValidatorManager(
        ctx,
        l1,
        validatorManagerAddress,
        managerType,
        fundedKey,
    )

    // Step 3: Add new nodes to network
    numValidators := len(weights)
    newNodes := make([]*tmpnet.Node, numValidators)

    for i := 0; i < numValidators; i++ {
        node := tmpnet.NewEphemeralNode(tmpnet.FlagsMap{})
        err := network.StartNode(ctx, node)
        Expect(err).NotTo(HaveOccurred())

        err = node.WaitForHealthy(ctx)
        Expect(err).NotTo(HaveOccurred())

        newNodes[i] = node
    }

    // Step 4: Issue ConvertSubnetToL1Tx on P-Chain
    convertSubnetTxID := issueConvertSubnetToL1Tx(
        ctx,
        network,
        l1.SubnetID,
        validatorManagerAddress,
        fundedKey,
    )

    // Step 5: Initialize validator set with Warp message
    initialValidationIDs := initializeValidatorSet(
        ctx,
        network,
        l1,
        validatorManagerAddress,
        newNodes,
        weights,
        convertSubnetTxID,
        fundedKey,
    )

    // Step 6: Add new nodes as L1 validators
    for i, node := range newNodes {
        addNodeToL1(ctx, network, l1, node)
    }

    return newNodes, initialValidationIDs
}

// deployValidatorManager deploys the appropriate validator manager contract
func deployValidatorManager(
    ctx context.Context,
    l1 L1TestInfo,
    managerType ValidatorManagerType,
    fundedKey *ecdsa.PrivateKey,
) common.Address {

    var address common.Address

    switch managerType {
    case PoAValidatorManager:
        address = deployPoAValidatorManager(ctx, l1, fundedKey)
    case NativeTokenStakingManager:
        address = deployNativeStakingManager(ctx, l1, fundedKey)
    case ERC20TokenStakingManager:
        address = deployERC20StakingManager(ctx, l1, fundedKey)
    }

    return address
}

// initializeValidatorManager sets up initial validator manager parameters
func initializeValidatorManager(
    ctx context.Context,
    l1 L1TestInfo,
    managerAddress common.Address,
    managerType ValidatorManagerType,
    fundedKey *ecdsa.PrivateKey,
) {

    switch managerType {
    case PoAValidatorManager:
        // PoA: Set owner address
        ownerAddress := crypto.PubkeyToAddress(fundedKey.PublicKey)
        initializePoA(ctx, l1, managerAddress, ownerAddress, fundedKey)

    case NativeTokenStakingManager:
        // Native Staking: Set staking parameters
        settings := NativeTokenStakingSettings{
            MinStakeDuration:      1,
            MinStakeAmount:        big.NewInt(1e16),
            MaxStakeAmount:        big.NewInt(10e18),
            MinDelegateFee:        1,
            MaxChurnPercentage:    20,
            ChurnPeriodSeconds:    1,
            WeightToValueFactor:   big.NewInt(1e12),
        }
        initializeNativeStaking(ctx, l1, managerAddress, settings, fundedKey)

    case ERC20TokenStakingManager:
        // ERC20 Staking: Set token and parameters
        tokenAddress := common.HexToAddress("0x...") // Your ERC20 token
        settings := ERC20TokenStakingSettings{
            MinStakeDuration:    1,
            MinStakeAmount:      big.NewInt(1e18),
            MaxStakeAmount:      big.NewInt(1000e18),
            MinDelegateFee:      1,
            MaxChurnPercentage:  20,
            ChurnPeriodSeconds:  1,
        }
        initializeERC20Staking(ctx, l1, managerAddress, tokenAddress, settings, fundedKey)
    }
}

// issueConvertSubnetToL1Tx issues the conversion transaction on P-Chain
func issueConvertSubnetToL1Tx(
    ctx context.Context,
    network *tmpnet.Network,
    subnetID ids.ID,
    validatorManagerAddress common.Address,
    fundedKey *ecdsa.PrivateKey,
) ids.ID {

    // Get P-Chain wallet
    pChainWallet := network.GetPChainWallet(fundedKey)

    // Convert address to proper format
    managerAddressBytes := validatorManagerAddress.Bytes()
    var chainAddress [20]byte
    copy(chainAddress[:], managerAddressBytes)

    // Issue ConvertSubnetToL1Tx
    txID, err := pChainWallet.IssueConvertSubnetToL1Tx(
        subnetID,
        chainAddress,
        fundedKey,
    )
    Expect(err).NotTo(HaveOccurred())

    return txID
}

// initializeValidatorSet creates initial validators with Warp message
func initializeValidatorSet(
    ctx context.Context,
    network *tmpnet.Network,
    l1 L1TestInfo,
    validatorManagerAddress common.Address,
    nodes []*tmpnet.Node,
    weights []uint64,
    convertTxID ids.ID,
    fundedKey *ecdsa.PrivateKey,
) []ids.ID {

    // Build initial validator set
    validatorSet := make([]InitialValidator, len(nodes))
    for i, node := range nodes {
        validationID := createValidationID(node.NodeID, l1.SubnetID)

        validatorSet[i] = InitialValidator{
            NodeID:       node.NodeID.Bytes(),
            Weight:       weights[i],
            BlsPublicKey: node.BlsPublicKey,
        }
    }

    // Create SubnetToL1ConversionMessage
    conversionData := SubnetToL1ConversionData{
        SubnetID:                  l1.SubnetID,
        ManagerBlockchainID:       l1.BlockchainID,
        ManagerAddress:            validatorManagerAddress.Bytes(),
        Validators:                validatorSet,
    }

    // Sign with P-Chain validators
    unsignedMessage := warp.NewUnsignedMessage(
        network.NetworkID,
        l1.BlockchainID,
        encodeConversionData(conversionData),
    )

    signedMessage := signWarpMessage(
        ctx,
        network,
        unsignedMessage,
        l1.SubnetID,
    )

    // Initialize validator set on contract
    validatorManager := getValidatorManagerContract(l1, validatorManagerAddress)

    tx := initializeValidatorSet(
        ctx,
        l1,
        validatorManager,
        conversionData,
        signedMessage.Bytes(),
        fundedKey,
    )

    receipt := waitForSuccess(ctx, l1, tx.Hash())

    // Extract validation IDs from events
    validationIDs := extractValidationIDsFromReceipt(receipt)

    return validationIDs
}

Testing Different Validator Manager Types

PoA (Proof of Authority)

ginkgo.It("should convert to PoA",
    ginkgo.Label("poa"),
    func() {
        nodes, validationIDs := convertSubnetToL1(
            context.Background(),
            network,
            l1Info,
            PoAValidatorManager,
            fundedKey,
        )

        Expect(nodes).To(HaveLen(2))
        Expect(validationIDs).To(HaveLen(2))
    })

Native Token Staking

ginkgo.It("should convert to native staking",
    ginkgo.Label("native-staking"),
    func() {
        // Use different weights for validators
        weights := []uint64{
            1 * units.Schmeckle,      // Validator 1: 1 AVAX
            1000 * units.Schmeckle,   // Validator 2: 1000 AVAX
        }

        nodes, validationIDs := convertSubnetToL1WithWeights(
            context.Background(),
            network,
            l1Info,
            NativeTokenStakingManager,
            weights,
            fundedKey,
        )

        Expect(nodes).To(HaveLen(2))
        Expect(validationIDs).To(HaveLen(2))

        // Verify weights
        for i, validationID := range validationIDs {
            weight := getValidatorWeight(context.Background(), l1Info, validationID)
            Expect(weight).To(Equal(weights[i]))
        }
    })

ERC20 Token Staking

ginkgo.It("should convert to ERC20 staking",
    ginkgo.Label("erc20-staking"),
    func() {
        // Deploy ERC20 token first
        tokenAddress, token := deployERC20Token(
            context.Background(),
            l1Info,
            fundedKey,
            "Staking Token",
            "STK",
        )

        // Convert with ERC20 manager
        nodes, validationIDs := convertSubnetToL1WithERC20(
            context.Background(),
            network,
            l1Info,
            tokenAddress,
            fundedKey,
        )

        Expect(nodes).To(HaveLen(2))
        Expect(validationIDs).To(HaveLen(2))
    })

Verifying Conversion Success

Check Validator Status

func verifyValidatorsActive(
    ctx context.Context,
    l1 L1TestInfo,
    validationIDs []ids.ID,
) {
    validatorManager := getValidatorManagerContract(l1)

    for _, validationID := range validationIDs {
        validator, err := validatorManager.GetValidator(
            &bind.CallOpts{},
            validationID,
        )
        Expect(err).NotTo(HaveOccurred())

        Expect(validator.Status).To(Equal(ValidatorStatusActive))
        Expect(validator.Weight).To(BeNumerically(">", 0))
    }
}

Check P-Chain State

func verifyPChainValidators(
    ctx context.Context,
    network *tmpnet.Network,
    subnetID ids.ID,
    expectedCount int,
) {
    pClient := network.GetPChainClient()

    validators, err := pClient.GetCurrentValidators(ctx, subnetID, nil)
    Expect(err).NotTo(HaveOccurred())

    Expect(validators).To(HaveLen(expectedCount))
}

Common Patterns

Conversion with Proxy

For upgradeable validator managers:

nodes, validationIDs, proxyAdmin := convertSubnetToL1WithProxy(
    ctx,
    network,
    l1Info,
    NativeTokenStakingManager,
    fundedKey,
)

// Can upgrade later
newImplementation := deployNewImplementation(ctx, l1Info, fundedKey)
upgradeProxy(ctx, l1Info, proxyAdmin, newImplementation, fundedKey)

Multiple Validators with Different Weights

weights := []uint64{
    100,   // Light validator
    1000,  // Medium validator
    10000, // Heavy validator
}

nodes, validationIDs := convertSubnetToL1WithWeights(
    ctx, network, l1Info, NativeTokenStakingManager, weights, fundedKey,
)

Best Practices

  1. Use generous timeouts: Conversion involves P-Chain transactions which can be slow
  2. Verify all steps: Check validator status after conversion
  3. Test different weights: Ensure validator weighting works correctly
  4. Handle errors: P-Chain operations can fail, plan for retries
  5. Clean up: Stop extra nodes in AfterEach if creating new networks per test

Troubleshooting

Conversion Transaction Fails

// Add retry logic
var txID ids.ID
err := retry.Do(func() error {
    var err error
    txID, err = issueConvertSubnetToL1Tx(ctx, network, subnetID, managerAddress, fundedKey)
    return err
}, retry.Attempts(3), retry.Delay(time.Second))

Warp Message Not Signed

Ensure all validators have accepted the block:

waitForAllValidatorsToAcceptBlock(
    ctx,
    l1.NodeURIs,
    l1.BlockchainID,
    receipt.BlockNumber.Uint64(),
)

Next Steps

Is this guide helpful?