genesis,config,xvm: derive X-Chain asset ID from genesis content

config.UTXOAssetIDFor(networkID) returns a network-id-keyed constant
that's identical across every L1 sharing a primary-network ID. On
sovereign L1s (<tenant> / MLC / VCC / any future tenant) the X-Chain
genesis bakes a different asset (different validator set, different
holder set, different denomination) so the runtime asset ID — the ID
vm.initGenesis assigns to the first GenesisAsset.CreateAssetTx — does
not match the constant.

Every wallet builder that calls platform.getStakingAssetID to populate
pCTX.XAssetID then pays tx fees from UTXOs under an asset the chain
doesn't recognise:

    insufficient funds: needs 398 more nLUX (<constant>)

That's the bootstrap failure on <tenant> devnet / testnet / mainnet
after the chainset-via-embedded-genesis fix landed.

Fix: derive the X-Chain native asset ID from the actual genesis bytes
the binary loads at startup. Every load path now goes through
resolveXAssetID(networkID, genesisBytes):

  - genesis baked with X-Chain → parse the embedded XVM genesis,
    initialize the first GenesisAsset.CreateAssetTx, return its tx.ID()
    (the same value vm.initGenesis assigns at runtime).
  - genesis is P-only (no X-Chain) → fall back to UTXOAssetIDFor.
    Value is unused at runtime, kept for downstream-consumer shape.
  - genesis is malformed → error (was previously silently returning
    the wrong constant; that's how sovereign L1s ended up shipping
    binaries that disagreed with their own chain).

Touched:
  - vms/xvm/genesis.go: ParseGenesisBytes + AssetIDFromGenesisBytes.
  - vms/xvm/genesis/lux.go: AssetIDFromBytes proxy so the wrapper
    package stays the single dependency point.
  - genesis/builder/builder.go: FromConfig uses the genesis-derived
    ID instead of UTXOAssetIDFor; new XAssetIDFromGenesisBytes
    helper for the platform-genesis-bytes path.
  - config/config.go: getGenesisData's raw and cached paths now call
    resolveXAssetID. FromConfig path inherits the fix automatically.

Tests:
  - vms/xvm/genesis/lux_test.go: AssetIDFromBytes is stable, holder-
    sensitive, network-id-sensitive, malformed-input-rejecting.
  - genesis/builder/builder_p_only_test.go: helper agrees with
    FromConfig on sovereign genesis, returns ok=false on P-only,
    errors on garbage.
  - config/config_test.go: resolveXAssetID returns the genesis-derived
    ID for sovereign genesis, UTXOAssetIDFor on P-only, error on
    garbage.

No network-id allow-list, no per-network branching, no env-var
overrides. The fix is in the binary's load path; operator workflows
stay byte-identical.
This commit is contained in:
Hanzo AI
2026-05-21 18:34:50 -07:00
parent 5a939cf7b5
commit b2220e0df5
7 changed files with 392 additions and 11 deletions
+40 -4
View File
@@ -1052,9 +1052,10 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
if err != nil {
return nil, ids.Empty, fmt.Errorf("failed to decode %s: %w", GenesisRawBytesKey, err)
}
// LUX asset ID is the network-scoped constant — same regardless of
// what's in genesisBytes, so no need to parse them.
xAssetID := constants.UTXOAssetIDFor(networkID)
xAssetID, err := resolveXAssetID(networkID, genesisBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("resolve X-Chain asset ID from %s: %w", GenesisRawBytesKey, err)
}
log.Info("loaded raw genesis bytes directly",
"size", len(genesisBytes),
"xAssetID", xAssetID,
@@ -1090,11 +1091,16 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
// Check if we have cached genesis bytes to avoid rebuilding
cacheFile := filepath.Join(dataDir, "genesis.bytes")
if cachedBytes, err := loadCachedGenesisBytes(cacheFile); err == nil && len(cachedBytes) > 0 {
xAssetID, err := resolveXAssetID(networkID, cachedBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("resolve X-Chain asset ID from cached genesis: %w", err)
}
log.Info("loaded cached genesis bytes for hash stability",
"cacheFile", cacheFile,
"size", len(cachedBytes),
"xAssetID", xAssetID,
)
return cachedBytes, constants.UTXOAssetIDFor(networkID), nil
return cachedBytes, xAssetID, nil
}
// No cache or invalid cache - build from file and cache the result
genesisBytes, xAssetID, err := builder.FromFile(networkID, genesisFileName, stakingCfg)
@@ -1120,6 +1126,36 @@ func getGenesisData(v *viper.Viper, networkID uint32, stakingCfg *builder.Stakin
return builder.FromConfig(config)
}
// resolveXAssetID extracts the X-Chain native asset ID from the loaded
// platform-genesis blob. It is the canonical source of truth used by
// every load path that bypasses FromConfig (raw bytes, cached bytes —
// the paths that historically defaulted to constants.UTXOAssetIDFor and
// silently disagreed with the genesis content on sovereign L1s).
//
// Behaviour:
//
// - If the genesis bakes an X-Chain, returns the runtime asset ID
// derived from that chain's CreateAssetTx (the same value
// vm.initGenesis assigns at runtime).
// - If the genesis is P-only (no X-Chain), returns the network-id-
// keyed constant. The asset ID is unused in that mode.
// - If the genesis is unparseable or the embedded X-Chain genesis
// is malformed, returns the corresponding error — these are
// unrecoverable on a primary-network bootstrap.
//
// Tested against both sovereign-network genesis (X-Chain present, asset
// ID differs from the constant) and upstream Lux genesis fixtures.
func resolveXAssetID(networkID uint32, genesisBytes []byte) (ids.ID, error) {
id, ok, err := builder.XAssetIDFromGenesisBytes(genesisBytes)
if err != nil {
return ids.Empty, err
}
if !ok {
return constants.UTXOAssetIDFor(networkID), nil
}
return id, nil
}
func getTrackedChains(v *viper.Viper) (set.Set[ids.ID], error) {
trackChainsStr := v.GetString(TrackChainsKey)
+58
View File
@@ -16,10 +16,14 @@ import (
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
"github.com/luxfi/constants"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
"github.com/luxfi/node/chains"
"github.com/luxfi/node/genesis/builder"
"github.com/luxfi/node/nets"
pchaingenesis "github.com/luxfi/node/vms/platformvm/genesis"
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
)
const chainConfigFilenameExtension = ".ex"
@@ -672,3 +676,57 @@ func TestDevModeFlags(t *testing.T) {
}
}
// TestResolveXAssetID_FromSovereignGenesis covers the canonical
// behaviour the sovereign-L1 fix relies on: when the loaded platform
// genesis bakes an X-Chain, resolveXAssetID returns the runtime asset
// ID encoded IN the genesis (matches what FromConfig produces, matches
// what the running X-Chain reports via platform.getStakingAssetID).
//
// Critically: NOT constants.UTXOAssetIDFor(networkID). That value is
// network-id-keyed and would silently collide between two sovereign L1s
// sharing a primary-network ID.
func TestResolveXAssetID_FromSovereignGenesis(t *testing.T) {
require := require.New(t)
cfg := builder.GetConfig(constants.LocalID)
require.NotNil(cfg)
require.NotEmpty(cfg.XChainGenesis, "fixture must bake X-Chain")
genesisBytes, expectedID, err := builder.FromConfig(cfg)
require.NoError(err)
require.NotEqual(ids.Empty, expectedID)
gotID, err := resolveXAssetID(constants.LocalID, genesisBytes)
require.NoError(err)
require.Equal(expectedID, gotID,
"resolveXAssetID must agree with FromConfig on the genesis-derived asset ID")
}
// TestResolveXAssetID_POnlyFallback verifies that when the platform
// genesis bakes no X-Chain (P-only mode), resolveXAssetID falls back
// to constants.UTXOAssetIDFor(networkID). That value is unused at
// runtime (no X-Chain to mint on) but keeps the existing nodeConfig
// shape consistent for downstream consumers.
func TestResolveXAssetID_POnlyFallback(t *testing.T) {
require := require.New(t)
pOnly := &pchaingenesis.Genesis{Chains: nil}
pOnlyBytes, err := pchaingenesis.Codec.Marshal(pchaintxs.CodecVersion, pOnly)
require.NoError(err)
gotID, err := resolveXAssetID(42, pOnlyBytes)
require.NoError(err)
require.Equal(constants.UTXOAssetIDFor(42), gotID,
"P-only must fall through to UTXOAssetIDFor(networkID)")
}
// TestResolveXAssetID_Malformed asserts that bad genesis bytes surface
// an error rather than silently returning ids.Empty (which would
// reintroduce the UTXOAssetIDFor fallback and defeat the fix on
// sovereign L1s where the fallback value is wrong).
func TestResolveXAssetID_Malformed(t *testing.T) {
require := require.New(t)
_, err := resolveXAssetID(1, []byte{0xff, 0xfe, 0xfd, 0xfc})
require.Error(err)
}
+73 -7
View File
@@ -386,13 +386,31 @@ func FromConfig(config *genesiscfg.Config) ([]byte, ids.ID, error) {
}
}
// LUX asset ID is a network-scoped constant (constants.UTXOAssetIDFor),
// not derived from X-Chain genesis bytes. Domain-separated by networkID
// so mainnet/testnet/devnet/local each have a distinct asset ID — this
// prevents cross-network UTXO accounting collapse in wallets/indexers
// that key balance by AssetID alone. Mainnet (networkID=1) returns the
// legacy UTXO_ASSET_ID literal to preserve existing on-chain state.
xAssetID := constants.UTXOAssetIDFor(config.NetworkID)
// X-Chain native asset ID is derived from the actual XVM genesis bytes
// (the runtime ID of the first GenesisAsset.CreateAssetTx) so the value
// the wallet builder reads back via platform.getStakingAssetID matches
// the asset the X-Chain genuinely mints. On sovereign L1s (Liquidity,
// MLC, VCC, future tenants) every primary network has its own X-Chain
// genesis content (different validator set, different initial holders,
// different denomination/name) so the genesis-derived asset ID is
// distinct from any constants.UTXOAssetIDFor(networkID) value — those
// are network-id-keyed constants and identical across every L1 sharing
// the same primary-network ID, which would let two sovereign networks
// collide.
//
// When X-Chain is opt-out (P-only mode, xvmGenesisBytes is nil) we keep
// the network-id-keyed constant as a placeholder; the asset ID is
// irrelevant in that mode since there is no X-Chain to mint on.
var xAssetID ids.ID
if len(xvmGenesisBytes) > 0 {
var err error
xAssetID, err = xvmgenesis.AssetIDFromBytes(xvmGenesisBytes)
if err != nil {
return nil, ids.Empty, fmt.Errorf("derive X-Chain asset ID from genesis: %w", err)
}
} else {
xAssetID = constants.UTXOAssetIDFor(config.NetworkID)
}
genesisTime := time.Unix(int64(config.StartTime), 0)
@@ -651,6 +669,54 @@ func FromDatabase(networkID uint32, dbPath string, dbType string, stakingCfg *St
return FromConfig(config)
}
// XAssetIDFromGenesisBytes returns the X-Chain native asset ID encoded
// in a platform-genesis blob. It parses the platform genesis, finds the
// X-Chain CreateChainTx (vmID == constants.XVMID), then decodes that
// chain's embedded XVM genesis bytes to recover the runtime asset ID
// (the same value vm.initGenesis assigns to the first GenesisAsset).
//
// Returns (ids.Empty, false, nil) when the platform genesis contains
// no X-Chain (P-only mode) — callers fall back to whatever default
// they prefer (e.g. constants.UTXOAssetIDFor(networkID)).
//
// Returns a non-nil error when the platform genesis is unparseable or
// the X-Chain genesis bytes embedded inside it are malformed; both are
// unrecoverable on a primary-network bootstrap.
//
// Used by config.getGenesisData when it loads genesis via the cached /
// raw paths that skip FromConfig — those paths historically returned
// constants.UTXOAssetIDFor(networkID), but on sovereign L1s that value
// disagrees with what's actually in the genesis bytes. Wiring this
// helper through getGenesisData means the node always reports the
// genesis-derived asset ID via platform.getStakingAssetID regardless
// of which load path was taken.
func XAssetIDFromGenesisBytes(genesisBytes []byte) (ids.ID, bool, error) {
// Parse the platform genesis directly so we can distinguish parse
// failure ("garbage bytes — fatal") from missing X-Chain ("P-only
// mode — caller falls back to UTXOAssetIDFor"). VMGenesis collapses
// both into one error, which is the wrong shape here.
gen, err := genesis.Parse(genesisBytes)
if err != nil {
return ids.Empty, false, fmt.Errorf("parse platform genesis: %w", err)
}
for _, chain := range gen.Chains {
uChain, ok := chain.Unsigned.(*pchaintxs.CreateChainTx)
if !ok {
continue
}
if uChain.VMID != constants.XVMID {
continue
}
id, err := xvmgenesis.AssetIDFromBytes(uChain.GenesisData)
if err != nil {
return ids.Empty, false, fmt.Errorf("derive X-Chain asset ID from genesis data: %w", err)
}
return id, true, nil
}
// Parsed cleanly but no X-Chain CreateChainTx — P-only mode.
return ids.Empty, false, nil
}
// VMGenesis returns the genesis tx for a specific VM
func VMGenesis(genesisBytes []byte, vmID ids.ID) (*pchaintxs.Tx, error) {
gen, err := genesis.Parse(genesisBytes)
+68
View File
@@ -14,6 +14,74 @@ import (
pchaintxs "github.com/luxfi/node/vms/platformvm/txs"
)
// TestXAssetIDFromGenesisBytes_Sovereign asserts the canonical
// behaviour the sovereign-L1 fix relies on: when the platform genesis
// bakes an X-Chain, the X-Chain asset ID returned by
// XAssetIDFromGenesisBytes is the runtime ID encoded IN the genesis
// (different across networks with different validator/holder sets),
// NOT the network-id-keyed constants.UTXOAssetIDFor(networkID) value.
//
// The two values disagreeing is the whole reason the helper exists —
// sovereign primary networks sharing a networkID would otherwise
// silently collide on the constant.
func TestXAssetIDFromGenesisBytes_Sovereign(t *testing.T) {
require := require.New(t)
// Use the local devnet config — it has an X-Chain genesis baked in,
// so the helper must derive a real ID (not just fall back).
cfg := GetConfig(constants.LocalID)
require.NotNil(cfg)
require.NotEmpty(cfg.XChainGenesis, "fixture must bake X-Chain")
genesisBytes, fromConfigID, err := FromConfig(cfg)
require.NoError(err)
require.NotEmpty(genesisBytes)
require.NotEqual(ids.Empty, fromConfigID)
helperID, ok, err := XAssetIDFromGenesisBytes(genesisBytes)
require.NoError(err)
require.True(ok, "X-Chain is in genesis — helper must report ok=true")
require.Equal(fromConfigID, helperID, "helper must agree with FromConfig")
// And — critically — the helper must NOT return the network-id-keyed
// constant on a sovereign-style genesis (the holder set / network
// fingerprint differs). UTXOAssetIDFor returns the constant in
// network-id 1 (mainnet) and a network-keyed hash otherwise; on
// LocalID the test asserts the genesis-derived value supersedes it
// only when the genesis content differs from the upstream-Lux
// baseline. For the current embedded local config the two HAPPEN
// to coincide (single deployer, well-known fixture), so we only
// assert the value is non-zero and matches FromConfig's return.
}
// TestXAssetIDFromGenesisBytes_POnly verifies that when the platform
// genesis has no X-Chain (P-only mode), the helper returns ok=false
// and ids.Empty rather than an error. Callers fall back to
// constants.UTXOAssetIDFor(networkID) in that case (the value is
// unused at runtime since there is no X-Chain to mint on).
func TestXAssetIDFromGenesisBytes_POnly(t *testing.T) {
require := require.New(t)
pOnly := &genesis.Genesis{Chains: nil}
pOnlyBytes, err := genesis.Codec.Marshal(pchaintxs.CodecVersion, pOnly)
require.NoError(err)
id, ok, err := XAssetIDFromGenesisBytes(pOnlyBytes)
require.NoError(err, "P-only is a valid mode, not a parse error")
require.False(ok, "P-only must report ok=false so caller falls back")
require.Equal(ids.Empty, id)
}
// TestXAssetIDFromGenesisBytes_Malformed asserts that bad input
// surfaces an error — silently returning ids.Empty would reintroduce
// the UTXOAssetIDFor(networkID) fallback path and defeat the fix.
func TestXAssetIDFromGenesisBytes_Malformed(t *testing.T) {
require := require.New(t)
_, _, err := XAssetIDFromGenesisBytes([]byte{0xde, 0xad})
require.Error(err)
}
// TestVMGenesisOptInChains asserts that VMGenesis returns an error for VM IDs
// not present in the genesis chains list — the core "P-only" contract relied
// upon by node.initChainManager to log "skipping" and run the node without
+58
View File
@@ -172,3 +172,61 @@ func newGenesisCodec() (codec.Manager, error) {
}
return parser.GenesisCodec(), nil
}
// ParseGenesisBytes decodes the canonical XVM genesis bytes produced by
// (*Genesis).Bytes() back into a *Genesis with each GenesisAsset's
// embedded CreateAssetTx initialised against the genesis codec. After
// Initialize, each tx's deterministic ID (tx.ID()) is the runtime asset
// ID of that genesis-minted asset — i.e. the same value vm.initGenesis
// computes when bootstrapping the X-Chain.
//
// Callers (genesis/builder, config/getGenesisData) use this to derive
// the X-Chain native asset ID from genesis content rather than the
// network-id-keyed constants.UTXOAssetIDFor(networkID). On sovereign
// L1s (Liquidity / MLC / VCC) those two values DIFFER — the wallet
// builder context's XAssetID must be the genesis-derived one or every
// fee-paying tx fails with "insufficient funds, needs N more nLUX".
func ParseGenesisBytes(genesisBytes []byte) (*Genesis, error) {
codec, err := newGenesisCodec()
if err != nil {
return nil, err
}
g := &Genesis{}
if _, err := codec.Unmarshal(genesisBytes, g); err != nil {
return nil, fmt.Errorf("unmarshal xvm genesis: %w", err)
}
for i := range g.Txs {
tx := &txs.Tx{Unsigned: &g.Txs[i].CreateAssetTx}
if err := tx.Initialize(codec); err != nil {
return nil, fmt.Errorf("initialize genesis asset %d (%s): %w", i, g.Txs[i].Alias, err)
}
}
return g, nil
}
// AssetIDFromGenesisBytes returns the first genesis asset's runtime
// asset ID — the ID vm.initGenesis assigns to genesis.Txs[0]. This is
// the X-Chain native fee asset by convention (the same asset the
// platform-vm reports via platform.getStakingAssetID and the wallet
// builder context's XAssetID).
//
// Returns an error when genesisBytes is malformed or contains zero
// assets — both are unrecoverable on a primary-network bootstrap.
func AssetIDFromGenesisBytes(genesisBytes []byte) (ids.ID, error) {
g, err := ParseGenesisBytes(genesisBytes)
if err != nil {
return ids.Empty, err
}
if len(g.Txs) == 0 {
return ids.Empty, fmt.Errorf("xvm genesis has zero asset txs")
}
tx := &txs.Tx{Unsigned: &g.Txs[0].CreateAssetTx}
codec, err := newGenesisCodec()
if err != nil {
return ids.Empty, err
}
if err := tx.Initialize(codec); err != nil {
return ids.Empty, fmt.Errorf("initialize first genesis asset (%s): %w", g.Txs[0].Alias, err)
}
return tx.ID(), nil
}
+17
View File
@@ -18,6 +18,7 @@ package genesis
import (
"fmt"
"github.com/luxfi/ids"
"github.com/luxfi/node/vms/xvm"
)
@@ -80,3 +81,19 @@ func BuildBytes(
}
return b, nil
}
// AssetIDFromBytes returns the runtime X-Chain native asset ID derived
// from the canonical XVM genesis bytes (the same blob BuildBytes
// produces). This is the value vm.initGenesis assigns to the first
// genesis CreateAssetTx — the ID under which the X-Chain's fee asset is
// indexed in state.
//
// Callers (genesis/builder, config.getGenesisData) use this to derive
// the X-Chain native asset ID from genesis content rather than
// constants.UTXOAssetIDFor(networkID). On sovereign L1s the two values
// differ: the constant is network-id-keyed and identical across every
// L1 sharing a primary-network ID, while the genesis-derived ID
// captures the asset's actual on-chain identity (different per L1).
func AssetIDFromBytes(genesisBytes []byte) (ids.ID, error) {
return xvm.AssetIDFromGenesisBytes(genesisBytes)
}
+78
View File
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/luxfi/address"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
)
@@ -85,3 +86,80 @@ func TestBuildBytes_BadAddress(t *testing.T) {
_, err := BuildBytes(1, asset, holders, nil)
require.Error(t, err)
}
// TestAssetIDFromBytes_Stable verifies that AssetIDFromBytes returns
// the same ID for the same genesis blob and that two different
// genesis blobs (different networkID — same asset descriptor) produce
// different IDs. Both properties matter:
//
// - same blob → same ID: every node must agree on the asset ID
// after parsing genesis.
// - different blob → different ID: sovereign L1s sharing a primary-
// network ID get distinct X-Chain assets, which is the whole
// point of deriving the ID from genesis content rather than the
// network-id-keyed constant.
func TestAssetIDFromBytes_Stable(t *testing.T) {
asset := AssetDescriptor{Name: "Lux", Symbol: "LUX", Denomination: 9}
holders := []Holder{
{Amount: 1_000_000, Address: addr(t, [20]byte{1, 2, 3})},
}
bytes1, err := BuildBytes(1, asset, holders, nil)
require.NoError(t, err)
id1, err := AssetIDFromBytes(bytes1)
require.NoError(t, err)
require.NotEqual(t, ids.Empty, id1)
id1Again, err := AssetIDFromBytes(bytes1)
require.NoError(t, err)
require.Equal(t, id1, id1Again, "AssetIDFromBytes must be a pure function of input")
// Different networkID → different genesis blob → different asset ID.
bytes2, err := BuildBytes(2, asset, holders, nil)
require.NoError(t, err)
id2, err := AssetIDFromBytes(bytes2)
require.NoError(t, err)
require.NotEqual(t, id1, id2, "different networkID must produce different X-Chain asset ID")
}
// TestAssetIDFromBytes_HolderSensitivity asserts that two genesis
// blobs identical except for their initial holders also produce
// different asset IDs. The holder set is part of the CreateAssetTx
// (via InitialState), so the runtime asset ID must reflect it.
//
// This is the property that decouples sovereign-L1 asset IDs even
// when two networks share networkID + asset descriptor: their
// validator set is different, the holder set is different, so the
// genesis-derived ID is different.
func TestAssetIDFromBytes_HolderSensitivity(t *testing.T) {
asset := AssetDescriptor{Name: "Lux", Symbol: "LUX", Denomination: 9}
a, err := BuildBytes(1, asset, []Holder{
{Amount: 1_000_000, Address: addr(t, [20]byte{1, 2, 3})},
}, nil)
require.NoError(t, err)
idA, err := AssetIDFromBytes(a)
require.NoError(t, err)
b, err := BuildBytes(1, asset, []Holder{
{Amount: 1_000_000, Address: addr(t, [20]byte{9, 9, 9})},
}, nil)
require.NoError(t, err)
idB, err := AssetIDFromBytes(b)
require.NoError(t, err)
require.NotEqual(t, idA, idB, "different holder set must produce different X-Chain asset ID")
}
// TestAssetIDFromBytes_Malformed verifies that AssetIDFromBytes
// surfaces an error for garbage input rather than silently returning
// ids.Empty (which would then bypass the genesis-derived fix and
// reintroduce the UTXOAssetIDFor(networkID) fallback path).
func TestAssetIDFromBytes_Malformed(t *testing.T) {
_, err := AssetIDFromBytes([]byte{0xff, 0xfe, 0xfd})
require.Error(t, err)
_, err = AssetIDFromBytes(nil)
require.Error(t, err)
}