node: all-VMs-as-plugins core/optional split + X-NFT-gated activation

Explicit CoreVMs{P,X,Q,Z} in-process + OptionalVMs plugin-only (no build tags; vms_dchain/nodchain removed). Two-layer anti-shadow (init panic + runtime ListFactories guard). NFT-gated activation at createChain (manager_authz), fail-closed; dex-validator flag gates D-Chain tracking; xvm.GetUTXOs locked.
This commit is contained in:
zeekay
2026-06-19 11:34:41 -07:00
parent 1b2122900c
commit 41c75fae65
17 changed files with 1734 additions and 144 deletions
+85 -5
View File
@@ -9,9 +9,9 @@ import (
"context"
"crypto"
"encoding/binary"
"github.com/go-json-experiment/json"
"errors"
"fmt"
"github.com/go-json-experiment/json"
"net/http"
"os"
"path/filepath"
@@ -352,10 +352,34 @@ type ManagerConfig struct {
CChainID ids.ID // ID of the C-Chain,
DChainID ids.ID // ID of the D-Chain (DEX),
CriticalChains set.Set[ids.ID] // Chains that can't exit gracefully
TimeoutManager timeout.Manager // Manages request timeouts when sending messages to other validators
Health health.Registerer
NetConfigs map[ids.ID]nets.Config // ID -> NetConfig
ChainConfigs map[string]ChainConfig // alias -> ChainConfig
// ChainAuthorizations gates per-validator activation of specific chains on
// X-Chain NFT ownership: a validator may track/validate a listed chain
// only if its staking X-address (StakingXAddress) holds the required
// nftfx NFT. A nil/empty map means no chain is NFT-gated, so every chain
// behaves exactly as before. Critical chains are never gated regardless of
// this map. Node wiring (e.g. DChainID -> dex-validator collection) is set
// in node.go; see authorizeChainActivation.
ChainAuthorizations map[ids.ID]NFTAuthorization
// StakingXAddress is this node's X-Chain address derived from its staking
// keys; it is the owner whose UTXOs the NFT-authorization gate inspects.
// Empty when no chain is gated; an empty address with a gated chain causes
// that chain to be opted out (fail closed).
StakingXAddress ids.ShortID
// DexValidator is the operator's opt-in to PARTICIPATE in the D-Chain DEX
// (track/validate it + dial the venue matcher). It is a NECESSARY condition
// for D-Chain activation that composes AND-wise with the NFT gate: the
// D-Chain (DChainID) activates only if DexValidator is true AND the NFT
// requirement (if any) is satisfied. Default false means a node does NOT
// activate the D-Chain even when it is queued by --track-all-chains; the
// activation authority is authorizeChainActivation, not the platformvm
// tracking set. Only the D-Chain consults this flag; every other chain is
// untouched. Sourced from node Config.DexValidator (see node.go).
DexValidator bool
TimeoutManager timeout.Manager // Manages request timeouts when sending messages to other validators
Health health.Registerer
NetConfigs map[ids.ID]nets.Config // ID -> NetConfig
ChainConfigs map[string]ChainConfig // alias -> ChainConfig
// ShutdownNodeFunc allows the chain manager to issue a request to shutdown the node
ShutdownNodeFunc func(exitCode int)
MeterVMEnabled bool // Should each VM be wrapped with a MeterVM
@@ -425,6 +449,16 @@ type manager struct {
pendingVMChainsLock sync.RWMutex
pendingVMChains map[ids.ID][]ChainParameters
// gatedChainsLock guards the NFT-authorization gate's defer state.
// pendingGatedChains holds chains parked because the X-Chain was not yet
// bootstrapped when their activation was attempted; they are re-queued once
// the X-Chain is created (retryPendingGatedChains). gatedAttempts caps
// re-attempts per chain so a never-bootstrapping X-Chain cannot park a
// chain forever. See manager_authz.go.
gatedChainsLock sync.Mutex
pendingGatedChains []ChainParameters
gatedAttempts map[ids.ID]int
chainsLock sync.Mutex
// Key: Chain's ID
// Value: The chain
@@ -502,6 +536,7 @@ func New(config *ManagerConfig) (Manager, error) {
unblockChainCreatorCh: make(chan struct{}),
chainCreatorShutdownCh: make(chan struct{}),
pendingVMChains: make(map[ids.ID][]ChainParameters),
gatedAttempts: make(map[ids.ID]int),
luxGatherer: luxGatherer,
handlerGatherer: handlerGatherer,
@@ -563,6 +598,44 @@ func (m *manager) createChain(chainParams ChainParameters) {
sb, _ := m.Nets.GetOrCreate(chainParams.ChainID)
// NFT-authorization gate: a chain listed in ChainAuthorizations may only be
// activated if this validator's staking X-address holds the required
// X-Chain NFT. Ungated and critical chains short-circuit to authorized, so
// this is a no-op for P/C/X/Q/… and whenever ChainAuthorizations is empty.
if authorized, ready := m.authorizeChainActivation(chainParams.ID); !ready {
// The X-Chain is not bootstrapped yet, so the gate cannot decide.
// Park the chain out of the queue and retry once the X-Chain is
// created; do not skip permanently. The attempt cap bounds this.
if m.deferGatedChain(chainParams) {
m.Log.Info("deferring gated chain activation until X-Chain bootstrapped",
log.Stringer("chainID", chainParams.ID),
log.Stringer("vmID", chainParams.VMID),
)
return
}
// Cap exhausted: the X-Chain never became queryable. Opt out cleanly,
// exactly like an unauthorized chain — mark bootstrapped, no failing
// health check.
m.Log.Warn("opting out of gated chain: X-Chain did not bootstrap in time",
log.Stringer("chainID", chainParams.ID),
log.Stringer("vmID", chainParams.VMID),
)
sb.Bootstrapped(chainParams.ID)
return
} else if !authorized {
// Clean opt-out: this validator does not hold the required NFT. Mirror
// the VM-plugin-not-loaded skip exactly — mark the slot bootstrapped
// and return WITHOUT a failing health check, so the node stays healthy
// while declining to validate a chain it is not authorized for.
m.Log.Info("chain activation not authorized — validator X-address does not hold the required NFT",
log.Stringer("chainID", chainParams.ChainID),
log.Stringer("chainID", chainParams.ID),
log.Stringer("vmID", chainParams.VMID),
)
sb.Bootstrapped(chainParams.ID)
return
}
// Note: buildChain builds all chain's relevant objects (notably engine and handler)
// but does not start their operations. Starting of the handler (which could potentially
// issue some internal messages), is delayed until chain dispatching is started and
@@ -698,6 +771,13 @@ func (m *manager) createChain(chainParams ChainParameters) {
m.chains[chainParams.ID] = chain
m.chainsLock.Unlock()
// The X-Chain is now tracked, so the NFT-authorization gate can query it.
// Re-queue any gated chains that were parked waiting for it (no-op when
// nothing is parked or this is not the X-Chain).
if chainParams.ID == m.XChainID {
m.retryPendingGatedChains()
}
// Associate the newly created chain with its default alias
if err := m.Alias(chainParams.ID, chainParams.ID.String()); err != nil {
m.Log.Error("failed to alias the new chain with itself",
+220
View File
@@ -0,0 +1,220 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chains
import (
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
lux "github.com/luxfi/utxo"
"github.com/luxfi/utxo/nftfx"
)
// maxGatedActivationAttempts bounds how many times a gated chain may be
// deferred waiting for the X-Chain to bootstrap before it is opted out. Each
// X-Chain creation re-queues parked chains exactly once, so this also bounds
// total re-queues per chain. It exists only as defense-in-depth against an
// X-Chain that never bootstraps; the normal path defers zero or one time.
const maxGatedActivationAttempts = 8
// NFTAuthorization identifies the X-Chain NFT a validator's staking address
// must hold to activate (track/validate) a gated chain.
//
// A chain is "gated" when ManagerConfig.ChainAuthorizations has an entry for
// its blockchain ID. Chains with no entry are ungated and always authorized —
// so a nil/empty ChainAuthorizations map preserves today's behavior exactly
// for every chain (P/C/X/Q/D/…).
type NFTAuthorization struct {
AssetID ids.ID // X-Chain nftfx asset (the collection)
GroupID uint32 // nftfx group within the collection; 0 = any group
}
// xChainUTXOReader is the narrow in-process accessor the gate needs from the
// X-Chain VM: list the UTXOs owned by an address set. *xvm.VM satisfies it via
// its GetUTXOs method (vms/xvm/vm.go), which wraps lux.GetAllUTXOs over the
// chain's committed state. Keeping the interface to this one method avoids
// importing the concrete xvm VM type into the chain manager.
type xChainUTXOReader interface {
GetUTXOs(addrs set.Set[ids.ShortID]) ([]*lux.UTXO, error)
}
// holdsAuthorizationNFT is the pure authorization policy: it reports whether
// [utxos] contains an nftfx transfer output that satisfies [auth]. A UTXO
// matches when its output is an *nftfx.TransferOutput for auth.AssetID and,
// when auth.GroupID is non-zero, its GroupID equals auth.GroupID (GroupID 0
// means "any group in the collection").
//
// This is the one place the NFT-ownership rule lives. It takes plain values
// (the fetched UTXOs and the requirement) and returns a decision, so it is
// unit-testable without standing up a manager or an X-Chain.
func holdsAuthorizationNFT(utxos []*lux.UTXO, auth NFTAuthorization) bool {
for _, utxo := range utxos {
out, ok := utxo.Out.(*nftfx.TransferOutput)
if !ok {
continue
}
if utxo.AssetID() != auth.AssetID {
continue
}
if auth.GroupID != 0 && out.GroupID != auth.GroupID {
continue
}
return true
}
return false
}
// authorizeChainActivation reports whether this node may activate
// (track/validate) [chainID].
//
// Returns (authorized, ready):
// - ready=false means the gate cannot decide yet because the X-Chain is not
// bootstrapped and therefore not queryable in-process. The caller MUST
// defer (re-attempt later), not skip — see createChain.
// - ready=true, authorized=true: proceed normally.
// - ready=true, authorized=false: clean opt-out — this validator's staking
// X-address does not hold the required NFT.
//
// Decision order:
// 1. Critical chains (P/C/X/…) are never gated — always (true, true).
// 2. Ungated chains (no ChainAuthorizations entry) are always (true, true),
// so the zero/nil map is a no-op for every chain.
// 3. Gated chain, X-Chain not bootstrapped → (false, false): defer.
// 4. Gated chain, X-Chain bootstrapped, but no usable in-process UTXO reader
// or no resolvable staking X-address → (false, true): clean opt-out. The
// node refuses to activate a gated chain it cannot prove authorization
// for, rather than fail open.
// 5. Otherwise query the X-Chain for the staking X-address's UTXOs and apply
// holdsAuthorizationNFT.
func (m *manager) authorizeChainActivation(chainID ids.ID) (authorized bool, ready bool) {
// Critical chains are foundational; the gate must never skip or defer them.
if m.CriticalChains.Contains(chainID) {
return true, true
}
// D-Chain participation is an operator opt-in (dex-validator), and it is a
// NECESSARY condition that composes AND-wise with the NFT gate below: the
// D-Chain activates only if the operator opted in AND (when configured) the
// staking X-address holds the dex-operator NFT. Checked here — before the
// ungated short-circuit — so the opt-in is required even when no operator
// collection is configured for this network (the otherwise-ungated case).
// A node without dex-validator therefore cleanly opts out of the D-Chain
// even when --track-all-chains queued it: this is the activation authority,
// not the platformvm tracking set. The opt-out is decided (ready=true), so
// the caller takes the same clean skip as a non-held NFT / absent plugin.
if m.DChainID != ids.Empty && chainID == m.DChainID && !m.DexValidator {
m.Log.Info("D-Chain activation declined: dex-validator opt-in is disabled",
log.Stringer("chainID", chainID),
)
return false, true
}
auth, gated := m.ChainAuthorizations[chainID]
if !gated {
return true, true
}
// TODO(phase-2): proof-of-possession. holdsAuthorizationNFT below checks
// ownership-OF-RECORD (the staking X-address appears in an nftfx output's
// owners) — it does NOT prove the node controls the key for that address.
// Phase-2 must derive StakingXAddress from the node's staking keys and bind
// the NFT check to key-control (a signed challenge), so a node cannot claim
// authorization from an NFT held at an address it does not control.
// The gate consults the X-Chain UTXO set, so the X-Chain must already be
// bootstrapped (created and tracked) on this node. If not, we cannot decide
// yet — signal the caller to defer.
if !m.IsBootstrapped(m.XChainID) {
return false, false
}
// The staking X-address must be wired for any gated chain. If it is empty,
// the node has no identity to check NFT ownership against; refuse to
// activate the gated chain (fail closed) rather than guess.
if m.StakingXAddress == ids.ShortEmpty {
m.Log.Warn("gated chain activation blocked: staking X-address not configured",
log.Stringer("chainID", chainID),
log.Stringer("assetID", auth.AssetID),
)
return false, true
}
reader := m.xChainUTXOReader()
if reader == nil {
m.Log.Warn("gated chain activation blocked: X-Chain UTXO reader unavailable",
log.Stringer("chainID", chainID),
log.Stringer("assetID", auth.AssetID),
)
return false, true
}
owners := set.Of(m.StakingXAddress)
utxos, err := reader.GetUTXOs(owners)
if err != nil {
// A query error is not a "decline" — the X-Chain claims to be
// bootstrapped but cannot answer. Defer and retry rather than
// permanently opt out on a transient read failure.
m.Log.Warn("gated chain activation deferred: X-Chain UTXO query failed",
log.Stringer("chainID", chainID),
log.Stringer("assetID", auth.AssetID),
log.Err(err),
)
return false, false
}
return holdsAuthorizationNFT(utxos, auth), true
}
// xChainUTXOReader returns the in-process UTXO reader for the X-Chain, or nil
// if the X-Chain VM is not present or does not expose the accessor. The
// X-Chain VM (*xvm.VM) implements xChainUTXOReader; this asserts against the
// narrow interface only.
func (m *manager) xChainUTXOReader() xChainUTXOReader {
m.chainsLock.Lock()
info, ok := m.chains[m.XChainID]
m.chainsLock.Unlock()
if !ok {
return nil
}
reader, _ := info.VM.(xChainUTXOReader)
return reader
}
// deferGatedChain parks [chainParams] out of the creation queue because the
// X-Chain is not yet bootstrapped, and reports whether the chain may still be
// retried. It returns false once the per-chain attempt cap is exhausted, in
// which case the caller must opt the chain out (a never-bootstrapping X-Chain
// must not park a chain forever). Parking out of the queue — rather than
// re-pushing — is what keeps the single-threaded dispatch loop from
// busy-spinning; retryPendingGatedChains re-pushes when the X-Chain is created.
func (m *manager) deferGatedChain(chainParams ChainParameters) (retry bool) {
m.gatedChainsLock.Lock()
defer m.gatedChainsLock.Unlock()
m.gatedAttempts[chainParams.ID]++
if m.gatedAttempts[chainParams.ID] > maxGatedActivationAttempts {
return false
}
m.pendingGatedChains = append(m.pendingGatedChains, chainParams)
return true
}
// retryPendingGatedChains re-queues every chain parked by deferGatedChain. It
// is called once right after the X-Chain finishes createChain, so the gate can
// now reach the X-Chain UTXO set. Chains that still cannot decide will park
// again (bounded by maxGatedActivationAttempts).
func (m *manager) retryPendingGatedChains() {
m.gatedChainsLock.Lock()
parked := m.pendingGatedChains
m.pendingGatedChains = nil
m.gatedChainsLock.Unlock()
for _, chainParams := range parked {
m.Log.Info("re-queuing gated chain after X-Chain bootstrap",
log.Stringer("chainID", chainParams.ID),
log.Stringer("vmID", chainParams.VMID),
)
m.chainsQueue.PushRight(chainParams)
}
}
+560
View File
@@ -0,0 +1,560 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chains
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/container/buffer"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
lux "github.com/luxfi/utxo"
"github.com/luxfi/utxo/nftfx"
"github.com/luxfi/utxo/secp256k1fx"
)
// fakeUTXOReader is a stand-in for the X-Chain VM in unit tests. It returns a
// fixed UTXO set (or an error) regardless of the requested addresses, which is
// all the gate's address-set query needs to be driven deterministically.
type fakeUTXOReader struct {
utxos []*lux.UTXO
err error
}
func (f *fakeUTXOReader) GetUTXOs(set.Set[ids.ShortID]) ([]*lux.UTXO, error) {
return f.utxos, f.err
}
// ownerScopedUTXOReader is a faithful X-Chain stand-in: it returns ONLY the
// UTXOs whose nftfx/secp owners intersect the queried address set, exactly as
// lux.GetAllUTXOs(vm.state, addrs) does over committed state. This is what the
// fixed-list fakeUTXOReader cannot model — and it is required to test the
// ownership-scoping invariant: a node may activate a gated chain only on an NFT
// held at ITS OWN staking X-address, never one held at someone else's. The gate
// queries set.Of(StakingXAddress), so any UTXO owned by a different address is
// invisible here, just as it would be on a real X-Chain.
type ownerScopedUTXOReader struct {
utxos []*lux.UTXO
}
func (r *ownerScopedUTXOReader) GetUTXOs(addrs set.Set[ids.ShortID]) ([]*lux.UTXO, error) {
var out []*lux.UTXO
for _, utxo := range r.utxos {
owned, ok := utxo.Out.(lux.Addressable)
if !ok {
continue
}
for _, raw := range owned.Addresses() {
addr, err := ids.ToShortID(raw)
if err != nil {
continue
}
if addrs.Contains(addr) {
out = append(out, utxo)
break
}
}
}
return out, nil
}
// nftUTXO builds a UTXO whose output is an nftfx transfer output for the given
// asset and group. owner is the address recorded as the holder.
func nftUTXO(assetID ids.ID, groupID uint32, owner ids.ShortID) *lux.UTXO {
return &lux.UTXO{
UTXOID: lux.UTXOID{TxID: ids.GenerateTestID()},
Asset: lux.Asset{ID: assetID},
Out: &nftfx.TransferOutput{
GroupID: groupID,
OutputOwners: secp256k1fx.OutputOwners{
Threshold: 1,
Addrs: []ids.ShortID{owner},
},
},
}
}
// secpUTXO builds a non-NFT (secp256k1 transfer) UTXO for the given asset, used
// to prove the gate ignores outputs that are not nftfx transfers.
func secpUTXO(assetID ids.ID, owner ids.ShortID) *lux.UTXO {
return &lux.UTXO{
UTXOID: lux.UTXOID{TxID: ids.GenerateTestID()},
Asset: lux.Asset{ID: assetID},
Out: &secp256k1fx.TransferOutput{
Amt: 1,
OutputOwners: secp256k1fx.OutputOwners{
Threshold: 1,
Addrs: []ids.ShortID{owner},
},
},
}
}
func TestHoldsAuthorizationNFT(t *testing.T) {
asset := ids.GenerateTestID()
other := ids.GenerateTestID()
owner := ids.GenerateTestShortID()
tests := []struct {
name string
utxos []*lux.UTXO
auth NFTAuthorization
want bool
}{
{
name: "empty set",
utxos: nil,
auth: NFTAuthorization{AssetID: asset},
want: false,
},
{
name: "matching asset, any group",
utxos: []*lux.UTXO{nftUTXO(asset, 7, owner)},
auth: NFTAuthorization{AssetID: asset, GroupID: 0},
want: true,
},
{
name: "matching asset and group",
utxos: []*lux.UTXO{nftUTXO(asset, 7, owner)},
auth: NFTAuthorization{AssetID: asset, GroupID: 7},
want: true,
},
{
name: "matching asset, wrong group",
utxos: []*lux.UTXO{nftUTXO(asset, 7, owner)},
auth: NFTAuthorization{AssetID: asset, GroupID: 8},
want: false,
},
{
name: "wrong asset",
utxos: []*lux.UTXO{nftUTXO(other, 7, owner)},
auth: NFTAuthorization{AssetID: asset},
want: false,
},
{
name: "non-nft output for asset is ignored",
utxos: []*lux.UTXO{secpUTXO(asset, owner)},
auth: NFTAuthorization{AssetID: asset},
want: false,
},
{
name: "match found among mixed outputs",
utxos: []*lux.UTXO{
secpUTXO(asset, owner),
nftUTXO(other, 1, owner),
nftUTXO(asset, 3, owner),
},
auth: NFTAuthorization{AssetID: asset, GroupID: 3},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, holdsAuthorizationNFT(tt.utxos, tt.auth))
})
}
}
// newGateManager builds the minimal manager needed to exercise the gate:
// only the fields authorizeChainActivation reads. xChainVM, when non-nil, is
// installed as the X-Chain's tracked VM so IsBootstrapped(XChainID) is true and
// xChainUTXOReader() resolves to it.
func newGateManager(
xChainID ids.ID,
stakingAddr ids.ShortID,
authz map[ids.ID]NFTAuthorization,
critical set.Set[ids.ID],
xChainVM xChainUTXOReader,
) *manager {
m := &manager{
chains: make(map[ids.ID]*chainInfo),
gatedAttempts: make(map[ids.ID]int),
}
m.Log = log.NewNoOpLogger()
m.XChainID = xChainID
m.StakingXAddress = stakingAddr
m.ChainAuthorizations = authz
m.CriticalChains = critical
if xChainVM != nil {
m.chains[xChainID] = &chainInfo{Name: "X-Chain", VM: xChainVM}
}
return m
}
func TestAuthorizeChainActivation(t *testing.T) {
xChainID := ids.GenerateTestID()
gatedChain := ids.GenerateTestID()
asset := ids.GenerateTestID()
addr := ids.GenerateTestShortID()
collection := map[ids.ID]NFTAuthorization{
gatedChain: {AssetID: asset, GroupID: 0},
}
t.Run("ungated chain is authorized and ready", func(t *testing.T) {
// No ChainAuthorizations entry: behaves exactly as today, even with no
// X-Chain present. Covers P/C/X-like IDs via a table.
m := newGateManager(xChainID, addr, nil, nil, nil)
for _, id := range []ids.ID{ids.GenerateTestID(), xChainID, gatedChain} {
authorized, ready := m.authorizeChainActivation(id)
require.True(t, authorized, "id %s", id)
require.True(t, ready, "id %s", id)
}
})
t.Run("gated chain, X-Chain not bootstrapped, defers", func(t *testing.T) {
// xChainVM nil => XChainID not in m.chains => IsBootstrapped false.
m := newGateManager(xChainID, addr, collection, nil, nil)
authorized, ready := m.authorizeChainActivation(gatedChain)
require.False(t, ready, "must signal defer")
require.False(t, authorized)
})
t.Run("gated chain, holder, authorized", func(t *testing.T) {
reader := &fakeUTXOReader{utxos: []*lux.UTXO{nftUTXO(asset, 0, addr)}}
m := newGateManager(xChainID, addr, collection, nil, reader)
authorized, ready := m.authorizeChainActivation(gatedChain)
require.True(t, ready)
require.True(t, authorized)
})
t.Run("gated chain, non-holder, clean opt-out", func(t *testing.T) {
reader := &fakeUTXOReader{utxos: nil}
m := newGateManager(xChainID, addr, collection, nil, reader)
authorized, ready := m.authorizeChainActivation(gatedChain)
require.True(t, ready, "decided, not deferred")
require.False(t, authorized, "no NFT => opt out")
})
t.Run("group match vs mismatch", func(t *testing.T) {
groupCollection := map[ids.ID]NFTAuthorization{
gatedChain: {AssetID: asset, GroupID: 5},
}
hold5 := &fakeUTXOReader{utxos: []*lux.UTXO{nftUTXO(asset, 5, addr)}}
hold9 := &fakeUTXOReader{utxos: []*lux.UTXO{nftUTXO(asset, 9, addr)}}
authorized, ready := newGateManager(xChainID, addr, groupCollection, nil, hold5).
authorizeChainActivation(gatedChain)
require.True(t, ready)
require.True(t, authorized, "group 5 held => authorized")
authorized, ready = newGateManager(xChainID, addr, groupCollection, nil, hold9).
authorizeChainActivation(gatedChain)
require.True(t, ready)
require.False(t, authorized, "wrong group => opt out")
})
t.Run("critical chain never skipped even if gated", func(t *testing.T) {
// gatedChain is in BOTH ChainAuthorizations and CriticalChains, the
// holder holds nothing, and the X-Chain is absent. A non-critical chain
// here would defer (not ready); a critical chain must be authorized.
critical := set.Of(gatedChain)
m := newGateManager(xChainID, addr, collection, critical, nil)
authorized, ready := m.authorizeChainActivation(gatedChain)
require.True(t, authorized, "critical chain must never be gated")
require.True(t, ready, "critical chain must never defer")
})
t.Run("gated chain, empty staking address, fails closed", func(t *testing.T) {
reader := &fakeUTXOReader{utxos: []*lux.UTXO{nftUTXO(asset, 0, addr)}}
m := newGateManager(xChainID, ids.ShortEmpty, collection, nil, reader)
authorized, ready := m.authorizeChainActivation(gatedChain)
require.True(t, ready, "decided")
require.False(t, authorized, "no staking identity => opt out")
})
t.Run("gated chain, X-Chain query error, defers", func(t *testing.T) {
reader := &fakeUTXOReader{err: errors.New("state not ready")}
m := newGateManager(xChainID, addr, collection, nil, reader)
authorized, ready := m.authorizeChainActivation(gatedChain)
require.False(t, ready, "transient query error => retry, not opt out")
require.False(t, authorized)
})
t.Run("gated chain, X-Chain tracked but VM lacks reader, opts out", func(t *testing.T) {
// X-Chain is in m.chains (IsBootstrapped true) but its VM does not
// satisfy xChainUTXOReader. The gate must fail closed (ready, !authz),
// not panic.
m := newGateManager(xChainID, addr, collection, nil, nil)
m.chains[xChainID] = &chainInfo{Name: "X-Chain", VM: struct{}{}}
authorized, ready := m.authorizeChainActivation(gatedChain)
require.True(t, ready)
require.False(t, authorized)
})
}
func TestDeferGatedChainCap(t *testing.T) {
xChainID := ids.GenerateTestID()
gatedChain := ids.GenerateTestID()
m := newGateManager(xChainID, ids.GenerateTestShortID(), nil, nil, nil)
params := ChainParameters{ID: gatedChain, VMID: ids.GenerateTestID()}
// The first maxGatedActivationAttempts calls park the chain and allow retry.
for i := 0; i < maxGatedActivationAttempts; i++ {
require.True(t, m.deferGatedChain(params), "attempt %d should allow retry", i+1)
}
// One past the cap opts out instead of parking again.
require.False(t, m.deferGatedChain(params), "cap exhausted")
// Every allowed attempt parked exactly one entry; the over-cap attempt did not.
m.gatedChainsLock.Lock()
require.Len(t, m.pendingGatedChains, maxGatedActivationAttempts)
m.gatedChainsLock.Unlock()
}
func TestRetryPendingGatedChainsRequeues(t *testing.T) {
xChainID := ids.GenerateTestID()
m := newGateManager(xChainID, ids.GenerateTestShortID(), nil, nil, nil)
m.chainsQueue = buffer.NewUnboundedBlockingDeque[ChainParameters](initialQueueSize)
parked := ChainParameters{ID: ids.GenerateTestID(), VMID: ids.GenerateTestID()}
require.True(t, m.deferGatedChain(parked))
m.retryPendingGatedChains()
// Parked list is drained and the chain is re-pushed onto the creation queue.
m.gatedChainsLock.Lock()
require.Empty(t, m.pendingGatedChains)
m.gatedChainsLock.Unlock()
got, ok := m.chainsQueue.PopLeft()
require.True(t, ok)
require.Equal(t, parked.ID, got.ID)
}
// dexGatedManager builds a manager whose D-Chain (dChainID) is NFT-gated on a
// synthetic dex-operator collection — exactly the ChainAuthorizations shape
// node.chainAuthorizationsFor produces for dexvm: dChainID -> {dexAsset,
// group 0}. reader is installed as the bootstrapped X-Chain so the gate can
// query the staking address's UTXOs. This is the chain-manager-side mirror of
// the OptionalVMs[DexVMID].RequiredNFT policy under a synthetic per-network
// asset.
func dexGatedManager(dexAsset ids.ID, staking ids.ShortID, reader xChainUTXOReader) (*manager, ids.ID) {
xChainID := ids.GenerateTestID()
dChainID := ids.GenerateTestID()
authz := map[ids.ID]NFTAuthorization{
dChainID: {AssetID: dexAsset, GroupID: 0}, // group 0 = any group in the collection
}
m := newGateManager(xChainID, staking, authz, nil, reader)
// Wire the D-Chain identity and turn the operator opt-in ON, so these NFT
// tests exercise the REAL dex-validator path: dex-validator=true, then the
// NFT gate decides. (The dex-validator=false short-circuit is proven
// separately in TestDexValidatorFlagGatesDChain.)
m.DChainID = dChainID
m.DexValidator = true
return m, dChainID
}
// TestDexVMFailsClosedWithoutXNFT (#4). A node whose staking X-address holds NO
// dex-operator NFT is NOT authorized to activate the D-Chain — a clean
// fail-closed opt-out (ready=true, authorized=false), not a fail-open and not a
// deferral. The X-Chain is bootstrapped and answers; the address simply holds
// nothing. Also covers holding the WRONG asset (some other NFT) → still opt-out.
func TestDexVMFailsClosedWithoutXNFT(t *testing.T) {
dexAsset := ids.GenerateTestID()
otherAsset := ids.GenerateTestID()
staking := ids.GenerateTestShortID()
t.Run("no NFT held => opt out", func(t *testing.T) {
reader := &fakeUTXOReader{utxos: nil}
m, dChainID := dexGatedManager(dexAsset, staking, reader)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready, "X-Chain answered => decided, not deferred")
require.False(t, authorized, "no dex-operator NFT => D-Chain activation fails closed")
})
t.Run("wrong collection held => opt out", func(t *testing.T) {
reader := &fakeUTXOReader{utxos: []*lux.UTXO{nftUTXO(otherAsset, 0, staking)}}
m, dChainID := dexGatedManager(dexAsset, staking, reader)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "holding a different collection's NFT must not authorize the D-Chain")
})
t.Run("no staking identity => fail closed", func(t *testing.T) {
// Even with a matching NFT in the set, an empty staking X-address has no
// identity to check ownership against → fail closed.
reader := &fakeUTXOReader{utxos: []*lux.UTXO{nftUTXO(dexAsset, 0, staking)}}
m, dChainID := dexGatedManager(dexAsset, ids.ShortEmpty, reader)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "no staking identity => fail closed")
})
}
// TestDexVMActivatesWithDexOperatorNFT (#5). The same gated D-Chain, but the
// staking X-address holds the dex-operator NFT → authorized to activate.
// Proves the positive path of the NFT-governed activation.
func TestDexVMActivatesWithDexOperatorNFT(t *testing.T) {
dexAsset := ids.GenerateTestID()
staking := ids.GenerateTestShortID()
t.Run("dex-operator NFT held => authorized", func(t *testing.T) {
reader := &fakeUTXOReader{utxos: []*lux.UTXO{nftUTXO(dexAsset, 0, staking)}}
m, dChainID := dexGatedManager(dexAsset, staking, reader)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.True(t, authorized, "holding the dex-operator NFT authorizes D-Chain activation")
})
t.Run("dex-operator NFT among mixed UTXOs => authorized", func(t *testing.T) {
other := ids.GenerateTestID()
reader := &fakeUTXOReader{utxos: []*lux.UTXO{
secpUTXO(dexAsset, staking), // non-NFT output for the asset: ignored
nftUTXO(other, 1, staking), // wrong collection: ignored
nftUTXO(dexAsset, 4, staking), // the dex-operator NFT (group 4, any-group gate)
}}
m, dChainID := dexGatedManager(dexAsset, staking, reader)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.True(t, authorized, "dex-operator NFT present among other UTXOs => authorized")
})
}
// TestDexVMOwnershipScoping (H1 gap). The activation NFT must be held at the
// node's OWN staking X-address — not merely exist somewhere on the X-Chain. The
// gate queries set.Of(StakingXAddress), so an ownership-honoring reader returns
// nothing for an NFT minted to a DIFFERENT address, and the gate fails closed.
// The fixed-list fakeUTXOReader could not catch this (it returns its list for
// any query); ownerScopedUTXOReader models real per-address X-Chain state.
//
// NOTE: this proves ownership-OF-RECORD scoping (the NFT is owned by this
// address). It does NOT prove the node controls that address's key — that is
// the // TODO(phase-2): proof-of-possession seam in authorizeChainActivation,
// out of scope this pass.
func TestDexVMOwnershipScoping(t *testing.T) {
dexAsset := ids.GenerateTestID()
nodeAddr := ids.GenerateTestShortID() // this node's staking X-address
strangerAddr := ids.GenerateTestShortID() // a DIFFERENT operator's address
t.Run("dex-operator NFT held by a DIFFERENT address => not authorized", func(t *testing.T) {
// The collection's NFT exists, but it is owned by strangerAddr. An
// ownership-honoring X-Chain returns no UTXOs for nodeAddr's query.
reader := &ownerScopedUTXOReader{utxos: []*lux.UTXO{nftUTXO(dexAsset, 0, strangerAddr)}}
m, dChainID := dexGatedManager(dexAsset, nodeAddr, reader)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready, "X-Chain answered => decided, not deferred")
require.False(t, authorized, "an NFT held at another address must NOT authorize this node")
})
t.Run("dex-operator NFT held at the node's OWN address => authorized", func(t *testing.T) {
// Same collection, but the NFT (alongside the stranger's) is also held by
// nodeAddr. Only the node's own holding authorizes it.
reader := &ownerScopedUTXOReader{utxos: []*lux.UTXO{
nftUTXO(dexAsset, 0, strangerAddr), // not ours: invisible to our query
nftUTXO(dexAsset, 0, nodeAddr), // ours: authorizes
}}
m, dChainID := dexGatedManager(dexAsset, nodeAddr, reader)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.True(t, authorized, "an NFT held at the node's own staking X-address authorizes it")
})
t.Run("only the stranger's NFT exists, queried by stranger => authorized for stranger only", func(t *testing.T) {
// Sanity: the same reader DOES authorize the address that actually holds
// the NFT, proving the scoping is by-address and not a blanket deny.
reader := &ownerScopedUTXOReader{utxos: []*lux.UTXO{nftUTXO(dexAsset, 0, strangerAddr)}}
m, dChainID := dexGatedManager(dexAsset, strangerAddr, reader)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.True(t, authorized, "the holder address is authorized; scoping is per-address, not blanket")
})
}
// TestDexValidatorFlagGatesDChain (M1). The dex-validator opt-in is a genuine,
// NECESSARY activation condition for the D-Chain — not just a log line — and it
// composes AND-wise with the NFT gate. This proves the full truth table at the
// activation authority (authorizeChainActivation), which is downstream of how a
// chain gets QUEUED: --track-all-chains queues the D-Chain in platformvm
// (CreateChain), but the manager's gate is what ACTIVATES it. So a manager with
// DexValidator=false standing in for a --track-all-chains node MUST decline the
// D-Chain regardless of NFT state.
func TestDexValidatorFlagGatesDChain(t *testing.T) {
xChainID := ids.GenerateTestID()
dChainID := ids.GenerateTestID()
dexAsset := ids.GenerateTestID()
staking := ids.GenerateTestShortID()
// withFlag builds a D-Chain manager with the dex-validator flag set to
// [dexValidator]. When [gated] the D-Chain is NFT-gated on dexAsset and the
// reader is the bootstrapped X-Chain; when not gated, ChainAuthorizations is
// empty (the --track-all-chains, no-operator-collection case).
withFlag := func(dexValidator, gated bool, reader xChainUTXOReader) *manager {
var authz map[ids.ID]NFTAuthorization
if gated {
authz = map[ids.ID]NFTAuthorization{dChainID: {AssetID: dexAsset, GroupID: 0}}
}
m := newGateManager(xChainID, staking, authz, nil, reader)
m.DChainID = dChainID
m.DexValidator = dexValidator
return m
}
holdsNFT := func() xChainUTXOReader {
return &fakeUTXOReader{utxos: []*lux.UTXO{nftUTXO(dexAsset, 0, staking)}}
}
t.Run("flag=false + ungated (track-all-chains) => D NOT activated", func(t *testing.T) {
// The headline M1 case: even with no operator collection configured (so
// the chain would otherwise be ungated and activate under --track-all),
// dex-validator=false declines the D-Chain. Decided opt-out, not defer.
m := withFlag(false, false, nil)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready, "decided opt-out, not a deferral")
require.False(t, authorized, "dex-validator=false must block the D-Chain even under --track-all-chains")
})
t.Run("flag=false + NFT held => still NOT activated (flag is necessary)", func(t *testing.T) {
// Holding the dex-operator NFT does not rescue activation when the flag
// is off: the flag is an independent necessary condition (the AND).
m := withFlag(false, true, holdsNFT())
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "flag=false blocks D even with the NFT held")
})
t.Run("flag=true + NFT held => activated", func(t *testing.T) {
// Both conditions met: opted in AND holds the NFT.
m := withFlag(true, true, holdsNFT())
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.True(t, authorized, "dex-validator=true AND NFT held => D-Chain activates")
})
t.Run("flag=true + gated but NFT NOT held => NOT activated (NFT is necessary)", func(t *testing.T) {
// The other half of the AND: opting in does not bypass the NFT gate.
m := withFlag(true, true, &fakeUTXOReader{utxos: nil})
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.False(t, authorized, "flag=true but no NFT => still blocked")
})
t.Run("flag=true + ungated (no operator collection) => activated", func(t *testing.T) {
// Opted in and no NFT requirement configured for this network: the flag
// alone governs and the D-Chain activates (preserves today's opt-in
// behavior for the in-flag operator).
m := withFlag(true, false, nil)
authorized, ready := m.authorizeChainActivation(dChainID)
require.True(t, ready)
require.True(t, authorized, "dex-validator=true with no NFT configured => activates")
})
t.Run("flag=false does not affect a non-D gated chain", func(t *testing.T) {
// The flag is D-specific: another gated chain (e.g. bridgevm) is governed
// solely by its NFT gate, untouched by dex-validator.
otherChain := ids.GenerateTestID()
authz := map[ids.ID]NFTAuthorization{otherChain: {AssetID: dexAsset, GroupID: 0}}
m := newGateManager(xChainID, staking, authz, nil, holdsNFT())
m.DChainID = dChainID
m.DexValidator = false // off, but irrelevant to otherChain
authorized, ready := m.authorizeChainActivation(otherChain)
require.True(t, ready)
require.True(t, authorized, "dex-validator must not gate a non-D chain")
})
}
+1
View File
@@ -1749,6 +1749,7 @@ func GetNodeConfig(v *viper.Viper) (node.Config, error) {
return node.Config{}, err
}
nodeConfig.TrackAllChains = v.GetBool(TrackAllChainsKey)
nodeConfig.DexValidator = v.GetBool(DexValidatorKey)
// HTTP APIs
nodeConfig.HTTPConfig, err = getHTTPConfig(v)
+34
View File
@@ -0,0 +1,34 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package config
import (
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
)
// TestDexValidatorFlagDefaultsOff pins the runtime opt-in contract for the
// D-Chain DEX: the dex-validator flag exists, defaults to false (most nodes do
// NOT run the DEX even though the DEXVM factory is always linked), and flips to
// true when set. Participation is a runtime decision, not a compile-time one.
func TestDexValidatorFlagDefaultsOff(t *testing.T) {
require := require.New(t)
// The flag must be registered in the canonical flag set.
fs := BuildFlagSet()
flag := fs.Lookup(DexValidatorKey)
require.NotNil(flag, "dex-validator flag must be registered")
require.Equal("false", flag.DefValue, "dex-validator must default to false")
// Default (unset) reads as false.
v := viper.New()
require.NoError(v.BindPFlags(fs))
require.False(v.GetBool(DexValidatorKey), "dex-validator must read false by default")
// Explicit opt-in flips it.
v.Set(DexValidatorKey, true)
require.True(v.GetBool(DexValidatorKey), "dex-validator must read true when set")
}
+1
View File
@@ -335,6 +335,7 @@ func addNodeFlags(fs *pflag.FlagSet) {
// Chain tracking
fs.String(TrackChainsKey, "", "Comma-separated list of chain IDs to track. A node tracking chains will sync those chains and track validator uptimes. Use --track-all-chains for development networks")
fs.Bool(TrackAllChainsKey, false, "If true, track all chains automatically. Useful for development and testing networks where you want to sync all deployed chains without specifying each one")
fs.Bool(DexValidatorKey, false, "If true, this node activates and participates in the D-Chain DEX: it tracks/validates the D-Chain and dials the venue matcher engine. Default off — the DEXVM factory is always linked (D-Chain is a genesis chain), but a node does NOT activate the D-Chain unless this flag is set, even under --track-all-chains. When a network configures the dex-operator NFT collection, activation also requires the node's X-Chain staking address to hold that NFT (flag AND NFT)")
// State syncing
fs.String(StateSyncIPsKey, "", "Comma separated list of state sync peer ips to connect to. Example: 127.0.0.1:9630,127.0.0.1:9631")
+1
View File
@@ -175,6 +175,7 @@ const (
PartialSyncPrimaryNetworkKey = "partial-sync-primary-network"
TrackChainsKey = "track-chains"
TrackAllChainsKey = "track-all-chains"
DexValidatorKey = "dex-validator"
AdminAPIEnabledKey = "api-admin-enabled"
InfoAPIEnabledKey = "api-info-enabled"
KeystoreAPIEnabledKey = "api-keystore-enabled"
+37
View File
@@ -209,6 +209,43 @@ type Config struct {
TrackedChains set.Set[ids.ID] `json:"trackedChains"`
TrackAllChains bool `json:"trackAllChains"`
// DexValidator gates whether this node PARTICIPATES in the D-Chain DEX —
// i.e. tracks/validates the D-Chain and dials the venue matcher engine.
// Default false: the DEXVM factory is always registered (the D-Chain is a
// first-class genesis chain in every build), but a node only ACTIVATES the
// D-Chain when its operator opts in. The licensed/private piece is the GPU
// venue ENGINE, never the node.
//
// Enforcement is real, not advisory: this flag flows into
// chains.ManagerConfig.DexValidator and the chain manager's
// authorizeChainActivation declines the D-Chain when it is false — even when
// --track-all-chains queued the chain. It composes AND-wise with the
// X-Chain operator-NFT gate (OptionalVMs[DexVMID].RequiredNFT joined with
// NFTAuthorizationAssets): the D-Chain activates only if DexValidator is
// true AND (when a network configures the dex-operator collection) the
// node's staking X-address holds that NFT.
//
// Phase-2 (NOT built here — see participatesInDEXChain in node/node.go and
// the StakingXAddress seam in chains.ManagerConfig): binding the NFT check
// to proof-of-possession of the staking key, and mDNS local-fiber geofence
// discovery among the HFT DEX cluster. Until a network configures the
// dex-operator collection the NFT half is a no-op and this flag alone gates.
DexValidator bool `json:"dexValidator"`
// NFTAuthorizationAssets supplies, per network, the X-Chain operator-NFT
// collection asset for each NFT-gated optional VM, keyed by VMID (e.g.
// DexVMID -> the dex-operator collection asset; BridgeVMID -> the
// bridge-operator collection asset). It is the per-network VALUE half of the
// activation gate; the POLICY half (which VMs need an NFT + which group)
// lives in node.OptionalVMs. node.chainAuthorizationsFor joins the two into
// the chain manager's ChainAuthorizations map.
//
// Empty by default: minting the real operator collections is a follow-up, so
// until a network sets an asset here the corresponding chain is ungated
// (today's opt-in-flag behavior) while the gate itself stays fail-closed for
// any configured-but-unheld NFT.
NFTAuthorizationAssets map[ids.ID]ids.ID `json:"nftAuthorizationAssets"`
NetConfigs map[ids.ID]nets.Config `json:"chainConfigs"`
ChainConfigs map[string]chains.ChainConfig `json:"-"`
+69 -32
View File
@@ -1307,14 +1307,23 @@ func (n *Node) initChainManager(utxoAssetID ids.ID) error {
}
// Resolve D-Chain ID for the ManagerConfig even if D is not critical.
// The DEXVM factory loads from PluginDir (CGO=0 plugin built by the
// Dockerfile Chain VM Plugin Stage), so the ID derived from genesis is
// always meaningful regardless of in-process registration. Whether this
// node ACTIVATES the D-Chain DEX (tracks/validates it + dials the venue)
// is enforced downstream by the chain manager's authorizeChainActivation
// from ManagerConfig.DexValidator (wired below). participatesInDEXChain
// reads the same flag only to log the operator's intent here.
var dChainID ids.ID
if true {
createDexVMTx, err := builder.VMGenesis(n.Config.GenesisBytes, constants.DexVMID)
if err == nil {
dChainID = createDexVMTx.ID()
if createDexVMTx, err := builder.VMGenesis(n.Config.GenesisBytes, constants.DexVMID); err == nil {
dChainID = createDexVMTx.ID()
if n.participatesInDEXChain() {
n.Log.Info("DEX validator: D-Chain activation enabled (dex-validator=true)", "chainID", dChainID)
} else {
n.Log.Info("D-Chain not in genesis, skipping")
n.Log.Info("D-Chain in genesis but activation disabled (dex-validator=false) — not tracked even under --track-all-chains", "chainID", dChainID)
}
} else {
n.Log.Info("D-Chain not in genesis, skipping")
}
_, err := metric.MakeAndRegister(
@@ -1343,31 +1352,45 @@ func (n *Node) initChainManager(utxoAssetID ids.ID) error {
n.chainManager, err = chains.New(
&chains.ManagerConfig{
SybilProtectionEnabled: n.Config.SybilProtectionEnabled,
StakingTLSSigner: n.StakingTLSSigner,
StakingTLSCert: n.StakingTLSCert,
StakingBLSKey: n.Config.StakingSigningKey,
Log: n.Log,
LogFactory: n.LogFactory,
VMManager: n.VMManager,
BlockAcceptorGroup: n.BlockAcceptorGroup,
TxAcceptorGroup: n.TxAcceptorGroup,
VertexAcceptorGroup: n.VertexAcceptorGroup,
DB: n.DB,
MsgCreator: n.msgCreator,
Router: n.chainRouter,
Net: n.Net,
Validators: n.vdrs,
PartialSyncPrimaryNetwork: n.Config.PartialSyncPrimaryNetwork,
NodeID: n.ID,
NetworkID: n.Config.NetworkID,
Server: n.APIServer,
AtomicMemory: n.sharedMemory,
UTXOAssetID: utxoAssetID,
XChainID: xChainID,
CChainID: cChainID,
DChainID: dChainID,
CriticalChains: criticalChains,
SybilProtectionEnabled: n.Config.SybilProtectionEnabled,
StakingTLSSigner: n.StakingTLSSigner,
StakingTLSCert: n.StakingTLSCert,
StakingBLSKey: n.Config.StakingSigningKey,
Log: n.Log,
LogFactory: n.LogFactory,
VMManager: n.VMManager,
BlockAcceptorGroup: n.BlockAcceptorGroup,
TxAcceptorGroup: n.TxAcceptorGroup,
VertexAcceptorGroup: n.VertexAcceptorGroup,
DB: n.DB,
MsgCreator: n.msgCreator,
Router: n.chainRouter,
Net: n.Net,
Validators: n.vdrs,
PartialSyncPrimaryNetwork: n.Config.PartialSyncPrimaryNetwork,
NodeID: n.ID,
NetworkID: n.Config.NetworkID,
Server: n.APIServer,
AtomicMemory: n.sharedMemory,
UTXOAssetID: utxoAssetID,
XChainID: xChainID,
CChainID: cChainID,
DChainID: dChainID,
CriticalChains: criticalChains,
// ChainAuthorizations is derived from the single OptionalVMs
// registry (which VMs need which operator NFT) joined with the
// per-network collection assets in Config.NFTAuthorizationAssets and
// genesis-resolved chain IDs. Empty until a network configures an
// operator collection — so gated optional chains (D/B) behave as
// today and ungated ones are untouched — while the gate itself fails
// closed for any configured-but-unheld NFT (chains/manager_authz.go).
ChainAuthorizations: chainAuthorizationsFor(n.Config.GenesisBytes, n.Config.NFTAuthorizationAssets),
// DexValidator is the operator's D-Chain opt-in. It is enforced as a
// necessary activation condition by authorizeChainActivation (composed
// AND with the NFT gate), so a node WITHOUT dex-validator does not
// activate the D-Chain even under --track-all-chains. Read once here
// (same value participatesInDEXChain reports for the log line below).
DexValidator: n.Config.DexValidator,
TimeoutManager: n.timeoutManager,
Health: n.health,
ShutdownNodeFunc: n.Shutdown,
@@ -1585,8 +1608,22 @@ func (n *Node) initVMs() error {
// C-Chain VM (EVM) is loaded as a plugin via ZAP transport when present
// in genesis. Plugin binary placed at <plugin-dir>/<EVMID> by init container.
// Register all VMs (Q, A, B, T, Z, G, D, K, O, R, I chains)
if err := n.registerOptionalVMs(); err != nil {
// Register the always-activated core VMs in-process (Q, Z). The optional
// chain VMs (A/B/D/G/I/K/O/R/T) load from PluginDir via VMRegistry.Reload
// below, like the C-Chain EVM — keeping luxd small per the all-plugins
// directive.
if err := n.registerCoreVMs(); err != nil {
return err
}
// Anti-shadow guard: with all in-process registration done (P/X above, Q/Z
// in registerCoreVMs), assert that NO optional/plugin VM leaked into the
// in-process registry. This runs BEFORE the PluginDir scan below, so a
// violation aborts boot before any plugin could be shadowed. Fail-closed:
// the node refuses to start rather than silently link a VM that must be a
// plugin.
if err := n.assertNoOptionalShadows(context.Background()); err != nil {
n.Log.Error("VM anti-shadow guard failed", "error", err)
return err
}
+252 -43
View File
@@ -5,63 +5,272 @@ package node
import (
"context"
"fmt"
"github.com/luxfi/chains/aivm"
"github.com/luxfi/chains/bridgevm"
"github.com/luxfi/chains/graphvm"
"github.com/luxfi/chains/identityvm"
"github.com/luxfi/chains/keyvm"
"github.com/luxfi/chains/oraclevm"
"github.com/luxfi/chains/quantumvm"
quantumvmconfig "github.com/luxfi/chains/quantumvm/config"
"github.com/luxfi/chains/relayvm"
"github.com/luxfi/chains/thresholdvm"
"github.com/luxfi/chains/zkvm"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
"github.com/luxfi/node/chains"
"github.com/luxfi/node/genesis/builder"
"github.com/luxfi/node/vms"
)
type vmEntry struct {
name string
id ids.ID
factory vms.Factory
// The node's VM model has exactly two registries, and they are the single
// source of truth for where every VM lives. A VM is in EXACTLY ONE of them —
// that disjointness is what makes static (in-process) registration unable to
// shadow a PluginDir plugin.
//
// - CoreVMs: in-process, no NFT gate, available at genesis/core boot.
// {P platformvm, X xvm, Q quantumvm, Z zkvm}.
// - OptionalVMs: plugin-only (loaded from PluginDir via the upstream
// VMRegistry scan), NFT-gated activation when RequiredNFT is
// set. {D dexvm, B bridgevm, C evm, A/G/I/K/O/R/T, future app
// VMs}.
//
// The accessor rule that makes shadowing impossible: a VMID is resolved as
// core (in-process factory) IFF it is in CoreVMs; otherwise it is resolved
// from OptionalVMs and loaded from PluginDir. Because the two maps are
// disjoint (assertRegistriesDisjoint, enforced at init and in tests) and no
// OptionalVMs id is ever handed to VMManager.RegisterFactory
// (assertNoOptionalShadows, enforced at boot before the PluginDir scan), the
// upstream registry's "skip any VMID the manager already has"
// (vms/registry/registry.go) can never fire for an optional VM — its plugin
// always wins because the in-process registry never contains it.
// CoreVM describes a VM that runs in-process (linked into luxd), never loaded
// from PluginDir and never NFT-gated.
//
// Factory is the concrete in-process factory for VMs whose construction needs
// no node runtime state (Q, Z) — registerCoreVMs installs these. It is nil for
// P and X, whose factories must be built with live node dependencies
// (chainManager, validators, security profile, classical-compat registry) and
// are therefore registered explicitly in node.go; RegisteredInNodeGo records
// that ownership so the nil Factory is self-documenting, not a footgun.
type CoreVM struct {
Name string
Factory vms.Factory
RegisteredInNodeGo bool
}
// registerOptionalVMs registers the optional VMs (A/B/G/I/K/O/Q/R/T/Z).
// Primary network (P/X/C) is registered separately.
// Session (S-Chain) is a standalone plugin at github.com/luxfi/session/plugin.
// NFTRequirement is the per-network-independent POLICY half of a VM's
// activation gate: which X-Chain nftfx collection a validator's staking
// address must hold, and which group within it (0 = any group). The per-network
// VALUE half — the concrete collection AssetID — is supplied separately from
// network config (Config.NFTAuthorizationAssets) and joined with this policy in
// chainAuthorizationsFor. Splitting policy (here) from per-network value
// (config) keeps one declaration of "which VMs need an operator NFT" while the
// asset IDs vary per network/genesis.
type NFTRequirement struct {
// Collection is the human label of the operator NFT collection (e.g.
// "dex-operator", "bridge-operator"). It exists for logging/clarity and to
// key the per-network asset lookup conceptually; the authoritative join key
// is the VMID (see chainAuthorizationsFor).
Collection string
// GroupID is the nftfx group within the collection that authorizes
// activation; 0 means any group in the collection.
GroupID uint32
}
// PluginSpec describes a VM that is loaded ONLY from PluginDir (never linked
// in-process), optionally gated on an X-Chain operator NFT.
type PluginSpec struct {
// Name is the chain VM's package/plugin name (e.g. "dexvm"). For the nine
// luxfi/chains VMs the plugin main is github.com/luxfi/chains/<Name>/cmd/
// plugin and the built artifact is placed at <plugin-dir>/<VMID-CB58> by the
// Dockerfile Chain VM Plugin Stage. The C-Chain EVM ("evm") is also
// plugin-loaded but its plugin main lives in the EVM repo, not chains.
Name string
// RequiredNFT, when non-nil, gates this VM's activation on X-Chain operator
// NFT ownership (see NFTRequirement). Nil means the VM is plugin-loaded but
// ungated (any node that tracks the chain may activate it).
RequiredNFT *NFTRequirement
}
// CoreVMs is the authoritative set of in-process VMs. Q and Z carry their
// concrete factory (registerCoreVMs installs them); P and X are registered in
// node.go with runtime dependencies (RegisteredInNodeGo=true, Factory nil).
//
// The D-Chain (DEXVM) proxy is registered ONLY in the `dchain` build (see
// vms_dchain.go / vms_nodchain.go): the public OSS luxd MUST NOT even link the
// proxy/D-Chain tree (public-build purity). appendDChainVM is the seam — a
// no-op in the default build, the real registration under -tags dchain.
func (n *Node) registerOptionalVMs() error {
entries := []vmEntry{
{"AIVM (A-Chain)", aivm.VMID, &aivm.Factory{}},
{"BridgeVM (B-Chain)", bridgevm.VMID, &bridgevm.Factory{}},
{"GraphVM (G-Chain)", graphvm.VMID, &graphvm.Factory{}},
{"IdentityVM (I-Chain)", identityvm.VMID, &identityvm.Factory{}},
{"KeyVM (K-Chain)", keyvm.VMID, &keyvm.Factory{}},
{"OracleVM (O-Chain)", oraclevm.VMID, &oraclevm.Factory{}},
{"QuantumVM (Q-Chain)", quantumvm.VMID, &quantumvm.Factory{Config: quantumvmconfig.DefaultConfig()}},
{"RelayVM (R-Chain)", relayvm.VMID, &relayvm.Factory{}},
{"ThresholdVM (T-Chain)", thresholdvm.VMID, &thresholdvm.Factory{}},
{"ZKVM (Z-Chain)", zkvm.VMID, &zkvm.Factory{}},
// This map is the single source for "what is core". The anti-shadow guards
// iterate its keys; tests assert all four are present and that none also
// appears in OptionalVMs.
var CoreVMs = map[ids.ID]CoreVM{
constants.PlatformVMID: {Name: "platformvm (P-Chain)", RegisteredInNodeGo: true},
constants.XVMID: {Name: "xvm (X-Chain)", RegisteredInNodeGo: true},
constants.QuantumVMID: {Name: "quantumvm (Q-Chain)", Factory: &quantumvm.Factory{Config: quantumvmconfig.DefaultConfig()}},
constants.ZKVMID: {Name: "zkvm (Z-Chain)", Factory: &zkvm.Factory{}},
}
// OptionalVMs is the authoritative set of plugin-only VMs and their activation
// gates. It is the single source for both (a) which VMs MUST NOT be linked
// in-process, and (b) which chains require an operator NFT to activate.
//
// D (dexvm) and B (bridgevm) are NFT-gated: a node may only track/validate
// them if its staking X-address holds the corresponding operator NFT. The
// concrete collection AssetID is per-network and supplied via
// Config.NFTAuthorizationAssets; minting the real collections is a follow-up,
// so until a network configures an asset the chain stays ungated (preserving
// today's opt-in-flag behavior) while the gate itself remains fail-closed.
//
// C (evm) and the remaining app VMs are plugin-loaded but ungated today.
var OptionalVMs = map[ids.ID]PluginSpec{
constants.DexVMID: {Name: "dexvm", RequiredNFT: &NFTRequirement{Collection: "dex-operator", GroupID: 0}},
constants.BridgeVMID: {Name: "bridgevm", RequiredNFT: &NFTRequirement{Collection: "bridge-operator", GroupID: 0}},
constants.EVMID: {Name: "evm"},
constants.AIVMID: {Name: "aivm"},
constants.GraphVMID: {Name: "graphvm"},
constants.IdentityVMID: {Name: "identityvm"},
constants.KeyVMID: {Name: "keyvm"},
constants.OracleVMID: {Name: "oraclevm"},
constants.RelayVMID: {Name: "relayvm"},
constants.ThresholdVMID: {Name: "thresholdvm"},
}
func init() {
// Static structural invariant: the two registries must be disjoint. A VM
// listed as both core and optional is a programming error knowable without
// any runtime state, so we fail at package init (process never starts).
if err := assertRegistriesDisjoint(); err != nil {
panic(err)
}
}
// D-Chain proxy VM: linked + appended only under -tags dchain.
appendDChainVM(n, &entries)
registered := 0
for _, e := range entries {
if err := n.VMManager.RegisterFactory(context.Background(), e.id, e.factory); err != nil {
n.Log.Warn("Failed to register VM", "name", e.name, "error", err)
continue
// assertRegistriesDisjoint reports an error if any VMID appears in both
// CoreVMs and OptionalVMs. It reads only the two static maps, so it is a pure
// check usable from init and from tests. This is the first anti-shadow layer:
// it guarantees no VM is simultaneously declared in-process and plugin-only.
func assertRegistriesDisjoint() error {
for id := range OptionalVMs {
if core, ok := CoreVMs[id]; ok {
return fmt.Errorf("VM %s declared in BOTH CoreVMs (%s) and OptionalVMs (%s): "+
"an optional/plugin VM must never also be a core/in-process VM, or static "+
"registration would shadow its PluginDir plugin", id, core.Name, OptionalVMs[id].Name)
}
n.Log.Info("VM registered", "name", e.name)
registered++
}
n.Log.Info("Optional VMs registered", "count", registered)
return nil
}
// registerCoreVMs registers the in-process core VMs that carry a concrete
// factory (Q, Z). P and X are registered separately in node.go because their
// factories require live node dependencies (RegisteredInNodeGo=true in
// CoreVMs). The optional chain VMs (A/B/C/D/G/I/K/O/R/T) are NEVER registered
// here — they load from PluginDir via the VMRegistry scan, exactly like any
// other plugin. Registering an optional VM in-process would shadow its plugin
// (the registry skips any VMID the manager already has — vms/registry/
// registry.go) and force luxd to link it, defeating the all-plugins directive.
func (n *Node) registerCoreVMs() error {
registered := 0
for id, core := range CoreVMs {
if core.Factory == nil {
// P/X: constructed with runtime deps in node.go.
continue
}
if err := n.VMManager.RegisterFactory(context.Background(), id, core.Factory); err != nil {
n.Log.Warn("Failed to register core VM", "name", core.Name, "error", err)
continue
}
n.Log.Info("Core VM registered in-process", "name", core.Name)
registered++
}
n.Log.Info("Core VMs registered in-process (from CoreVMs registry)", "count", registered)
n.Log.Info("Optional chain VMs load from PluginDir (OptionalVMs registry)", "count", len(OptionalVMs))
return nil
}
// assertNoOptionalShadows is the runtime anti-shadow guard. It enumerates the
// in-process VM registry (after ALL in-process registration: P/X in node.go +
// CoreVMs here) and fails if any OptionalVMs id is present. node.go calls this
// BEFORE the PluginDir scan (VMRegistry.Reload), so a violation aborts boot
// before any plugin could be shadowed.
//
// This complements assertRegistriesDisjoint: the static check proves the two
// declarations are disjoint; this runtime check proves the LIVE manager never
// acquired an optional VM by any path (a stray RegisterFactory elsewhere, a
// future regression). Together they make optional-VM shadowing structurally
// impossible.
func (n *Node) assertNoOptionalShadows(ctx context.Context) error {
inProcess, err := n.VMManager.ListFactories(ctx)
if err != nil {
return fmt.Errorf("anti-shadow guard: list in-process VM factories: %w", err)
}
registered := make(map[ids.ID]struct{}, len(inProcess))
for _, id := range inProcess {
registered[id] = struct{}{}
}
for id, spec := range OptionalVMs {
if _, shadowed := registered[id]; shadowed {
return fmt.Errorf("anti-shadow violation: optional VM %s (%s) is registered "+
"in-process; it MUST load only from PluginDir or its plugin will be shadowed "+
"(the VMRegistry skips any VMID already in the manager)", id, spec.Name)
}
}
n.Log.Info("anti-shadow guard passed: no optional VM is registered in-process",
"inProcessCount", len(inProcess),
"optionalCount", len(OptionalVMs),
)
return nil
}
// chainAuthorizationsFor builds the manager's ChainAuthorizations map — the
// single mapping of "which chain requires which operator NFT" — from
// OptionalVMs (the policy) joined with the per-network asset IDs (the values)
// and genesis (the chain IDs). It is the ONE place this mapping is derived, so
// OptionalVMs[id].RequiredNFT is the sole declaration of NFT-gating; node.go
// merely passes the result to chains.ManagerConfig.
//
// For each OptionalVMs entry with a RequiredNFT:
// - if the network has not configured a collection AssetID for that VMID,
// the chain is left UNGATED (no map entry) — collections are minted as a
// follow-up, so until then the chain behaves as today (opt-in flag only);
// - if the chain is not present in this network's genesis, it is skipped;
// - otherwise the genesis-derived chain ID maps to NFTAuthorization{asset,
// group}, so authorizeChainActivation fails closed for any node whose
// staking X-address does not hold the NFT.
func chainAuthorizationsFor(genesisBytes []byte, assets map[ids.ID]ids.ID) map[ids.ID]chains.NFTAuthorization {
out := make(map[ids.ID]chains.NFTAuthorization)
for vmID, spec := range OptionalVMs {
if spec.RequiredNFT == nil {
continue
}
asset, ok := assets[vmID]
if !ok || asset == ids.Empty {
continue
}
createTx, err := builder.VMGenesis(genesisBytes, vmID)
if err != nil {
continue
}
out[createTx.ID()] = chains.NFTAuthorization{
AssetID: asset,
GroupID: spec.RequiredNFT.GroupID,
}
}
return out
}
// participatesInDEXChain reports this node's D-Chain DEX opt-in — whether the
// operator asked to track/validate the D-Chain and dial the venue matcher
// engine. It is a thin read of Config.DexValidator used for the node's startup
// log line; the SAME bool is passed to ManagerConfig.DexValidator, where the
// chain manager actually ENFORCES it (authorizeChainActivation declines the
// D-Chain when the flag is off). So there is one source of truth (the config
// flag) with one enforcement point (the gate); this accessor is sugar for the
// log, not a parallel policy.
//
// Default is false: most validators never run the DEX. The licensed/private
// piece is the GPU venue ENGINE (lx/dex cmd/dvenue, cgo) — never the node.
//
// The effective activation rule for the D-Chain is flag AND NFT: the chain
// manager's authorizeChainActivation requires DexValidator=true AND, when a
// network has configured the dex-operator collection (OptionalVMs[DexVMID].
// RequiredNFT joined with Config.NFTAuthorizationAssets), the node's staking
// X-address to hold that NFT. With the flag off the D-Chain is declined even
// under --track-all-chains; with the flag on but the NFT unheld the gate fails
// closed. (Phase-2 seam: wiring the node's staking X-address into
// ManagerConfig.StakingXAddress and minting the dex-operator collection per
// network; until a collection is configured the NFT half is a no-op and the
// flag alone governs.)
func (n *Node) participatesInDEXChain() bool {
return n.Config.DexValidator
}
-22
View File
@@ -1,22 +0,0 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build dchain
package node
import "github.com/luxfi/chains/dexvm"
// appendDChainVM links the D-Chain (DEXVM) proxy and appends its factory to the
// optional-VM registration set. This file compiles ONLY under -tags dchain, so
// the chains/dexvm import — and the entire proxy/D-Chain tree behind it — is
// absent from the public OSS luxd binary (public-build purity).
//
// Defense in depth: even a dchain-tagged binary will not INSTANTIATE the chain
// unless D-Chain is present in genesis (the genesis-gate in node.go). Three
// independent layers gate the venue: public can't link it; venue without
// D-Chain genesis links-but-doesn't-run it; venue + genesis runs it.
func appendDChainVM(n *Node, entries *[]vmEntry) {
*entries = append(*entries, vmEntry{"DEXVM (D-Chain)", dexvm.VMID, &dexvm.Factory{}})
n.Log.Info("D-Chain (DEXVM) proxy linked (dchain build)")
}
-13
View File
@@ -1,13 +0,0 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !dchain
package node
// appendDChainVM is a no-op in the public (non-dchain) build: the D-Chain
// (DEXVM) proxy is NOT linked, so `go build ./...` of luxd does not import
// github.com/luxfi/chains/dexvm at all and the entire proxy/D-Chain tree is
// absent from the public binary. The venue build (-tags dchain) links it via
// vms_dchain.go.
func appendDChainVM(_ *Node, _ *[]vmEntry) {}
+97
View File
@@ -0,0 +1,97 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package node
import (
"os/exec"
"strings"
"testing"
)
// goListDeps returns the transitive package dependency list of pkg as reported
// by `go list -deps`, run from the module root (two levels up from this
// package's node/node directory). It is the ground-truth dep graph of the
// compiled artifact — the only honest way to assert what a binary links.
func goListDeps(t *testing.T, pkg string) []string {
t.Helper()
cmd := exec.Command("go", "list", "-deps", pkg)
cmd.Dir = ".." // module root: .../node (this test lives in .../node/node)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go list -deps %s failed: %v\n%s", pkg, err, out)
}
return strings.Split(strings.TrimSpace(string(out)), "\n")
}
func countContaining(deps []string, needle string) int {
n := 0
for _, d := range deps {
if strings.Contains(d, needle) {
n++
}
}
return n
}
// TestOptionalVMsNotLinkedInProcess enforces the directive "all VMs as plugins
// 100% to keep luxd small" for the nine optional / NFT-gated chain VMs.
//
// These VMs build as CGO=0 plugins (Dockerfile Chain VM Plugin Stage, placed at
// <plugin-dir>/<VMID>) and load through VMRegistry.Reload's PluginDir scan,
// exactly like the C-Chain EVM. They MUST NOT be compiled into the node: an
// in-process RegisterFactory would shadow the plugin (the registry skips any
// VMID the manager already has — vms/registry/registry.go) and force luxd to
// link every VM, defeating the directive.
//
// The four core VMs are deliberately NOT asserted here: P(platformvm) and
// X(xvm) are foundational primary-network VMs, and Q(quantumvm)/Z(zkvm) stay
// in-process for now (Q backs Quasar hybrid finality + is critical by default).
// Turning Q/Z into plugins is a separate design-first phase.
func TestOptionalVMsNotLinkedInProcess(t *testing.T) {
optionalVMs := []string{
"github.com/luxfi/chains/aivm",
"github.com/luxfi/chains/bridgevm",
"github.com/luxfi/chains/dexvm",
"github.com/luxfi/chains/graphvm",
"github.com/luxfi/chains/identityvm",
"github.com/luxfi/chains/keyvm",
"github.com/luxfi/chains/oraclevm",
"github.com/luxfi/chains/relayvm",
"github.com/luxfi/chains/thresholdvm",
}
for _, pkg := range []string{"./node/", "./main"} {
deps := goListDeps(t, pkg)
for _, vm := range optionalVMs {
if got := countContaining(deps, vm); got != 0 {
t.Errorf("%s links %q (%d packages); the optional chain VMs must "+
"load from PluginDir, not be compiled in-process (directive: "+
"all VMs as plugins to keep luxd small)", pkg, vm, got)
}
}
}
}
// TestVenueEngineNotLinked guards the REAL boundary that the (now deleted)
// purity gate was reaching for: the node links the pure-Go D-Chain PROXY, but
// MUST NOT link the private/licensed venue ENGINE or its GPU deps. Those live
// in the separate venue engine (lx/dex cmd/dvenue + luxcpp + gpu-kernels), not
// in the node. The node dials the venue over ZAP at runtime; it never compiles
// the matcher/GPU kernels in.
func TestVenueEngineNotLinked(t *testing.T) {
forbidden := []string{
"luxcpp", // C++ GPU bindings (private)
"gpu-kernels", // CUDA/Metal/HIP matcher kernels (private)
"lux-private", // the private workspace
"luxfi/dex", // the matcher/venue wrapper (CGO/GPU engine)
}
for _, pkg := range []string{"./node/", "./main"} {
deps := goListDeps(t, pkg)
for _, f := range forbidden {
if got := countContaining(deps, f); got != 0 {
t.Errorf("%s links %q (%d packages); the private venue engine "+
"and its GPU deps must NOT be in the node", pkg, f, got)
}
}
}
}
+345
View File
@@ -0,0 +1,345 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package node
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/constants"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/metric"
"github.com/luxfi/node/utils/filesystem"
"github.com/luxfi/node/vms"
"github.com/luxfi/node/vms/registry"
"github.com/luxfi/node/vms/rpcchainvm/runtime"
)
// newTestLog returns a no-op logger for unit tests that exercise node methods
// reading n.Log.
func newTestLog() log.Logger { return log.NewNoOpLogger() }
// stubFactory is a minimal vms.Factory used only to occupy a VMID slot in an
// in-process manager (to simulate a shadow). Its New is never called in these
// tests.
type stubFactory struct{}
func (stubFactory) New(log.Logger) (interface{}, error) { return struct{}{}, nil }
// noopProcessTracker satisfies registry's ProcessTracker without managing real
// processes. The registry only needs it to construct the lazy rpcchainvm
// factory; it is never invoked during Reload (the subprocess is spawned lazily
// on factory.New, which Reload does not call).
type noopProcessTracker struct{}
func (noopProcessTracker) TrackProcess(int) {}
func (noopProcessTracker) UntrackProcess(int) {}
// realPluginRegistry builds the genuine upstream VMRegistry (NewVMRegistry +
// NewVMGetter) pointed at pluginDir, over the given manager. This is the exact
// wiring node.go uses (node/node.go initVMs), so a Reload here exercises the
// real resolution path — not a stand-in.
func realPluginRegistry(t *testing.T, mgr vms.Manager, pluginDir string) registry.VMRegistry {
t.Helper()
return registry.NewVMRegistry(registry.VMRegistryConfig{
VMGetter: registry.NewVMGetter(registry.VMGetterConfig{
FileReader: filesystem.NewReader(),
Manager: mgr,
PluginDirectory: pluginDir,
ProcessTracker: noopProcessTracker{},
RuntimeTracker: runtime.NewManager(),
MetricsGatherer: metric.NewMultiGatherer(),
}),
VMManager: mgr,
})
}
// writePluginFile creates a plugin file in dir named by the VMID's CB58 string
// (the on-disk convention the registry resolves and the Dockerfile emits).
// Contents are a non-empty placeholder; rpcchainvm.NewFactory is lazy, so the
// file is never executed during Reload — only its name (the VMID) and presence
// matter for the resolution path under test.
func writePluginFile(t *testing.T, dir string, vmID ids.ID) string {
t.Helper()
path := filepath.Join(dir, vmID.String())
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755))
return path
}
// TestRegistriesAreDisjoint (CTO anti-shadow, static layer). Proves the two
// authoritative registries share no VMID — the structural property that makes
// it impossible for a VM to be both in-process and plugin-loaded. This is the
// same check enforced at package init via assertRegistriesDisjoint.
func TestRegistriesAreDisjoint(t *testing.T) {
require.NoError(t, assertRegistriesDisjoint(),
"CoreVMs and OptionalVMs must be disjoint; overlap would let static "+
"registration shadow a PluginDir plugin")
}
// TestRegistriesDisjointDetectsOverlap proves assertRegistriesDisjoint is not a
// no-op: a VMID present in both registries is rejected. The production maps are
// disjoint (TestRegistriesAreDisjoint) and init() panics on overlap; this
// exercises the rejection logic that init() relies on, without mutating the
// package globals. It mirrors assertRegistriesDisjoint over local maps.
func TestRegistriesDisjointDetectsOverlap(t *testing.T) {
core := map[ids.ID]CoreVM{constants.DexVMID: {Name: "dexvm (WRONG: core)"}}
optional := map[ids.ID]PluginSpec{constants.DexVMID: {Name: "dexvm"}}
overlap := false
for id := range optional {
if _, ok := core[id]; ok {
overlap = true
}
}
require.True(t, overlap,
"a VMID in both CoreVMs and OptionalVMs MUST be detectable — this is the "+
"condition assertRegistriesDisjoint rejects and init() panics on")
}
// TestOptionalVMsNotStaticallyRegistered (#1). Every OptionalVMs id is absent
// from CoreVMs (the in-process set), so none can be statically registered as a
// core VM. Pairs with TestOptionalVMsNotLinkedInProcess (which proves they are
// not even LINKED into the binary) and the runtime guard (which proves the live
// manager never acquires them).
func TestOptionalVMsNotStaticallyRegistered(t *testing.T) {
require.NotEmpty(t, OptionalVMs)
for id, spec := range OptionalVMs {
_, isCore := CoreVMs[id]
require.Falsef(t, isCore,
"optional VM %s (%s) must NOT be in CoreVMs / the in-process registry",
id, spec.Name)
}
// And the headline gated VMs are explicitly present as optional.
for _, id := range []ids.ID{constants.DexVMID, constants.BridgeVMID} {
_, ok := OptionalVMs[id]
require.Truef(t, ok, "%s must be declared in OptionalVMs", id)
}
}
// TestCoreVMsRemainInProcess (#7). P/X/Q/Z are all in CoreVMs (the in-process
// set) and none of them carries an NFT gate / appears in OptionalVMs. Q and Z
// carry a concrete in-process factory; P and X are flagged RegisteredInNodeGo
// (their factories need runtime deps, registered in node.go). registerCoreVMs
// is proven to install Q/Z in-process — with no PluginDir involved — in
// TestRegisterCoreVMsInstallsQZInProcess below.
func TestCoreVMsRemainInProcess(t *testing.T) {
for _, id := range []ids.ID{
constants.PlatformVMID, constants.XVMID, constants.QuantumVMID, constants.ZKVMID,
} {
core, ok := CoreVMs[id]
require.Truef(t, ok, "%s must be a core in-process VM", id)
_, optional := OptionalVMs[id]
require.Falsef(t, optional, "core VM %s must not also be optional/NFT-gated", id)
switch id {
case constants.QuantumVMID, constants.ZKVMID:
require.NotNilf(t, core.Factory, "%s (%s) must carry an in-process factory", id, core.Name)
require.Falsef(t, core.RegisteredInNodeGo, "%s registered by registerCoreVMs, not node.go", id)
case constants.PlatformVMID, constants.XVMID:
require.Truef(t, core.RegisteredInNodeGo, "%s (%s) is registered in node.go with runtime deps", id, core.Name)
require.Nilf(t, core.Factory, "%s factory is built in node.go, not statically", id)
}
}
}
// TestRegisterCoreVMsInstallsQZInProcess proves registerCoreVMs registers the
// factory-carrying core VMs (Q, Z) into a real in-process VMManager, with no
// PluginDir anywhere — and that it does NOT register any optional VM. This is
// the in-process half of "P/X/Q/Z core, no NFT required".
func TestRegisterCoreVMsInstallsQZInProcess(t *testing.T) {
mgr := vms.NewManager()
n := &Node{VMManager: mgr}
n.Log = newTestLog()
require.NoError(t, n.registerCoreVMs())
ctx := context.Background()
for _, id := range []ids.ID{constants.QuantumVMID, constants.ZKVMID} {
_, err := mgr.GetFactory(ctx, id)
require.NoErrorf(t, err, "core VM %s must be registered in-process by registerCoreVMs", id)
}
// No optional VM may have been registered.
for id := range OptionalVMs {
_, err := mgr.GetFactory(ctx, id)
require.Errorf(t, err, "optional VM %s must NOT be registered in-process", id)
}
// And the runtime anti-shadow guard passes against this manager.
require.NoError(t, n.assertNoOptionalShadows(ctx))
}
// TestAssertNoOptionalShadowsCatchesLeak proves the runtime anti-shadow guard
// FAILS when an optional VM is (incorrectly) registered in-process — i.e. it
// actually catches a shadow, it is not a no-op. We register dexvm in-process to
// simulate the regression the guard exists to prevent.
func TestAssertNoOptionalShadowsCatchesLeak(t *testing.T) {
mgr := vms.NewManager()
n := &Node{VMManager: mgr}
n.Log = newTestLog()
ctx := context.Background()
// Clean manager: guard passes.
require.NoError(t, n.assertNoOptionalShadows(ctx))
// Simulate the regression: an optional VM leaks into the in-process registry.
require.NoError(t, mgr.RegisterFactory(ctx, constants.DexVMID, stubFactory{}))
err := n.assertNoOptionalShadows(ctx)
require.Error(t, err, "guard must reject an optional VM registered in-process")
require.Contains(t, err.Error(), "anti-shadow violation")
require.Contains(t, err.Error(), constants.DexVMID.String())
}
// TestOptionalVMsBuiltIntoPluginDir (#2). Each NFT-gated/app chain VM in
// OptionalVMs has a buildable github.com/luxfi/chains/<Name>/cmd/plugin whose
// CGO=0 artifact is named by the VMID CB58 (the Dockerfile Chain VM Plugin
// Stage convention). This actually COMPILES each plugin to a tempdir and
// asserts the artifact exists at <VMID>.
//
// Honest scope: this requires the luxfi/chains source on disk (the workspace
// go.work links ./chains). When chains is not resolvable (a node-only checkout
// in CI) or in -short mode, the build is skipped with an explicit reason — it
// is never faked. The C-Chain EVM is excluded: it is plugin-loaded too, but its
// plugin main lives in the EVM repo, not chains (see Dockerfile EVM stage).
func TestOptionalVMsBuiltIntoPluginDir(t *testing.T) {
if testing.Short() {
t.Skip("skipping plugin compile in -short mode")
}
outDir := t.TempDir()
built := 0
for vmID, spec := range OptionalVMs {
if vmID == constants.EVMID {
continue // EVM plugin builds from the EVM repo, not chains.
}
pkg := "github.com/luxfi/chains/" + spec.Name + "/cmd/plugin"
srcDir, ok := resolvePackageDir(t, pkg)
if !ok {
t.Logf("INTEGRATION-GAP: %s not resolvable (chains source absent; "+
"workspace go.work links ./chains locally) — skipping build of %s", pkg, spec.Name)
continue
}
artifact := filepath.Join(outDir, vmID.String())
cmd := exec.Command("go", "build", "-o", artifact, pkg)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("building %s (%s) failed: %v\n%s", spec.Name, pkg, err, out)
}
info, err := os.Stat(artifact)
require.NoErrorf(t, err, "plugin artifact for %s must exist at %s (VMID CB58)", spec.Name, artifact)
require.Greaterf(t, info.Size(), int64(0), "plugin artifact for %s must be non-empty", spec.Name)
t.Logf("built %s -> %s (%d bytes), src=%s", spec.Name, vmID, info.Size(), srcDir)
built++
}
if built == 0 {
t.Skip("INTEGRATION-GAP: no chains plugin sources resolvable in this checkout; " +
"build is exercised by the Dockerfile Chain VM Plugin Stage and the workspace go.work")
}
}
// TestDexVMLoadsFromPluginDir (#3). Points the REAL upstream VMRegistry at a
// tempdir PluginDir containing a dexvm plugin file (named by the dexvm VMID
// CB58) over a manager that has ONLY the core VMs. Reload must register the
// dexvm VMID FROM the plugin (it was not in-process). Uses the genuine
// registry.Reload path; rpcchainvm.NewFactory is lazy so no subprocess is
// spawned during registration.
func TestDexVMLoadsFromPluginDir(t *testing.T) {
mgr := vms.NewManager()
n := &Node{VMManager: mgr}
n.Log = newTestLog()
ctx := context.Background()
// In-process registry holds only the core VMs — dexvm is NOT among them.
require.NoError(t, n.registerCoreVMs())
_, err := mgr.GetFactory(ctx, constants.DexVMID)
require.Error(t, err, "precondition: dexvm must not be in-process")
pluginDir := t.TempDir()
writePluginFile(t, pluginDir, constants.DexVMID)
reg := realPluginRegistry(t, mgr, pluginDir)
newVMs, failed, err := reg.Reload(ctx)
require.NoError(t, err)
require.Empty(t, failed, "no plugin load failures")
require.Containsf(t, newVMs, constants.DexVMID,
"Reload must register dexvm FROM PluginDir (not in-process)")
// dexvm is now resolvable, and it came from the plugin path.
_, err = mgr.GetFactory(ctx, constants.DexVMID)
require.NoError(t, err, "dexvm must be registered after Reload from PluginDir")
}
// TestPluginShadowingRegression (#6 — THE core regression). Proves end to end
// that the dexvm plugin is resolved from PluginDir and is NOT shadowed by any
// in-process registration. Three load-bearing facts in one test:
//
// 1. dexvm is absent from the in-process registry (it is in OptionalVMs, not
// CoreVMs; registerCoreVMs does not install it).
// 2. With the plugin present in PluginDir, the REAL registry.Reload registers
// it from the plugin.
// 3. The shadow counterfactual: if dexvm WERE registered in-process first,
// Reload skips it (the upstream registry skips any VMID already in the
// manager — vms/registry/registry.go:119) — i.e. an in-process dexvm WOULD
// shadow the plugin. Our design makes (3) unreachable in production via the
// disjoint registries + the boot-time anti-shadow guard; this asserts the
// shadow mechanism exists and that we are on the correct side of it.
func TestPluginShadowingRegression(t *testing.T) {
ctx := context.Background()
pluginDir := t.TempDir()
writePluginFile(t, pluginDir, constants.DexVMID)
// --- Correct world: dexvm plugin-only, resolves from PluginDir. ---
mgr := vms.NewManager()
n := &Node{VMManager: mgr}
n.Log = newTestLog()
require.NoError(t, n.registerCoreVMs())
_, err := mgr.GetFactory(ctx, constants.DexVMID)
require.Error(t, err, "dexvm must be absent from the in-process registry")
require.NoError(t, n.assertNoOptionalShadows(ctx), "no in-process shadow of dexvm")
newVMs, failed, err := realPluginRegistry(t, mgr, pluginDir).Reload(ctx)
require.NoError(t, err)
require.Empty(t, failed)
require.Contains(t, newVMs, constants.DexVMID, "dexvm resolved from PluginDir, not shadowed")
// --- Shadow counterfactual: in-process dexvm WOULD shadow the plugin. ---
shadowMgr := vms.NewManager()
require.NoError(t, shadowMgr.RegisterFactory(ctx, constants.DexVMID, stubFactory{}))
shadowNewVMs, _, err := realPluginRegistry(t, shadowMgr, pluginDir).Reload(ctx)
require.NoError(t, err)
require.NotContainsf(t, shadowNewVMs, constants.DexVMID,
"with dexvm registered in-process the registry SKIPS the plugin — this is the "+
"shadow our design prevents (disjoint registries + boot anti-shadow guard)")
// And the guard would have aborted boot in the shadow world.
shadowNode := &Node{VMManager: shadowMgr}
shadowNode.Log = newTestLog()
require.Error(t, shadowNode.assertNoOptionalShadows(ctx),
"the anti-shadow guard would refuse to boot the shadow configuration")
}
// resolvePackageDir returns the on-disk directory of a Go package and whether
// it is resolvable in the current module/workspace context. Used to honestly
// gate the plugin-build test on whether the chains source is present.
func resolvePackageDir(t *testing.T, pkg string) (string, bool) {
t.Helper()
cmd := exec.Command("go", "list", "-f", "{{.Dir}}", pkg)
out, err := cmd.CombinedOutput()
if err != nil {
return "", false
}
dir := strings.TrimSpace(string(out))
if dir == "" {
return "", false
}
if _, statErr := os.Stat(dir); statErr != nil {
return "", false
}
return dir, true
}
+4 -7
View File
@@ -1,8 +1,6 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build dchain
// Package dexvm re-exports the canonical DEX VM from
// github.com/luxfi/chains/dexvm so existing callers that imported
// github.com/luxfi/node/vms/dexvm pre-extraction keep working
@@ -11,11 +9,10 @@
// New code should import the canonical path:
// "github.com/luxfi/chains/dexvm"
//
// This package is a thin alias wrapper kept for backward compatibility. It is
// gated behind the `dchain` build tag: it imports github.com/luxfi/chains/dexvm
// (the D-Chain proxy tree), which MUST be absent from the public OSS luxd build
// (public-build purity). Nothing in-tree imports this alias; it exists only for
// out-of-tree backward compatibility, and only a venue (dchain) build links it.
// This package is a thin, unconditional backward-compatibility alias. The
// underlying chains/dexvm is the pure-Go stateless atomic proxy (zero private
// deps), so — like every other genesis VM — it is linked into every build with
// no build tag.
package dexvm
import (
-17
View File
@@ -1,17 +0,0 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !dchain
// Package dexvm is, in the public (non-dchain) build, an INTENTIONALLY EMPTY
// backward-compatibility shim.
//
// The real alias (dexvm.go) re-exports github.com/luxfi/chains/dexvm and is
// gated behind the `dchain` build tag, because importing the D-Chain proxy tree
// would violate public-build purity (the public OSS luxd must not link
// chains/dexvm). This file exists only so the package has a buildable Go source
// in the public build — otherwise `go test ./...` would report "build
// constraints exclude all Go files" for this directory. Nothing in-tree imports
// this package; it is kept solely for out-of-tree backward compatibility under
// a venue (dchain) build.
package dexvm
+28 -5
View File
@@ -18,11 +18,10 @@ import (
metrics "github.com/luxfi/metric"
"github.com/luxfi/address"
"github.com/luxfi/constants"
chain "github.com/luxfi/vm/chain"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/engine/dag"
dagvertex "github.com/luxfi/consensus/engine/dag/vertex"
"github.com/luxfi/constants"
"github.com/luxfi/container/linked"
"github.com/luxfi/database"
"github.com/luxfi/database/versiondb"
@@ -35,21 +34,22 @@ import (
"github.com/luxfi/node/version"
"github.com/luxfi/node/vms/components/index"
"github.com/luxfi/node/vms/pcodecs"
lux "github.com/luxfi/utxo"
"github.com/luxfi/node/vms/txs/auth"
"github.com/luxfi/node/vms/txs/mempool"
"github.com/luxfi/node/vms/xvm/block"
"github.com/luxfi/node/vms/xvm/config"
"github.com/luxfi/node/vms/xvm/network"
"github.com/luxfi/node/vms/xvm/state"
"github.com/luxfi/node/vms/xvm/txs"
"github.com/luxfi/node/vms/txs/auth"
"github.com/luxfi/node/vms/xvm/utxo"
"github.com/luxfi/node/vms/txs/mempool"
"github.com/luxfi/runtime"
"github.com/luxfi/timer/mockable"
lux "github.com/luxfi/utxo"
"github.com/luxfi/utxo/secp256k1fx"
validators "github.com/luxfi/validators"
consensusversion "github.com/luxfi/version"
vmcore "github.com/luxfi/vm"
chain "github.com/luxfi/vm/chain"
"github.com/luxfi/warp"
blockbuilder "github.com/luxfi/node/vms/xvm/block/builder"
@@ -793,6 +793,29 @@ func (vm *VM) LoadUser(
return utxos, kc, nil
}
// GetUTXOs returns every UTXO this chain's state holds that is owned by at
// least one address in [addrs]. This is the in-process (no-HTTP) counterpart
// to Service.GetUTXOs: the chain manager's NFT-authorization gate calls it to
// decide whether a validator's staking X-address holds a chain-activation NFT.
//
// GetAllUTXOs is a multi-step UTXOIDs-then-GetUTXO walk over vm.state, so it
// acquires vm.Lock for read to take a consistent snapshot — mirroring
// Service.GetUTXOs, which locks for its own GetUTXOs walk. The lock is taken
// HERE (not left to callers) so the snapshot guarantee is part of the method's
// contract and no caller can violate it; a read lock is sufficient and lets
// concurrent readers proceed. Safe against deadlock: the sole in-process caller
// is the chain manager's NFT gate (chains/manager_authz.go), which holds only
// its own chainsLock when resolving this reader and never vm.Lock.
// Returns an empty slice (never nil error) when [addrs] is empty.
func (vm *VM) GetUTXOs(addrs set.Set[ids.ShortID]) ([]*lux.UTXO, error) {
if addrs.Len() == 0 {
return nil, nil
}
vm.Lock.RLock()
defer vm.Lock.RUnlock()
return lux.GetAllUTXOs(vm.state, addrs)
}
// selectChangeAddr returns the change address to be used for [kc] when [changeAddr] is given
// as the optional change address argument
func (vm *VM) selectChangeAddr(defaultAddr ids.ShortID, changeAddr string) (ids.ShortID, error) {