mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
527 lines
16 KiB
Go
527 lines
16 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package builder
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/luxfi/constants"
|
|
"github.com/luxfi/crypto/hash"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/node/vms/platformvm/genesis"
|
|
"github.com/luxfi/node/vms/platformvm/stakeable"
|
|
"github.com/luxfi/node/vms/platformvm/txs"
|
|
"github.com/luxfi/utxo/secp256k1fx"
|
|
|
|
genesiscfg "github.com/luxfi/genesis/pkg/genesis"
|
|
)
|
|
|
|
func TestGetStakingConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
networkID uint32
|
|
}{
|
|
{"Mainnet", constants.MainnetID},
|
|
{"Testnet", constants.TestnetID},
|
|
{"CustomID", constants.LocalID},
|
|
{"Custom", 12345},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := GetStakingConfig(tt.networkID)
|
|
|
|
// Verify basic constraints
|
|
require.GreaterOrEqual(t, cfg.UptimeRequirement, 0.0)
|
|
require.LessOrEqual(t, cfg.UptimeRequirement, 1.0)
|
|
require.Greater(t, cfg.MinValidatorStake, uint64(0))
|
|
require.GreaterOrEqual(t, cfg.MaxValidatorStake, cfg.MinValidatorStake)
|
|
require.Greater(t, cfg.MinDelegatorStake, uint64(0))
|
|
require.Greater(t, cfg.MinStakeDuration, time.Duration(0))
|
|
require.GreaterOrEqual(t, cfg.MaxStakeDuration, cfg.MinStakeDuration)
|
|
|
|
// RewardConfig is populated by builder with node-specific types
|
|
// The genesis package only provides the base staking parameters
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetTxFeeConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
networkID uint32
|
|
}{
|
|
{"Mainnet", constants.MainnetID},
|
|
{"Testnet", constants.TestnetID},
|
|
{"CustomID", constants.LocalID},
|
|
{"Custom", 12345},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := GetTxFeeConfig(tt.networkID)
|
|
|
|
// Verify basic fee config
|
|
require.Greater(t, cfg.TxFee, uint64(0))
|
|
require.Greater(t, cfg.CreateAssetTxFee, uint64(0))
|
|
|
|
// Verify dynamic fee config
|
|
require.Greater(t, uint64(cfg.DynamicFeeConfig.MaxCapacity), uint64(0))
|
|
require.Greater(t, uint64(cfg.DynamicFeeConfig.MaxPerSecond), uint64(0))
|
|
|
|
// Verify validator fee config
|
|
require.Greater(t, uint64(cfg.ValidatorFeeConfig.Capacity), uint64(0))
|
|
require.Greater(t, uint64(cfg.ValidatorFeeConfig.Target), uint64(0))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetBootstrappers(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
networkID uint32
|
|
}{
|
|
{"Mainnet", constants.MainnetID},
|
|
{"Testnet", constants.TestnetID},
|
|
{"Devnet", constants.DevnetID},
|
|
{"CustomID", constants.LocalID},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
bootstrappers, err := GetBootstrappers(tt.networkID)
|
|
require.NoError(t, err)
|
|
|
|
// Bootstrappers may not be configured for all networks
|
|
// This is acceptable - they can be provided via config
|
|
|
|
// Verify each bootstrapper has valid ID and endpoint
|
|
for _, b := range bootstrappers {
|
|
require.NotEqual(t, b.ID.String(), "")
|
|
require.True(t, b.Endpoint.Port > 0, "endpoint port must be non-zero")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSampleBootstrappers(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
networkID uint32
|
|
count int
|
|
}{
|
|
{"Mainnet_5", constants.MainnetID, 5},
|
|
{"Mainnet_10", constants.MainnetID, 10},
|
|
{"Testnet_3", constants.TestnetID, 3},
|
|
{"Custom_0", constants.LocalID, 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sampled, err := SampleBootstrappers(tt.networkID, tt.count)
|
|
require.NoError(t, err)
|
|
|
|
// Should not exceed requested count
|
|
require.LessOrEqual(t, len(sampled), tt.count)
|
|
|
|
// Should not exceed available bootstrappers
|
|
all, err := GetBootstrappers(tt.networkID)
|
|
require.NoError(t, err)
|
|
require.LessOrEqual(t, len(sampled), len(all))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
networkID uint32
|
|
}{
|
|
{"Mainnet", constants.MainnetID},
|
|
{"Testnet", constants.TestnetID},
|
|
{"MainnetChainID", constants.MainnetChainID},
|
|
{"TestnetChainID", constants.TestnetChainID},
|
|
{"CustomID", constants.LocalID},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := GetConfig(tt.networkID)
|
|
require.NotNil(t, cfg)
|
|
require.Greater(t, cfg.NetworkID, uint32(0))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetConfigAllocations(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
networkID uint32
|
|
minAllocs int
|
|
minStakers int
|
|
}{
|
|
{"Mainnet", constants.MainnetID, 50, 1},
|
|
{"Testnet", constants.TestnetID, 50, 1},
|
|
{"Devnet", constants.DevnetID, 50, 1},
|
|
{"Local", constants.LocalID, 50, 1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := GetConfig(tt.networkID)
|
|
require.NotNil(t, cfg)
|
|
require.GreaterOrEqual(t, len(cfg.Allocations), tt.minAllocs,
|
|
"network %d must have at least %d allocations", tt.networkID, tt.minAllocs)
|
|
require.GreaterOrEqual(t, len(cfg.InitialStakers), tt.minStakers,
|
|
"network %d must have at least %d stakers", tt.networkID, tt.minStakers)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFromConfigNonZeroSupply(t *testing.T) {
|
|
for _, networkID := range []uint32{constants.MainnetID, constants.TestnetID, constants.DevnetID, constants.LocalID} {
|
|
t.Run(fmt.Sprintf("network_%d", networkID), func(t *testing.T) {
|
|
cfg := GetConfig(networkID)
|
|
require.NotNil(t, cfg)
|
|
|
|
genesisBytes, _, err := FromConfig(cfg)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, genesisBytes)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVMAliases(t *testing.T) {
|
|
// Verify all expected VMs have aliases
|
|
require.Contains(t, VMAliases, constants.PlatformVMID)
|
|
require.Contains(t, VMAliases, constants.XVMID)
|
|
require.Contains(t, VMAliases, constants.EVMID)
|
|
|
|
// Verify aliases are non-empty
|
|
for vmID, aliases := range VMAliases {
|
|
require.NotEmpty(t, aliases, "VM %s should have aliases", vmID)
|
|
}
|
|
}
|
|
|
|
func TestChainAliases(t *testing.T) {
|
|
require.NotEmpty(t, PChainAliases)
|
|
require.NotEmpty(t, XChainAliases)
|
|
require.NotEmpty(t, CChainAliases)
|
|
|
|
require.Contains(t, PChainAliases, "P")
|
|
require.Contains(t, XChainAliases, "X")
|
|
require.Contains(t, CChainAliases, "C")
|
|
}
|
|
|
|
func TestDefaultFeeConfigs(t *testing.T) {
|
|
// Test dynamic fee configs
|
|
configs := []struct {
|
|
name string
|
|
config interface{}
|
|
}{
|
|
{"MainnetDynamic", MainnetDynamicFeeConfig},
|
|
{"TestnetDynamic", TestnetDynamicFeeConfig},
|
|
{"LocalDynamic", LocalDynamicFeeConfig},
|
|
{"MainnetValidator", MainnetValidatorFeeConfig},
|
|
{"TestnetValidator", TestnetValidatorFeeConfig},
|
|
{"LocalValidator", LocalValidatorFeeConfig},
|
|
}
|
|
|
|
for _, tt := range configs {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
require.NotNil(t, tt.config)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFromConfigExplicitStakers(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
// Build 5 stakers with explicit Weight and NodeID (no BLS keys)
|
|
nodeIDs := []string{
|
|
"NodeID-7D3wajA7bNpfyHpfEtkjUF1KcuhFEfPbZ",
|
|
"NodeID-A6d3tQtteyaBCiffij3ohxxgq17DdvChs",
|
|
"NodeID-Kv37z2BuRsGPhYNeCyktfgeYHK5QbZMUu",
|
|
"NodeID-HvrW5UP1SdR8ujEHzCdUurhvUj1Liydxo",
|
|
"NodeID-LBhpNZ7Sf9gpT1gJv2MKKS4Yd2SibTVeK",
|
|
}
|
|
|
|
var secpAddr ids.ShortID
|
|
stakerHash := hash.ComputeHash160([]byte("test-staker-v1"))
|
|
copy(secpAddr[:], stakerHash)
|
|
|
|
startTime := uint64(time.Now().Unix())
|
|
const hundredYears = 100 * 365 * 24 * 60 * 60
|
|
const oneBillionLUX = 1_000_000_000_000_000_000
|
|
const oneMillionLUX = 1_000_000_000_000_000
|
|
|
|
stakers := make([]genesiscfg.Staker, len(nodeIDs))
|
|
for i, nidStr := range nodeIDs {
|
|
nodeID, err := ids.NodeIDFromString(nidStr)
|
|
require.NoError(err)
|
|
stakers[i] = genesiscfg.Staker{
|
|
NodeID: nodeID,
|
|
RewardAddress: secpAddr,
|
|
DelegationFee: 1000000,
|
|
Weight: oneBillionLUX / uint64(len(nodeIDs)),
|
|
StartTime: startTime,
|
|
EndTime: startTime + hundredYears,
|
|
}
|
|
}
|
|
|
|
var ethShortID ids.ShortID
|
|
|
|
cfg := &genesiscfg.Config{
|
|
NetworkID: constants.LocalID,
|
|
Allocations: []genesiscfg.Allocation{
|
|
{
|
|
EVMAddr: secpAddr,
|
|
UTXOAddr: secpAddr,
|
|
InitialAmount: 0,
|
|
UnlockSchedule: []genesiscfg.LockedAmount{
|
|
{Amount: oneBillionLUX, Locktime: 0},
|
|
},
|
|
},
|
|
{
|
|
EVMAddr: ethShortID,
|
|
UTXOAddr: ethShortID,
|
|
InitialAmount: oneMillionLUX,
|
|
},
|
|
},
|
|
StartTime: startTime,
|
|
InitialStakeDuration: hundredYears,
|
|
InitialStakeDurationOffset: 0,
|
|
InitialStakedFunds: []ids.ShortID{secpAddr},
|
|
InitialStakers: stakers,
|
|
CChainGenesis: `{"config":{"chainId":31337},"alloc":{}}`,
|
|
Message: "Test Genesis",
|
|
}
|
|
|
|
genesisBytes, _, err := FromConfig(cfg)
|
|
require.NoError(err)
|
|
require.NotEmpty(genesisBytes)
|
|
|
|
// Parse genesis and verify validators
|
|
parsed, err := genesis.Parse(genesisBytes)
|
|
require.NoError(err)
|
|
require.Len(parsed.Validators, 5, "expected 5 validators in genesis")
|
|
|
|
for i, vdrTx := range parsed.Validators {
|
|
switch ut := vdrTx.Unsigned.(type) {
|
|
case *txs.AddValidatorTx:
|
|
require.Equal(stakers[i].Weight, ut.Wght,
|
|
"validator %d weight mismatch", i)
|
|
t.Logf("Validator %d: NodeID=%s Weight=%d StakeOuts=%d",
|
|
i, ut.Validator.NodeID, ut.Wght, len(ut.StakeOuts))
|
|
case *txs.AddPermissionlessValidatorTx:
|
|
require.Equal(stakers[i].Weight, ut.Wght,
|
|
"validator %d weight mismatch", i)
|
|
t.Logf("Validator %d: NodeID=%s Weight=%d StakeOuts=%d",
|
|
i, ut.Validator.NodeID, ut.Wght, len(ut.StakeOuts))
|
|
default:
|
|
t.Fatalf("unexpected validator tx type: %T", ut)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFromConfigExplicitStakersNoStakedFunds reproduces the bug: when
|
|
// InitialStakedFunds is empty but stakers have explicit Weight, the validators
|
|
// end up with empty StakeOuts and the P-Chain rejects them.
|
|
func TestFromConfigExplicitStakersNoStakedFunds(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
nodeIDs := []string{
|
|
"NodeID-7D3wajA7bNpfyHpfEtkjUF1KcuhFEfPbZ",
|
|
"NodeID-A6d3tQtteyaBCiffij3ohxxgq17DdvChs",
|
|
"NodeID-Kv37z2BuRsGPhYNeCyktfgeYHK5QbZMUu",
|
|
"NodeID-HvrW5UP1SdR8ujEHzCdUurhvUj1Liydxo",
|
|
"NodeID-LBhpNZ7Sf9gpT1gJv2MKKS4Yd2SibTVeK",
|
|
}
|
|
|
|
var deployerAddr ids.ShortID
|
|
deployerHash := hash.ComputeHash160([]byte("test-deployer"))
|
|
copy(deployerAddr[:], deployerHash)
|
|
|
|
startTime := uint64(time.Now().Unix())
|
|
const hundredYears = 100 * 365 * 24 * 60 * 60
|
|
const oneBillionLUX = 1_000_000_000_000_000_000
|
|
const oneMillionLUX = 1_000_000_000_000_000
|
|
|
|
stakers := make([]genesiscfg.Staker, len(nodeIDs))
|
|
for i, nidStr := range nodeIDs {
|
|
nodeID, err := ids.NodeIDFromString(nidStr)
|
|
require.NoError(err)
|
|
stakers[i] = genesiscfg.Staker{
|
|
NodeID: nodeID,
|
|
RewardAddress: deployerAddr,
|
|
DelegationFee: 1000000,
|
|
Weight: oneBillionLUX / uint64(len(nodeIDs)),
|
|
StartTime: startTime,
|
|
EndTime: startTime + hundredYears,
|
|
}
|
|
}
|
|
|
|
// No InitialStakedFunds -- stakers have explicit Weight only
|
|
cfg := &genesiscfg.Config{
|
|
NetworkID: constants.LocalID,
|
|
Allocations: []genesiscfg.Allocation{
|
|
{
|
|
EVMAddr: deployerAddr,
|
|
UTXOAddr: deployerAddr,
|
|
InitialAmount: oneMillionLUX,
|
|
},
|
|
},
|
|
StartTime: startTime,
|
|
InitialStakeDuration: hundredYears,
|
|
InitialStakeDurationOffset: 0,
|
|
InitialStakedFunds: nil, // No staked funds
|
|
InitialStakers: stakers,
|
|
CChainGenesis: `{"config":{"chainId":31337},"alloc":{}}`,
|
|
Message: "Test Genesis No Staked Funds",
|
|
}
|
|
|
|
genesisBytes, _, err := FromConfig(cfg)
|
|
require.NoError(err)
|
|
require.NotEmpty(genesisBytes)
|
|
|
|
parsed, err := genesis.Parse(genesisBytes)
|
|
require.NoError(err)
|
|
require.Len(parsed.Validators, 5, "expected 5 validators in genesis")
|
|
|
|
for i, vdrTx := range parsed.Validators {
|
|
switch ut := vdrTx.Unsigned.(type) {
|
|
case *txs.AddValidatorTx:
|
|
require.Greater(ut.Wght, uint64(0), "validator %d weight must be non-zero", i)
|
|
require.Greater(len(ut.StakeOuts), 0, "validator %d must have stake outputs", i)
|
|
t.Logf("Validator %d: NodeID=%s Weight=%d StakeOuts=%d",
|
|
i, ut.Validator.NodeID, ut.Wght, len(ut.StakeOuts))
|
|
case *txs.AddPermissionlessValidatorTx:
|
|
require.Greater(ut.Wght, uint64(0), "validator %d weight must be non-zero", i)
|
|
require.Greater(len(ut.StakeOuts), 0, "validator %d must have stake outputs", i)
|
|
t.Logf("Validator %d: NodeID=%s Weight=%d StakeOuts=%d",
|
|
i, ut.Validator.NodeID, ut.Wght, len(ut.StakeOuts))
|
|
default:
|
|
t.Fatalf("unexpected validator tx type: %T", ut)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFromConfigLegacyShapedBackCompat proves the back-compat rule for
|
|
// legacy legacy-shaped pchain configs where initialAmount AND unlockSchedule
|
|
// are both set and initialAmount == sum(unlockSchedule). These are two views
|
|
// of the same total. Emitting both would double-mint. The builder must emit
|
|
// exactly one UTXO per unlock entry (no additional initialAmount UTXO).
|
|
func TestFromConfigLegacyShapedBackCompat(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
var stakerAddr ids.ShortID
|
|
copy(stakerAddr[:], hash.ComputeHash160([]byte("legacy-staker")))
|
|
|
|
var holderAddr ids.ShortID
|
|
copy(holderAddr[:], hash.ComputeHash160([]byte("legacy-holder")))
|
|
|
|
startTime := uint64(time.Now().Unix())
|
|
const hundredYears = 100 * 365 * 24 * 60 * 60
|
|
|
|
// holder gets 100 LUX, split into 10 yearly unlocks of 10 LUX each.
|
|
// initialAmount is the total (legacy convention).
|
|
const holderTotal = 100_000_000_000_000 // 100 LUX in nLUX
|
|
const perUnlock = holderTotal / 10
|
|
holderUnlocks := make([]genesiscfg.LockedAmount, 10)
|
|
for i := range holderUnlocks {
|
|
holderUnlocks[i] = genesiscfg.LockedAmount{
|
|
Amount: perUnlock,
|
|
Locktime: startTime + uint64(i)*365*24*60*60,
|
|
}
|
|
}
|
|
|
|
nodeID, err := ids.NodeIDFromString("NodeID-7D3wajA7bNpfyHpfEtkjUF1KcuhFEfPbZ")
|
|
require.NoError(err)
|
|
|
|
cfg := &genesiscfg.Config{
|
|
NetworkID: constants.LocalID,
|
|
Allocations: []genesiscfg.Allocation{
|
|
{
|
|
EVMAddr: stakerAddr,
|
|
UTXOAddr: stakerAddr,
|
|
InitialAmount: 0,
|
|
UnlockSchedule: []genesiscfg.LockedAmount{
|
|
{Amount: holderTotal, Locktime: 0},
|
|
},
|
|
},
|
|
{
|
|
// Legacy legacy-shaped: both fields set, initialAmount
|
|
// equals sum(unlockSchedule). The builder MUST treat these
|
|
// as one allocation (no double-mint).
|
|
EVMAddr: holderAddr,
|
|
UTXOAddr: holderAddr,
|
|
InitialAmount: holderTotal,
|
|
UnlockSchedule: holderUnlocks,
|
|
},
|
|
},
|
|
StartTime: startTime,
|
|
InitialStakeDuration: hundredYears,
|
|
InitialStakeDurationOffset: 0,
|
|
InitialStakedFunds: []ids.ShortID{stakerAddr},
|
|
InitialStakers: []genesiscfg.Staker{
|
|
{
|
|
NodeID: nodeID,
|
|
RewardAddress: stakerAddr,
|
|
DelegationFee: 1000000,
|
|
StartTime: startTime,
|
|
EndTime: startTime + hundredYears,
|
|
},
|
|
},
|
|
CChainGenesis: `{"config":{"chainId":31337},"alloc":{}}`,
|
|
Message: "Legacy Shaped Back-Compat",
|
|
}
|
|
|
|
genesisBytes, _, err := FromConfig(cfg)
|
|
require.NoError(err)
|
|
require.NotEmpty(genesisBytes)
|
|
|
|
parsed, err := genesis.Parse(genesisBytes)
|
|
require.NoError(err)
|
|
|
|
// Count UTXOs belonging to holderAddr. With the back-compat fix the
|
|
// builder emits exactly len(holderUnlocks) UTXOs (one per unlock entry)
|
|
// and no extra immediately-spendable UTXO for initialAmount. Without
|
|
// the fix it would emit len(holderUnlocks)+1 UTXOs (double-mint).
|
|
//
|
|
// Outputs with locktime in the future are wrapped in *stakeable.LockOut
|
|
// whose inner Out is the *secp256k1fx.TransferOutput; outputs that are
|
|
// immediately spendable are raw *secp256k1fx.TransferOutput.
|
|
var holderUTXOs int
|
|
var holderSum uint64
|
|
for _, u := range parsed.UTXOs {
|
|
var out *secp256k1fx.TransferOutput
|
|
switch o := u.Out.(type) {
|
|
case *secp256k1fx.TransferOutput:
|
|
out = o
|
|
case *stakeable.LockOut:
|
|
inner, ok := o.TransferableOut.(*secp256k1fx.TransferOutput)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out = inner
|
|
default:
|
|
continue
|
|
}
|
|
if len(out.OutputOwners.Addrs) != 1 || out.OutputOwners.Addrs[0] != holderAddr {
|
|
continue
|
|
}
|
|
holderUTXOs++
|
|
holderSum += out.Amt
|
|
}
|
|
require.Equal(len(holderUnlocks), holderUTXOs,
|
|
"holder must have exactly %d UTXOs (one per unlock entry), got %d (double-mint?)",
|
|
len(holderUnlocks), holderUTXOs)
|
|
require.Equal(uint64(holderTotal), holderSum,
|
|
"holder UTXO sum must equal initialAmount (no double-mint)")
|
|
}
|