consensus parity: X-Chain finalizes via the linear 2/3-stake cert (drop undriven DAG path)

X-Chain was on an undriven, unconfigured DAG engine (test-ID/height-0 vertices, a
handler that dropped every inbound consensus message) — no certificate finality,
no parity with C/D/P. It now runs the same linear consensuschain path as every
other chain: deterministic 2/3-stake BFT certificate finality (AcceptWithCert),
the BFT overlap-bound floor, and the epoch-pinned validator set.

vms/xvm/vm.go: conform *VM to block.ChainVM / consensuschain.BlockBuilder
(Connected/Shutdown/WaitForEvent/HealthCheck), remove GetEngine() and the dead
DAG methods, add the compile-time asserts. X already had a real linear builder
(embedded blockbuilder.Builder, wired in Linearize) — nothing faked.
vms/xvm/health.go: HealthCheck -> chain.HealthResult. vms/xvm/tx.go: deleted
(dead dag.Tx wrapper).
chains/manager.go: linearize DAG-native VMs (X-Chain) into linear block mode
before SetState, wired to the REAL toEngine the cert runtime reads from.
Interface-gated — only VMs implementing Linearize are affected; C/D/P/Q untouched.
With no production VM exposing GetEngine, the undriven DAG dispatch is dead.
vms/dexvm/dexvm.go, vms/types/fee/policy.go: doc corrections (dexvm is
plugin/optional/NFT-gated, linear cert finality; X-Chain UTXO fee is a deliberate
exception to the account-model FlatPolicy floor).

Full node builds; vms/xvm + chains tests green. Parity holds by construction for
C/D/X/Q/Z: one cert path, one finality authority.
This commit is contained in:
zeekay
2026-06-25 17:17:34 -07:00
parent 63ea6b5d98
commit 15cdfd937b
9 changed files with 83 additions and 428 deletions
+22
View File
@@ -1185,6 +1185,28 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
}
}
// X-Chain (and any DAG-native VM) must be linearized into linear block mode
// BEFORE it goes Ready, so its embedded block builder is wired to the real
// toEngine channel the cert runtime reads from. Interface-gated: only VMs that
// implement Linearize (X-Chain) take this path; C/D/P/Q are untouched. This is
// what lets X-Chain finalize through the same 2/3-stake cert path as every other
// chain instead of the (now-removed) undriven DAG engine.
if linearVM, ok := vmTyped.(interface {
Linearize(context.Context, ids.ID, chan<- vm.Message) error
}); ok {
m.Log.Info("linearizing DAG-native VM into linear block mode",
log.Stringer("chainID", chainParams.ID))
linCtx, linCancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := linearVM.Linearize(linCtx, ids.Empty, toEngine); err != nil {
linCancel()
m.Log.Error("failed to linearize VM",
log.Stringer("chainID", chainParams.ID),
log.Err(err))
return nil, fmt.Errorf("failed to linearize VM into linear block mode: %w", err)
}
linCancel()
}
// Transition VM to normal operation after initialization
// For genesis-based networks with pre-configured validators, this is required
// to make the VM APIs available immediately
+6 -4
View File
@@ -9,10 +9,12 @@
// New code should import the canonical path:
// "github.com/luxfi/chains/dexvm"
//
// 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.
// This package is a thin backward-compatibility alias. The underlying
// chains/dexvm is the pure-Go stateless atomic proxy (zero private deps).
// Unlike the always-on genesis VMs, dexvm is registered in OptionalVMs and is
// NFT-gated (see node/vms.go:118, RequiredNFT "dex-operator"): a node only
// tracks/validates the D-Chain when the network has configured that operator
// collection, so it is plugin-loaded on demand, not linked unconditionally.
package dexvm
import (
+7
View File
@@ -43,6 +43,13 @@ import (
// bound when reviewing/upgrading per-VM policies; not enforced inside
// Validate (a VM is free to charge MORE), but every VM choosing less
// will be flagged by the migration checklist.
//
// Known intentional exception: the X-Chain (xvm) prices transactions through
// its own UTXO fee subsystem (xvm/config.go TxFee, default 1000 nLUX), NOT the
// FlatPolicy here, and is deliberately outside this floor — its high-throughput
// UTXO economics are set independently of the account-model FlatPolicy floor.
// This is by design, not a migration miss; do not "fix" it by raising xvm
// TxFee to MinTxFeeFloor.
const MinTxFeeFloor uint64 = 1_000_000
// Sentinel errors returned by Policy implementations.
+16 -3
View File
@@ -3,8 +3,21 @@
package xvm
import "context"
import (
"context"
func (*VM) HealthCheck(context.Context) (interface{}, error) {
return nil, nil
"github.com/luxfi/node/version"
chain "github.com/luxfi/vm/chain"
)
// HealthCheck reports the VM's health to the consensus engine. It returns a
// chain.HealthResult (= block.HealthCheckResult) so *VM satisfies the linear
// chain.ChainVM interface used by the certificate path.
func (vm *VM) HealthCheck(context.Context) (chain.HealthResult, error) {
return chain.HealthResult{
Healthy: vm.onShutdownCtx == nil || vm.onShutdownCtx.Err() == nil,
Details: map[string]string{
"version": version.Current.String(),
},
}, nil
}
-161
View File
@@ -1,161 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package xvm
import (
"context"
"errors"
"fmt"
"github.com/luxfi/log"
"github.com/luxfi/consensus/core/choices"
"github.com/luxfi/consensus/engine/dag"
"github.com/luxfi/database"
"github.com/luxfi/ids"
"github.com/luxfi/math/set"
"github.com/luxfi/node/vms/xvm/txs"
"github.com/luxfi/node/vms/xvm/txs/executor"
)
var (
_ dag.Tx = (*Tx)(nil)
errTxNotProcessing = errors.New("transaction is not processing")
errUnexpectedReject = errors.New("attempting to reject transaction")
)
type Tx struct {
vm *VM
tx *txs.Tx
}
func (tx *Tx) ID() ids.ID {
return tx.tx.ID()
}
// Height returns the height of this transaction (not used in XVM)
func (tx *Tx) Height() uint64 {
return 0
}
// Parent returns the parent ID (not used in XVM DAG)
func (tx *Tx) Parent() ids.ID {
return ids.Empty
}
// ParentIDs returns the IDs of the parent transactions (inputs)
func (tx *Tx) ParentIDs() []ids.ID {
// Return the transaction IDs this transaction depends on
parents := []ids.ID{}
for _, in := range tx.tx.Unsigned.InputUTXOs() {
if in.Symbolic() {
continue
}
txID, _ := in.InputSource()
parents = append(parents, txID)
}
return parents
}
func (tx *Tx) Accept(ctx context.Context) error {
if s := tx.Status(); s != choices.Processing {
return fmt.Errorf("%w: %s", errTxNotProcessing, s)
}
tx.vm.onAccept(tx.tx)
executor := &executor.Executor{
Codec: tx.vm.txBackend.Codec,
State: tx.vm.state,
Tx: tx.tx,
Inputs: set.NewSet[ids.ID](0), // Initialize empty set for imported inputs
}
err := tx.tx.Unsigned.Visit(executor)
if err != nil {
return fmt.Errorf("error staging accepted state changes: %w", err)
}
tx.vm.state.AddTx(tx.tx)
commitBatch, err := tx.vm.state.CommitBatch()
if err != nil {
txID := tx.tx.ID()
return fmt.Errorf("couldn't create commitBatch while processing tx %s: %w", txID, err)
}
defer tx.vm.state.Abort()
// Convert the atomicRequests to interface{} type for SharedMemory
requests := make(map[ids.ID]interface{}, len(executor.AtomicRequests))
for chainID, reqs := range executor.AtomicRequests {
requests[chainID] = reqs
}
err = tx.vm.SharedMemory.Apply(
requests,
commitBatch,
)
if err != nil {
txID := tx.tx.ID()
return fmt.Errorf("error committing accepted state changes while processing tx %s: %w", txID, err)
}
return tx.vm.metrics.MarkTxAccepted(tx.tx)
}
func (*Tx) Reject(ctx context.Context) error {
return errUnexpectedReject
}
func (tx *Tx) Status() choices.Status {
txID := tx.tx.ID()
_, err := tx.vm.state.GetTx(txID)
switch err {
case nil:
return choices.Accepted
case database.ErrNotFound:
return choices.Processing
default:
tx.vm.log.Error("failed looking up tx status",
log.Stringer("txID", txID),
log.String("error", err.Error()),
)
return choices.Processing
}
}
func (tx *Tx) MissingDependencies() (set.Set[ids.ID], error) {
txIDs := make(set.Set[ids.ID])
for _, in := range tx.tx.Unsigned.InputUTXOs() {
if in.Symbolic() {
continue
}
txID, _ := in.InputSource()
_, err := tx.vm.state.GetTx(txID)
switch err {
case nil:
// Tx was already accepted
case database.ErrNotFound:
txIDs.Add(txID)
default:
return nil, err
}
}
return txIDs, nil
}
func (tx *Tx) Bytes() []byte {
return tx.tx.Bytes()
}
func (tx *Tx) Verify(ctx context.Context) error {
if s := tx.Status(); s != choices.Processing {
return fmt.Errorf("%w: %s", errTxNotProcessing, s)
}
return tx.tx.Unsigned.Visit(&executor.SemanticVerifier{
Backend: tx.vm.txBackend,
State: tx.vm.state,
Tx: tx.tx,
})
}
+30 -92
View File
@@ -19,8 +19,7 @@ import (
"github.com/luxfi/address"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/engine/dag"
dagvertex "github.com/luxfi/consensus/engine/dag/vertex"
consensuschain "github.com/luxfi/consensus/engine/chain"
"github.com/luxfi/constants"
"github.com/luxfi/container/linked"
"github.com/luxfi/database"
@@ -66,6 +65,17 @@ var (
errIncompatibleFx = errors.New("incompatible feature extension")
errUnknownFx = errors.New("unknown feature extension")
errGenesisAssetMustHaveState = errors.New("genesis asset must have non-empty state")
// Compile-time check that *VM satisfies chain.ChainVM (= block.ChainVM)
// AND the consensus engine's BlockBuilder. Together these prove X-Chain
// takes the LINEAR ⅔-stake cert path in the chain manager (buildChain →
// consensuschain.NewRuntime), not the DAG path. BuildBlock is promoted
// from the embedded blockbuilder.Builder (the real linear builder: parent
// = preferred, height = parent+1, real timestamp). Do NOT add a
// GetEngine() dag.Engine method: that routes the manager's type switch
// back to createDAG and bypasses the certificate.
_ chain.ChainVM = (*VM)(nil)
_ consensuschain.BlockBuilder = (*VM)(nil)
)
// BCLookup provides blockchain alias lookup
@@ -115,7 +125,7 @@ type VM struct {
registerer metrics.Registerer
connectedPeers map[ids.NodeID]*version.Application
connectedPeers map[ids.NodeID]*consensusversion.Application
parser block.Parser
@@ -170,21 +180,14 @@ type VM struct {
classicalCompatRegistry auth.ClassicalCompatRegistry
}
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, version *version.Application) error {
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, nodeVersion *consensusversion.Application) error {
// If the chain isn't linearized yet, we must track the peers externally
// until the network is initialized.
if vm.network == nil {
vm.connectedPeers[nodeID] = version
vm.connectedPeers[nodeID] = nodeVersion
return nil
}
// Convert to consensus version type
consensusVer := &consensusversion.Application{
Name: version.Name,
Major: version.Major,
Minor: version.Minor,
Patch: version.Patch,
}
return vm.network.Connected(ctx, nodeID, consensusVer)
return vm.network.Connected(ctx, nodeID, nodeVersion)
}
func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
@@ -281,7 +284,7 @@ func (vm *VM) initialize(
// Get metrics from a global registry or create new one
vm.registerer = metric.NewRegistry()
vm.connectedPeers = make(map[ids.NodeID]*version.Application)
vm.connectedPeers = make(map[ids.NodeID]*consensusversion.Application)
// Initialize metrics as soon as possible
vm.metrics, err = xvmmetrics.New(vm.registerer)
@@ -421,7 +424,7 @@ func (vm *VM) SetState(_ context.Context, stateNum uint32) error {
}
}
func (vm *VM) Shutdown() error {
func (vm *VM) Shutdown(context.Context) error {
if vm.state == nil {
return nil
}
@@ -618,15 +621,8 @@ func (vm *VM) Linearize(ctx context.Context, stopVertexID ids.ID, toEngine chan<
}
// Notify the network of our current peers
for nodeID, version := range vm.connectedPeers {
// Convert to consensus version type
consensusVer := &consensusversion.Application{
Name: version.Name,
Major: version.Major,
Minor: version.Minor,
Patch: version.Patch,
}
if err := vm.network.Connected(ctx, nodeID, consensusVer); err != nil {
for nodeID, nodeVersion := range vm.connectedPeers {
if err := vm.network.Connected(ctx, nodeID, nodeVersion); err != nil {
return err
}
}
@@ -658,26 +654,6 @@ func (vm *VM) Linearize(ctx context.Context, stopVertexID ids.ID, toEngine chan<
return nil
}
func (vm *VM) ParseTx(_ context.Context, bytes []byte) (dag.Tx, error) {
tx, err := vm.parser.ParseTx(bytes)
if err != nil {
return nil, err
}
err = tx.Unsigned.Visit(&txexecutor.SyntacticVerifier{
Backend: vm.txBackend,
Tx: tx,
})
if err != nil {
return nil, err
}
return &Tx{
vm: vm,
tx: tx,
}, nil
}
/*
******************************************************************************
********************************** JSON API **********************************
@@ -895,19 +871,22 @@ func (vm *VM) onAccept(tx *txs.Tx) {
vm.walletService.decided(txID)
}
// WaitForEvent implements the engine.VM interface
func (vm *VM) WaitForEvent(ctx context.Context) (interface{}, error) {
// WaitForEvent blocks until the VM has work for the consensus engine (a
// pending-tx event) or ctx is cancelled. It returns a vmcore.Message
// (= block.Message) so *VM satisfies the linear chain.ChainVM interface used by
// the certificate path.
func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
if vm.toEngine == nil {
// Before linearization, no events to wait for
// Before linearization, no events to wait for.
<-ctx.Done()
return vmcore.PendingTxs, ctx.Err()
return vmcore.Message{}, ctx.Err()
}
select {
case msgType := <-vm.toEngine:
return msgType, nil
case msg := <-vm.toEngine:
return msg, nil
case <-ctx.Done():
return vmcore.PendingTxs, ctx.Err()
return vmcore.Message{}, ctx.Err()
}
}
@@ -917,47 +896,6 @@ func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
return nil, nil
}
// BuildVertex builds a new vertex - required for LinearizableVMWithEngine
func (vm *VM) BuildVertex(ctx context.Context) (dagvertex.Vertex, error) {
// XVM doesn't use vertices, it uses blocks
return nil, errors.New("XVM does not support vertex building")
}
// GetVertex gets a vertex by ID - required for LinearizableVMWithEngine
func (vm *VM) GetVertex(ctx context.Context, vtxID ids.ID) (dagvertex.Vertex, error) {
// XVM doesn't use vertices, it uses blocks
return nil, errors.New("XVM does not support vertex operations")
}
// ParseVertex parses vertex bytes - required for LinearizableVMWithEngine
func (vm *VM) ParseVertex(ctx context.Context, vtxBytes []byte) (dagvertex.Vertex, error) {
// XVM doesn't use vertices, it uses blocks
return nil, errors.New("XVM does not support vertex parsing")
}
// GetEngine returns the consensus engine - required for LinearizableVMWithEngine
func (vm *VM) GetEngine() dag.Engine {
// XVM doesn't have a separate engine, return a new DAG engine
return dag.New()
}
// SetEngine sets the consensus engine - required for LinearizableVMWithEngine
func (vm *VM) SetEngine(engine interface{}) {
// XVM doesn't use a separate engine
}
// GetTx returns a transaction by ID - required for LinearizableVMWithEngine
func (vm *VM) GetTx(ctx context.Context, txID ids.ID) (dag.Transaction, error) {
tx, err := vm.state.GetTx(txID)
if err != nil {
return nil, err
}
return &Tx{
vm: vm,
tx: tx,
}, nil
}
// noOpHandler is a simple no-op implementation of warp.Handler
type noOpHandler struct{}
+1 -1
View File
@@ -90,7 +90,7 @@ func TestXVMInitialize_WiresSecurityProfileIntoMempool(t *testing.T) {
Sender: &noOpSender{},
},
))
t.Cleanup(func() { _ = vmImpl.Shutdown() })
t.Cleanup(func() { _ = vmImpl.Shutdown(context.Background()) })
// Linearize so the mempool builder is constructed and SetAuthPolicy
// has fired with the strict-PQ profile.
-166
View File
@@ -14,7 +14,6 @@ import (
"github.com/luxfi/vm"
"github.com/luxfi/runtime"
"github.com/luxfi/constants"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
@@ -365,107 +364,6 @@ func TestVMFormat(t *testing.T) {
}
}
func TestTxAcceptAfterParseTx(t *testing.T) {
require := require.New(t)
env := setup(t, &envConfig{
fork: upgrade.Default,
notLinearized: true,
})
defer env.vm.Lock.Unlock()
var (
key = keys[0]
kc = secp256k1fx.NewKeychain(key)
)
firstTx, err := env.txBuilder.BaseTx(
[]*lux.TransferableOutput{{
Asset: lux.Asset{ID: env.genesisTx.ID()},
Out: &secp256k1fx.TransferOutput{
Amt: startBalance - env.vm.TxFee,
OutputOwners: secp256k1fx.OutputOwners{
Threshold: 1,
Addrs: []ids.ShortID{key.PublicKey().Address()},
},
},
}},
nil, // memo
kc,
key.Address(),
)
require.NoError(err)
// Find the output index that holds the requested startBalance-TxFee
// amount; firstTx may have a separate change output, and the canonical
// output sort (ZAP-native wire envelope bytes per LP-023) determines
// which index that lands on.
requestedAmt := startBalance - env.vm.TxFee
firstBaseTx := firstTx.Unsigned.(*xvmtxs.BaseTx)
var requestedIdx uint32
found := false
for i, out := range firstBaseTx.Outs {
o, ok := out.Out.(*secp256k1fx.TransferOutput)
if !ok {
continue
}
if o.Amt == requestedAmt {
requestedIdx = uint32(i)
found = true
break
}
}
require.True(found, "firstTx must produce an output with the requested amount")
// let secondTx spend firstTx outputs
secondTx := &xvmtxs.Tx{Unsigned: &xvmtxs.BaseTx{
BaseTx: lux.BaseTx{
NetworkID: constants.UnitTestID,
BlockchainID: env.vm.XChainID,
Ins: []*lux.TransferableInput{{
UTXOID: lux.UTXOID{
TxID: firstTx.ID(),
OutputIndex: requestedIdx,
},
Asset: lux.Asset{ID: env.genesisTx.ID()},
In: &secp256k1fx.TransferInput{
Amt: requestedAmt,
Input: secp256k1fx.Input{
SigIndices: []uint32{
0,
},
},
},
}},
},
}}
require.NoError(secondTx.SignSECP256K1Fx(env.vm.parser.Codec(), [][]*secp256k1.PrivateKey{{key}}))
parsedFirstTx, err := env.vm.ParseTx(context.Background(), firstTx.Bytes())
require.NoError(err)
require.NoError(parsedFirstTx.Verify(context.Background()))
require.NoError(parsedFirstTx.Accept(context.Background()))
// Update the preferred block (normally done by consensus engine)
require.NoError(env.vm.SetPreference(context.Background(), parsedFirstTx.ID()))
parsedSecondTx, err := env.vm.ParseTx(context.Background(), secondTx.Bytes())
require.NoError(err)
require.NoError(parsedSecondTx.Verify(context.Background()))
require.NoError(parsedSecondTx.Accept(context.Background()))
// Update the preferred block (normally done by consensus engine)
require.NoError(env.vm.SetPreference(context.Background(), parsedSecondTx.ID()))
_, err = env.vm.state.GetTx(firstTx.ID())
require.NoError(err)
_, err = env.vm.state.GetTx(secondTx.ID())
require.NoError(err)
}
// Test issuing an import transaction.
func TestIssueImportTx(t *testing.T) {
require := require.New(t)
@@ -544,70 +442,6 @@ func TestIssueImportTx(t *testing.T) {
env.vm.Lock.Unlock() // Final unlock (no defer in this test)
}
// Test force accepting an import transaction.
func TestForceAcceptImportTx(t *testing.T) {
require := require.New(t)
env := setup(t, &envConfig{
fork: upgrade.Default,
notLinearized: true,
})
defer env.vm.Lock.Unlock()
genesisTx := getCreateTxFromGenesisTest(t, env.genesisBytes, "LUX")
luxID := genesisTx.ID()
key := keys[0]
utxoID := lux.UTXOID{
TxID: ids.ID{
0x0f, 0x2f, 0x4f, 0x6f, 0x8e, 0xae, 0xce, 0xee,
0x0d, 0x2d, 0x4d, 0x6d, 0x8c, 0xac, 0xcc, 0xec,
0x0b, 0x2b, 0x4b, 0x6b, 0x8a, 0xaa, 0xca, 0xea,
0x09, 0x29, 0x49, 0x69, 0x88, 0xa8, 0xc8, 0xe8,
},
}
txAssetID := lux.Asset{ID: luxID}
tx := &xvmtxs.Tx{Unsigned: &xvmtxs.ImportTx{
BaseTx: xvmtxs.BaseTx{BaseTx: lux.BaseTx{
NetworkID: constants.UnitTestID,
BlockchainID: env.vm.XChainID,
Outs: []*lux.TransferableOutput{{
Asset: txAssetID,
Out: &secp256k1fx.TransferOutput{
Amt: 10,
OutputOwners: secp256k1fx.OutputOwners{
Threshold: 1,
Addrs: []ids.ShortID{keys[0].PublicKey().Address()},
},
},
}},
}},
SourceChain: constants.PlatformChainID,
ImportedIns: []*lux.TransferableInput{{
UTXOID: utxoID,
Asset: txAssetID,
In: &secp256k1fx.TransferInput{
Amt: 1010,
Input: secp256k1fx.Input{
SigIndices: []uint32{0},
},
},
}},
}}
require.NoError(tx.SignSECP256K1Fx(env.vm.parser.Codec(), [][]*secp256k1.PrivateKey{{key}}))
parsedTx, err := env.vm.ParseTx(context.Background(), tx.Bytes())
require.NoError(err)
require.NoError(parsedTx.Verify(context.Background()))
require.NoError(parsedTx.Accept(context.Background()))
id := utxoID.InputID()
_, err = env.vm.SharedMemory.Get(constants.PlatformChainID, [][]byte{id[:]})
require.ErrorIs(err, database.ErrNotFound)
}
func TestImportTxNotState(t *testing.T) {
require := require.New(t)
+1 -1
View File
@@ -318,7 +318,7 @@ func setup(t testing.TB, config *envConfig) *testEnv {
// This ensures PushGossip and PullGossip goroutines are properly terminated
t.Cleanup(func() {
// Shutdown the VM to cancel onShutdownCtx and stop gossip goroutines
_ = vmImpl.Shutdown()
_ = vmImpl.Shutdown(context.Background())
})
// Linearize the DAG to initialize the network