Files
zeekay 538b88d7b9 conformance: the X-chain's semantic pass, judged against something
The differential compared five fields of every X vector and the sixth,
`exec`, of none of them. Both evaluators reported the semantic pass as not
evaluated, on the grounds that judging it needs a funded UTXO set — so the
runner had one voice on that field, listed it under NOT COMPARED, and was
right to. A field one implementation answers is not a comparison.

It does not need a funded set. The P-chain's vectors have always been
judged on a chain that holds NOTHING, and the reason is written down in
`execp.go`: a funded state would have to be built three times, once per
language, and the differential would then be measuring three state builders
instead of three chains. What survives an empty chain is the verdict CLASS,
and that is the shape a fork has — a chain that refuses a kind by name
answers UNSUPPORTED where a chain that looks the input up answers LEDGER.

So the X-chain is judged the same way. Go runs `SemanticVerifier` and then
`Executor` over a fresh diff on an empty `xvm/state`, through the node's own
types; Rust runs `verify_semantic` and then `execute` over an empty `Store`.
Both are given an empty shared area as well, so an import refuses for having
nothing to import rather than for having nowhere to look. Both answer
LEDGER on all five, computed on each side from the bytes.

That the field is now really compared was checked by breaking it: a Rust
evaluator that skips the semantic pass and applies the transaction anyway
answers OK, and the runner fails with five disagreements against Go and
five against the recorded corpus. Under the old harness that same chain —
one that never asks whether a transaction is allowed — passed green.

The Go backend was doubled: the syntactic verifier built one and the new
semantic path would have built another. There is one now, `xBackend()`, so
a vector cannot be judged under one wiring and executed under another.

C++ still declines, and the runner still says so.
2026-09-04 22:37:46 -07:00

10 KiB

lux-xvm — the Lux X-Chain in Rust

The UTXO ledger: value exists as unspent outputs, a transaction consumes some and produces others, a block is an ordered run of transactions plus the root of the state they leave behind. Ported from the Go reference at ~/work/lux/node/vms/xvm (13,306 lines, 43 test files), behind the object-safe VM seam of ~/work/lux-rs/node (src/vm.rs) — which this crate imports rather than restates.

17,222 lines across 29 files. 314 tests, all green. No stubs.

Build

PATH=~/.cargo/bin:$PATH LUX_LIB_DIR=~/work/lux/crypto/dist cargo test

LUX_LIB_DIR is needed because this crate names the node, the node holds an ML-DSA identity and an ML-KEM session, and both come from libluxcrypto through lux-pq. build.rs repeats the directory that crate resolved rather than searching for a second one, so this crate's test binaries — which run the node — can load.

What proves it is the same chain

tests/golden.rs. Twelve vectors printed by the Go X-chain (luxfi/node/vms/xvm on luxfi/zap v1.2.7, luxfi/utxo v0.5.10) and pasted in unchanged, covering all five transaction types, a block, and every fx primitive envelope. Each asserts three things: the unsigned bytes a signature is computed over, the signed bytes that go on the wire, and the id — which is the SHA-256 of those bytes, so a difference of one byte is two names for one transaction.

src/state/root.rs carries the execution-root KAT verbatim from state/xvmroot/xvmroot_test.go — the value the Go package and all seven GPU backends produce. It passes.

tests/node.rs runs the chain inside lux-rs/node itself: the node's registry finds it, the node's JSON-RPC server routes a call to it over a real socket, the node's sealed plugin link drives build/verify/accept across a process boundary, and the node's engine reads the two roots a certificate is made of out of a block this chain built. Nothing there is a mock.

src/bin/conformance.rs answers the shared corpus under conformance/ — the differential that hands one corpus to the Go, Rust and C++ X-chains and fails on any field two of them answer differently.

Layout

zap.rs        the serialization: a fixed section of known offsets plus a tail
wire/         the (family, shape) envelopes and the container shapes
fx/           the three fx families and the rules by which a credential spends
utxo.rs       an output, an input, an unspent output, and the flow check
txs/          the five transactions, their encoding, signing, and executor/
state/        what the chain knows, in two shapes: committed, and what-would-be
db.rs         where the chain writes itself down, so a restart remembers
block/        a block, how one is built, and the state machine over them
mempool.rs    what waits to go in a block, and what is refused before it waits
security.rs   on what terms a transaction is admitted at all
gossip.rs     how a transaction reaches the network, and the filter it is in
vm.rs         the chain behind the node's seam

Five decisions worth knowing

The seam is imported, not stated. lux_node::vm is host here, and Id is lux_consensus::finality::Id — the node's own trait and the node's own id, not a copy of either. A restated seam compiles against a shape the node never sees and keeps compiling on the day the node changes a method; a second 32-byte id would need translating at every call, and a node holding two chains that each minted one could not put them in one map.

There is one mempool, and one door into it. A wallet's transaction and a peer's arrive at the same Xvm::issue, and the refusals run in Go's order: already held, already refused for a reason still remembered, does it verify against the preferred state, does it fit, does it conflict, is it allowed. Every check before the verification costs a lookup — that ordering is the whole anti-flood argument. The pool lives inside gossip::Gossip, so what is held is also what this node advertises and what it has to pass on; there is nothing to keep in step.

The security profile is where a transaction enters. A chain that is post-quantum in its signatures and classical in its mempool is classical. Xvm::hold_to is Go's SetAuthPolicy, and under the strict profile with no exemption list this chain admits nothing — every fx family it runs spends with a secp256k1 signature. That refusal is the correct answer rather than a gap.

This is the one place the port is deliberately stricter than the reference, and it is pinned by a test that says so. Go asks the question as a type assertion, c.(*secp256k1fx.Credential); nftfx.Credential and propertyfx.Credential each EMBED that type rather than being it, so the assertion fails and Go admits both under the strict profile — admitting a secp256k1 signature, recovered by the same curve, which is exactly what the profile exists to refuse. Go's own X-chain test (vm_security_profile_test.go) only ever offers a bare secp256k1fx.Credential, so the case is untested there rather than decided there. Being stricter cannot split the chain: this gate runs at admission and nowhere in block verification, so a node holding this rule builds from fewer transactions and still accepts every block a Go node produces. The disagreement is about what a node will relay, which is policy, not about what is true.

Nothing reaches the disk until a block is accepted. Manager::accept puts the block, everything its transactions changed, and the position it moved the chain to into one batch. A restart therefore never sees a block applied at a height the chain has not reached, and a block that was verified and then lost leaves nothing behind.

A block and the value it moved across a chain boundary are ONE write. When an accepted block imports or exports, Manager::accept does not write its state and then ask the shared area to do its part; it STAGES the state (Chain::commit_batch) and hands that batch to SharedMemory::apply, which writes the shared area's changes and the block's own in a single write. This is Go's arrangement — Block.Accept stages CommitBatch and passes it to SharedMemory.Apply, which combines it with WriteAll — and it is the whole point: as two writes, an interruption between them credits an import whose source was never spent, or spends an export nobody was ever handed. A chain with no shared area refuses such a block (Error::NoSharedMemory) rather than writing the half it can. block/manager.rs counts the writes in a test, because atomicity is a property of how many chances there are to stop, not of what ends up stored.

The asset family of the state root is empty, deliberately. The chain's state is unspent outputs and nothing else — an asset exists only as the id stamped on the outputs its creating transaction produced. Projecting an asset arena would mean inventing state the executor does not keep. Go does the same thing for the same reason (block/executor/executionroot.go), and the asset binding survives through each UTXO leaf's asset id.

Storage

db::Db is an ordered map from bytes to bytes, written in batches that either all happen or none do. The keys are Go's, byte for byte — utxo, tx, blockID, block, singleton, and the singleton keys 0x00, 0x01, 0x02, with a height packed big-endian. What is under a key is the object's canonical ZAP encoding, the same bytes that go on a wire and that the id is taken over, so a stored block hashes to the id it is filed under and Store::on checks exactly that when it reads one back.

This is NOT Go's on-disk file format. That format is LevelDB's; no rule in this chain is stated over it and two nodes never exchange it. db::Log is ours: one framed record per batch, flushed to the device before the write returns, replayed in order on open. A torn tail is a batch that never returned to a caller, so it is dropped and the file is cut back to the last whole frame.

The timestamp is seconds since the epoch, big-endian, where Go writes a time.Time's own binary form. A language's internal representation of a clock reading is not a ledger value, and the chain's timestamp is seconds everywhere else it appears.

What was ported, and what was left

Ported: the ZAP object format; the wire envelopes; secp256k1 / nft / property fx with their spend rules; all five transactions with their encoding, signing and parsing; the syntactic, semantic and execution passes; the store and the diff stack; the block, its builder, and the verify/accept/reject machine; the execution root and the owner root; the VM behind the seam; persistence; the bounded conflict-aware mempool with its memory of refusals; the strict post-quantum admission profile; the gossip set and its Bloom filter, function for function with p2p/gossip/bloom.go.

Not ported, and none of it is ledger behaviour:

  • the node's transaction indexer (index_test.go)
  • the sockets themselves. This crate holds the SET and answers the three questions a p2p layer asks — do you have this, here is one, what do you already know — and which socket they arrive on is the node's business.
  • the RPC op registry and its doc/tool generators (ops.go, ops_test.go)
  • the genesis-building static service (static_service*.go)
  • config parsing (config/)
  • Go's parallel Merkle fold and the tests that assert it equals the serial one. There is one fold here, so the property has nothing to compare against; the KAT pins the bytes either way.

Known, and not hidden

The X vectors' exec field IS compared, by Go and by this port. Both run the semantic pass and then the executor over a chain that holds nothing — the same arrangement the P-chain's vectors have always been judged under, and for the same reason: a funded state would have to be built three times and the differential would then be measuring three state builders rather than three chains. What survives an empty chain is the verdict CLASS, which is the shape a fork has. All five vectors answer LEDGER on both sides, and a chain that skipped the semantic pass and answered OK fails the run.

It reads as SKIPPED in C++ still. One voice on a field is not a comparison, so the runner lists it — and until Go and this port both answered, that is what the field was: a chain that never checked whether a transaction was allowed passed the differential green. Giving the C++ evaluator the same two passes on its own empty chain closes the last voice.