mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
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:
+85
-5
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user