Files
zeekayandHanzo Dev df0b9934b3 Pass the vote sender to the engine; state the BLS signature length
Local, unreleased; go.mod still pins luxfi/consensus v1.36.94.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 17:29:55 -07:00

37 KiB
Raw Permalink Blame History

node

github.com/luxfi/node — the Lux node. The Go implementation IS this module: cmd/luxd and everything under it are these sources, so the repository is the thing its import path names. The Rust and C++ implementations sit beside it, and the differentials here hold all three to the same answers.

Layout

cmd/luxd                the daemon
node/vms.go             the one chain registry — what runs, what is a plugin, what needs consent
mesh/                   the peer layer: dialer, post-quantum handshake, peer, throttling, tracker
network/                a set of chains that share a validator set and bootstrap together
chains/                 the chain manager, and chains/{rust,cpp}/* the ports
chains/schema/*.zap     the wire, from which the accessors are generated for all three languages
runtime/{go,rust,cpp}   what each implementation builds, from where, and the exact invocation
gpu/                    the primitive seam: a complete CPU backend, an optional kernel plugin
conformance/            the corpora and the harnesses that run them per language
bin/{go,rust,cpp}/luxd  build output (gitignored)
Makefile                the one build entry point

The three implementations

Each is a running node: it executes blocks itself, decides them by BLS quorum certificate over a real mesh, and serves them. None imports another. make luxd RUNTIME=<go|rust|cpp> builds one; make all builds three and exits nonzero unless all three come out.

Go builds from ./cmd/luxd in this module and fetches nothing for it. It runs the primary network's chains — P X C Q Z A B G K M F natively, D through its plugin — and the closure is asserted clean at build time.

Rust builds ~/work/lux-rs/node, C++ builds ~/work/lux-cpp/node. The shims under runtime/ name the checkout and the invocation; nothing is copied or forked, and only the binary is carried back into bin/. Delete this repository and both are exactly as they were.

One brand. This repository builds luxd and names no other. Downstream networks layer their own identity in their own repositories and take these as a dependency — hanzoai/node builds hanzod, zooai/node builds zood.

The one chain registry

node/vms.go holds every chain in one map, and each row says the three things that differ between them: whether the factory is built in node.go with runtime dependencies, whether the chain brings its own plugin binary, and whether an operator has to name it before it runs.

A chain asks for consent when running it costs an operator something that validating the primary network did not — co-location with a matcher, an HSM, a GPU, a custody role. luxd --chains=D,B,M names them, by letter or alias, and an unknown name is refused at boot rather than ignored. A chain becomes permissionless by dropping Consent from its row; nothing else changes.

What is not here

The D-Chain and F-Chain ports are in lux-cpp/chains and lux-rs/chains, public, beside this repository; the differentials build them from those checkouts, so those rows are still measured. The pure-Go DEX in luxfi/dex is the protocol they are ports of. The GPU kernels are in lux-gpu/gpu, which is private; gpu/ here is the seam they plug into, and its CPU backend is complete, so a node that never finds a kernel library is a whole node.

Building against live checkouts

The Rust and C++ trees are shared checkouts that other sessions edit while this Makefile reads them. Every sub-build's exit code is checked and a failure is reported rather than absorbed — cargo and ctest have both reported stale success through a wrapper that did not look. Nothing here writes to those trees to make a build go. If one fails, retry; if it persists, git status in the checkout before suspecting the shim.

The primitive seam (gpu/)

gpu/ is not a shim. It is the one place a chain asks for a hash or a signature check, in all three languages, and the one place the choice between computing the answer and handing it to an installed kernel library is made.

Kernels are private and none of their source is here; what crosses into this public tree is four symbol names, dlopen'd at run time. The CPU backend is COMPLETE and is the definition of every primitive — a node with nothing installed is a whole node, and make luxd RUNTIME=go builds exactly that (CGO_ENABLED=0 has no way to open a shared library, so it never has a plugin). The plugin is a strict positive overlay: absent, declining, or erroring, the CPU answers.

One knob, LUX_GPU ∈ off | on | verify, plus LUX_GPU_LIB for the path. verify computes both answers and stops on the first differing byte; make gpu-differential runs every seam twice, once with no library visible and once under verify.

Only keccak256_batch — and the RFC 6962 fold built on it — has two paths today. sha256 and ripemd160 have no op in the plugin ABI at all, and its ecrecover answers a different question (an Ethereum address, where a Lux address is ripemd160(sha256(compressed key))). gpu/README.md has the table, what the differential actually measured on this machine, and the one cross-language disagreement about secp256k1 recovery ids that consolidating the primitives turned up.

The consensus engine

luxd runs luxfi/consensus engine/chain at the version go.mod pins (v1.36.95). A block is accepted — VM.Accept, the ledger's decided height — only on the ⅔ certificate: signers holding more than two thirds of the signing stake and at least ⌊2n/3⌋+1 of the n signers, over a set of at least four. A bare majority accepts nothing, so one equivocating validator cannot decide two blocks at one height. A certificate served for a height decided before this rule is read at the ⅔ rung: four of five signers prove the height, three do not.

Upgrade: every validator of a network moves to v1.37.12 together. A node before it accepts on a majority certificate this one refuses, so a mixed set can decide different blocks at a height. Stop all, upgrade all, start all.

At one height a validator signs the lowest sibling that can still reach the ⅔ floor given the verified votes it has seen, proposes nothing beside a live sibling, pushes the block it signed with its vote again when a sibling lands beside it, passes on each proposer's first block at a height, caps undecided blocks at 4 a proposer and 64 a height, and settles until one deadline per height. The vote gossip handler passes the transport-authenticated sender (HandleIncomingVote(nodeID, …)): a vote for a block this node lacks is parked only when it is the sender's own, and at the BLS signature length (blsVoteVerifier.SignatureLen). The rules and where they live: that repo's LLM.md, "Siblings at one height". C-Chain blocks are proposervm blocks and state their proposer (Proposer()), which the caps count by; P- and X-Chain blocks (pChainHeightVM) state none and count only toward the 64 a height.

Conformance

~/work/lux/consensus/conformance (tag v1.36.91) is the finality standard written down as data — what a validator signs, what a certificate looks like on the wire, what the finality predicate decides — read live from the Go definitions, never restated, so a rule change moves the corpus and the failure is at the source. Rust and C++ each hold their own suite that checks against it byte for byte, and both already pass. make conformance runs all three, for real, every time: go test in the consensus repo, five cargo test binaries in pkg/rust (pop_conformance and verdict_conformance are the two the corpus is named for), and the two prebuilt C++ binaries conformance_test and pop_conformance_test. A runtime whose harness is not configured prints skipped and says why — a skip is never reported as a pass.

make chains and make precompiles are the other two differentials, and they are the same machine with different subjects: one corpus, three implementations, a runner that understands nothing and compares strings. The runner is shared — it is told which columns to compare, because a runner that knew the chain's five field names would have to learn the next differential's as well — and it fails a run in which any implementation printed no row at all, since with four voices three answering is still a comparison, it agrees, and the fourth's silence would read as a pass.

make chains passes. It covers seven chains — P and X from luxfi/node, Q, Z, D and F from luxfi/chains, and O from luxfi/oracle, which had no vector at all until they were added, which is the same shape the P-chain fork hid in. All three columns now answer all seven, and every compared field of all 942 vectors agrees.

O was added last and taught three things before a line of either port was written. chains/oraclevm is 213 lines and is NOT the chain: it re-exports luxfi/oracle/vm, which is 1628 — and chains/relayvm is 161 lines over luxfi/relay/vm's 2077, so the two chains that looked smallest by an order of magnitude were the two that were not there. The O-chain's wire is not a codec frame but encoding/json, and its block id is the SHA-256 of the block marshalled AGAIN, so a block does not round-trip its own id. And its genesis block is built with time.Unix, which is LOCAL time, so the same genesis file gives block id 6cf00752… on a -08:00 box and f4970a27… under TZ=Asia/Kolkata — two validators in two timezones are on different chains from block zero. That one is a bug in the chain and it is unfixed; the corpus routes around it by pinning the genesis through a re-marshal instead, and conformance/README.md says so rather than letting the workaround hide it.

The Rust column was the last to close, and the last two chains in it each said something. chains/rust/zkvm had sixteen modules, no VM to hold them and no evaluator; given both it agreed with Go on all 137 Z vectors on its first run, so the fifteen thousand lines under it had been right and unexercised. chains/rust/fhevm answered every F vector and disagreed with Go on seven — all in the F_JSON_* group, all the same kind of mistake. Its JSON decoder was stricter than encoding/json in four ways nobody had written down: it refused a null payload where Go reads the zero struct, refused a stray } or ] after a complete value where Go's Decoder.More reports no further element, folded member names by ASCII case where Go folds by unicode.SimpleFold, and refused a base64 word broken across a line that encoding/base64 reads straight through. Each would have refused a transaction the reference admits, which on a live chain is a node that refuses a block its peers accepted. conformance/README.md states all four rules.

make precompiles is described in conformance/PRECOMPILE.md. Across 258 calls the three runtimes now agree on every field but eight, no column declines one, and what is left is a single sentence: Rust and C++ do not implement three module families the chain enables. They are aiMiningConfig at 0x0300…0000, mlkemConfig at 0x…012201 and xwingConfig at 0x…2221 — Go serves each because mainnet's upgrade.json names its key, the two ports serve none of them, and an address one runtime answers at and another does not is a fork whichever way it falls. That is an implementation gap with a size, which is what a differential is for.

Everything else it was reporting was the harness rather than the chain, and all of it came back to one question the Go reference was not asking — what does the C-Chain actually serve? — and the answer has two halves the reference had both of wrong.

Registration is not activation. Importing luxfi/precompile/registry links fifty-six modules into a binary. LuxPrecompileOverrider answers from GetExtrasRules(...).Precompiles, which holds only the keys a chain's precompileUpgrades name, and it never asks the registry whether the code is linked. The reference asked the registry, so it answered for nineteen modules no network has turned on. It now asks mainnet's enabled set.

The stock table is the chain's, and the chain is at Cancun. luxfi/evm maps its Quasar fork to CancunTime and defines nothing after it — its config carries no OsakaTime for a network to set, so no network sets one.

At 0x…0100 both halves showed at once, which is why three runtimes gave three answers. Two implementations are written for that address and neither runs: the Lux secp256r1 module charges 3450 and secp256r1Config is named in no upgrade.json, and geth's p256verify charges 6900 and arrives at Osaka. So Go read 3450 off the registry, the two ports read 6900 off a table held at a revision no Lux chain reaches, and a call there meets an empty account. All three answer ABSENT.

0x0300…0003 is the same finding with a longer history. aiInferenceConfig is enabled nowhere either — the AI key the chain does enable, aiMiningConfig, is at another address — and cevm had been taught to serve inference there in order to agree with Go. A fix that makes a second node serve an address the first leaves empty is a state-root split adopted to settle a differential, and it is unregistered again.

Holding the ports at Osaka had a second cost that no address made obvious: modexp was priced by EIP-7883 and EIP-7823, which no validator charges. Moving to Cancun moved 25 corpus rows, which is the whole of the corpus diff besides the five at 0x…0100 and the two at 0x0300…0003.

The BLS12-381 modules at 0x0b–0x11 are untouched by any of this, and they are the reason the layout matters: they are on the pre-final EIP-2537, which still had separate multiply precompiles, so from 0x0c up the same address names a different operation to Ethereum than to the chain — 0x0d is a G1 multi-exponentiation to the module and a G2 addition to geth, revm and cevm, different curve groups rather than different gas. All seven keys are enabled on mainnet at a timestamp in December 2025, on testnet and devnet, and at zero on local and localnet, and Cancun's stock table holds nothing there, so the modules are the sole occupants and all three runtimes serve that layout.

Address zero is not unclaimed either: the dead-address module (LP-0150) is registered there, is enabled, and reads chain state, so it cannot be answered for without a chain — and the corpus does not ask.

No column declines a field any more either. revm computes a price inside the work, so its error carries no charge and a refused call used to leave the gas column blank on six fields. A price is the smallest offer a precompile accepts, so the Rust evaluator now bisects the offer and recovers it — 0 where revm rejects a blake2 length before reading the round count, 50000 for a KZG blob it prices before validating — which is what Go and cevm already reported.

So the eight vectors above are the whole of what the differential still finds, and they name work rather than a disagreement about a price: ML-KEM, X-Wing and AI-mining have to be written in the two ports. Five more post-quantum families were reported alongside them until the reference started asking the enabled set — mldsaVerify, slhdsaVerify, pulsarVerify, p3qVerify and coronaThreshold are linked into the Go binary and enabled by no network, so all three runtimes answer ABSENT and there is nothing there to implement.

This is consensus-layer conformance, not node-level — three live bin/luxd-* daemons handed the same blocks over real sockets and checked for agreement is a different, larger harness that does not exist yet, and wiring it before runtime/go has an actual node host to point at would not test what its name claims. It is Phase 2, alongside the Go host itself.

Build

make luxd RUNTIME=go      # bin/go/luxd    — ./cmd/luxd, this module
make luxd RUNTIME=rust    # bin/rust/luxd  — lux-rs/node
make luxd RUNTIME=cpp     # bin/cpp/luxd   — lux-cpp/node
make all                  # all three + gpu; reports each; nonzero exit unless 3/3
make gpu                  # lux-gpu/gpu kernel library
make gpu-differential     # CPU alone, then CPU against the plugin, all three
make conformance          # the pop/verdict corpus, all three languages
make chains               # the seven-chain differential, all three languages
make chains-corpus        # regenerate that corpus from the Go reference
make precompiles          # the EVM precompile differential, all three languages
make precompiles-corpus   # regenerate that corpus from the Go reference

Every sub-build's exit code is checked explicitly — cargo and ctest have both reported stale success from a wrapper that didn't check, so nothing here trusts a green Makefile over a red sub-build.

ZAP, and where it lives

ZAP is a bidirectional binary protocol with pipelining: both peers on a connection initiate, and requests pipeline — many in flight, answers back in whatever order the peer finishes them. It is not a codec, and treating it as one is how this repo got into trouble.

The C++ chains share one wire implementation. It replaced chains/cpp/xvm/include/lux/xvm/zap.hpp (557 lines) and chains/cpp/platformvm/include/lux/platformvm/zap.hpp (517 lines) — and the two copies had drifted from each other and from the reference. Two chains in one binary disagreeing about frames is a fork surface inside one process.

They are gone. Both chains now speak the published SDK, zap::zap from github.com/zap-proto/cpp, pinned in chains/cpp/zap.cmake and reached as a package — never a path into a checkout, never vendored. An installed package wins over the fetch, so -DCMAKE_PREFIX_PATH=<install> keeps an offline build offline.

What the drift actually was, since it is the reason the rule exists:

  • set_bytes must hold its payload until finish(). Both hand-written copies wrote it on the spot, which puts the payload BEFORE a list started on the same builder afterwards; the reference puts it after. Same fields, different bytes. Neither copy matched the reference, and no fixture here covered it — the SDK's tail_then_list case does, against bytes zap-proto/go printed.
  • set_bytes must extend the object to cover the field it writes. platformvm did; xvm did not, and a field past the declared payload size wrote off the reserved region.
  • finish_with_flags and a choosable header version: platformvm had both, xvm neither — so xvm could not have emitted a routed RPC envelope at all.
  • Out-of-line list elements: xvm could read them, platformvm could only write them. One half of a pair each.

The wire-format suite lives in the SDK beside the implementation, case for case. A wire rule belongs in one place, and its test belongs beside it.

Four more copies followed: chains/cpp/zap (which fhevm read), and quantumvm's, zkvm's and dexvm's. 2,198 lines. Every C++ chain now links zap::zap and nothing else.

A nuance about set_bytes, since the paragraph above is half the story

There are two Go runtimes, and they differ here. zap-proto/go holds a byte tail until Finish(); github.com/luxfi/zap, the hardened runtime the chain corpus was generated through, appends it on the spot. They produce the same bytes for exactly one write order — every payload first, then the object, then nothing but field setters — and every builder in this tree happens to write that way, which is why nothing ever caught it.

That is now a property of the code rather than an accident: zapgen EMITS that order, and a test in the generator pins it.

The wire's accessors come out of a schema

The runtime is one implementation. What sits on top of it — the offsets, the strides, the readers and the builders — was written by hand, per chain, per language, and that is what a generator is for.

zapgen has a C++ backend, so a chain states its wire once in a .zap schema beside it and the accessors are printed:

chains/cpp/xvm/schema/wire.zap             ->  include/lux/xvm/gen/wire_zap.hpp
chains/cpp/quantumvm/schema/wire.zap       ->  include/lux/quantumvm/gen/wire_zap.hpp
chains/cpp/platformvm/schema/wire.zap      ->  include/lux/platformvm/gen/wire_zap.hpp
chains/cpp/platformvm/schema/genesis.zap   ->  include/lux/platformvm/gen/genesis_zap.hpp
chains/cpp/platformvm/schema/warp.zap      ->  include/lux/platformvm/gen/warp_zap.hpp
chains/cpp/platformvm/schema/warpmsg.zap   ->  include/lux/platformvm/gen/warpmsg_zap.hpp

One schema per namespace, because a package name is what a backend renders as a namespace and a file declares one. The P-chain's four cover every shape it puts on a wire or in a store: nineteen transactions, three blocks, the credentials, an owner set, the two fx envelopes an output is ordered by, the genesis blob, and the six warp messages.

cmake --build build --target wire-schema regenerates (genesis-schema, warp-schema, warpmsg-schema for the others); the output is committed, so a build needs neither Go nor the generator. The generator has its own published pin, ZAPGEN_TAG in chains/cpp/zap.cmake, separate from the runtime's ZAP_TAG — they are different repositories, and one tag naming both is how that target came to point at a version that was never published.

The four list shapes the Lux wire actually has are DERIVED from the element type, never declared — a run of numbers at its own width, a run of fixed-width byte records, a run of struct payloads back to back, and a run of relative pointers when the element carries a tail and cannot sit inline. A schema therefore cannot spell one list two ways.

What is left in each chain's wire.hpp is what the bytes MEAN: the X-chain's (TypeKind, ShapeKind) discriminator and its quorum gate, the Q-chain's canonicality rule and its bound on a transaction count. Neither spells an offset.

The P-chain kept 283 lines beside its four schemas, and not one of them is an offset. What is there is the ARRANGEMENT, which is a fact about the chain rather than about the format: an output's owner addresses do not live in the output, they live in one run shared by the whole transaction, and the output names a slice of it. That decision is stated once instead of at the nineteen places a transaction is built.

Still by hand, and named rather than left to be found: zkvm (1,452 lines), fhevm (913), dexvm (585).

The Rust chains: generated, from chains/schema

Rust had the same drift — chains/rust/{xvm,quantumvm,zkvm}/src/zap.rs at 896 lines each, platformvm's at 770 and fhevm's at 598, five copies of one reader. They are gone, and so is every hand-written accessor over them.

Every byte a Rust chain writes now comes out of a schema. The schemas are in chains/schema, one per wire, and zapgen writes the accessors:

schema what it states emitted to
pchain.zap the P-chain's transactions, blocks, credentials, and the fx primitives it spends platformvm/src/pchain_zap.rs
warp.zap the warp envelope, its payloads, and the conversion preimage platformvm/src/warp_zap.rs
state.zap what the P-chain writes to its own disk platformvm/src/state_zap.rs
xchain.zap the X-chain's transactions, blocks and fx primitives xvm/src/xchain_zap.rs
qchain.zap the Q-chain's blocks and transactions quantumvm/src/qchain_zap.rs
zchain.zap the Z-chain's shielded transactions and blocks zkvm/src/zchain_zap.rs
fchain.zap the F-chain's transactions and blocks fhevm/src/fchain_zap.rs

One runtime under all of them: chains/rust/zap, also emitted, which every chain takes as a path dependency. make wire rewrites all of it and is a fixed point — run it and git diff is empty, which is how you check that what is committed is what the schemas say. The generator is pinned by exact commit, because a moving reference would let the accessors change without the schema changing.

Two things that are NOT in a schema and should not be: the two bytes an fx primitive travels behind (the family and the shape), and the framing of a run of variable-width items as a length list beside one blob. Both are the chain's rules about a sequence of messages, not the layout of one.

Three traps, all of them found by measurement.

Every hand copy defaulted Builder::new to wire version 2. The emitted runtime keeps new at version 1 and names new_v2 for the other, so a swap that looks purely mechanical moves byte 4 of every message. The P-chain's warp differential caught it against bytes Go printed. Every P-chain writer says new_v2.

chains/rust/platformvm/src/state_zap.rs's Staker reserves sixteen bytes past its last field. The record has always reserved them; the schema names them Reserved for the same reason Out and In name their Pad — a schema that stopped at the last real field would write a shorter record than every record already on disk.

TransferOutput and Utxo are declared in BOTH xchain.zap and pchain.zap, because the X-chain makes them and the P-chain spends them. That is two statements of one format, and it is not what anyone wants. Lifting them into a shared fx.zap needs an import across schemas that zapgen does not have — xchain.zap's own envelope holds list<ptr<TransferableOut>>, so the reference cannot be moved away from it. Until the import exists, chains/rust/zap/tests/one_format.rs is the join: it reads both schema files and fails the day the two declarations drift.

What proves the bytes did not move: tests/corpus_bytes.rs, in each of the five chains. Each reads every vector of its own chain out of the shared corpus — which the Go chain generated — re-encodes it through the BUILDER, and compares to Go's hex. Tx::parse keeps the bytes it was handed, so asking a parsed value for its bytes would prove nothing; these go back through the writer. 68 P transactions and 4 P blocks, 19 X transactions and 6 X blocks, 12 Q blocks, 22 Z blocks, 43 F transactions — every vector the corpus expects to parse. The rest are the deliberately malformed ones and are still refused.

A ZAP message carries its own length, so some vectors hold bytes past the end of the message and still parse (…_TRAIL_1, …_TRAIL_4, X_EDGE_CORRUPT_TAIL). What a writer writes is the message: where a vector IS the message the two are the same bytes, and where it is not, the message is its prefix. A rewrite longer than its vector, or one disagreeing anywhere inside the message, fails.

The chain differential (make chains)

make conformance is the consensus layer. make chains is the chain layer, and it did not exist until now: one corpus of P-chain and X-chain bytes, generated by the Go chains, handed to the Go, Rust and C++ implementations of both, with every answer compared against every other. See conformance/README.md for the format, the verdict vocabulary and why an empty ledger is the right state to judge against.

Why it had to exist: every port checked itself against Go in isolation, each one choosing its own cases. That is how a P-chain fork survived — chains/cpp/platformvm executes the sovereign-L1 plane and chains/rust/platformvm refuses it by name, and nothing anywhere put those two answers side by side.

It failed when it was written, which was the point: thirteen vectors disagreed, including both bugs it was built to catch (the L1-plane fork; the missing block reject on the C++ side, whose root was that lux::node::Block — the C++ host seam itself — declared verify and accept and no reject) and three that were not on anyone's list, the sharpest being that a transaction addressed to another network passed the Rust P-chain's syntactic check.

All thirteen are closed. The three chains and the corpus now agree on every compared field of all 208 vectors, and no field goes uncompared. make chains exits zero, and it is the only thing that says so — the ports' own suites pass either way, which is how a fork lived here for as long as it did.

conformance/gen is the one place in this repo that depends on luxfi/node. That is what a reference is. It is a separate Go module so it cannot reach this module's own dependency graph, which make luxd greps and fails on.

Two golden-generator packages, chains/rust/platformvm/tests/vectors and chains/cpp/xvm/test/golden, are references of the same kind but live in the ROOT module, so go list -deps ./... does reach luxfi/node through them. Nothing that ships imports either one and make luxd's grep is scoped to the runtime it builds, so no artifact carries the dependency — but the module is not clean by inspection, and the honest fix is to give them their own module the way conformance/gen has one.

The chain benchmark (make bench)

Three implementations of the same node and nothing comparing their speed: Go carries 48 benchmark functions in luxfi/node, the Rust chains have no benches/ at all, and the C++ chains have none of their own. Any claim that one is faster than another was unsupported.

The differential already hands all three the same 725 vectors and makes each one parse and verify them, which is a fair identical workload. make bench times it. Every evaluator takes an optional repeat count after the corpus path, walks its chain's vectors that many times, and prints its own elapsed time to stderr as B <impl> <vectors> <repeats> <seconds>. conformance/bench runs them and prints the table. Without a count nothing changes and make chains is untouched.

It did not run. Six of the twelve evaluators the differential runs had no clock at all — rust dexvm and quantumvm, cpp quantumvm, zkvm, dexvm and fhevm — and the target named two of the six, so make bench exited 1 on the first of them and had never produced a number for any chain. All twelve take the count now; in C++ the loop lives once in the header the four already share. The D and F evaluators are in lux-cpp/chains and lux-rs/chains, so the change to those two is there and the header they build against is here.

One row per chain per language. A port is one chain, so its time is that chain's. The Go reference is all six in one program and could only report a whole-corpus time, which is not a number to set beside a sixth of one; it now takes -chain P|X|Q|Z|D|F before the corpus path and names itself the way the ports name themselves. Eighteen rows, plus a total per language over the same 725 vectors.

Three things about it are load-bearing:

Each evaluator times itself. Timing the child from outside would measure five process starts, five corpus reads and five output writes as if they were chain work. Measured: Go spends 43.6 ms on all of that, four times the C++ X-chain evaluator's entire process and more than the C++ P-chain's, so a process-level benchmark would have reported it as Go being slow at the chain. The clock starts after the corpus is read and stops before the first verdict is printed.

The verdicts are kept, not dropped. Each round writes into a vector that is printed afterwards, in all three languages, so no round can be optimised away — and the printed rows are byte-identical whether the count is 1 or 200.

A single timing is not a measurement. The evaluators are run five times, the runs interleaved, and the table carries the fastest, the median, the slowest and the spread. µs/vector comes from the fastest run: everything else on the machine can add time to a run and nothing can subtract it, so the fastest is the least contaminated and the spread says how contaminated the rest were.

What it says, and the honest caveats, are in conformance/README.md under Timing it. The sharpest is that the biggest number in the table is not about a language: cpp/quantumvm costs 1821 µs a vector against go/quantumvm's 1.02, and a Q block that dies on its FIRST BYTE costs it 1.85 ms — within 1% of one that verifies to the end. The C++ evaluator stands a fresh chain up per vector and the Go one stands one up per process, both deliberately: 81 chains a round against one a process is the entire ratio. The next sharpest is that a field a port DECLINES is work it did not do, so a row make chains names under DECLINED is fast for that reason. Take Q out and the three totals over the remaining 644 vectors are Go 3.16 s, Rust 2.75 s, C++ 4.28 s — the same shape P and F give on their own.

Running the three

make luxd RUNTIME={go,rust,cpp} builds; nothing here starts a cluster. Three things have to hold before a height moves, and only the first is obvious.

Up is not producing. All three answer eth_chainId and eth_blockNumber the moment their listener opens, from a chain that has decided nothing. A read-only RPC benchmark cannot tell the difference, because every question it asks is answerable at height 0. Ask for the height twice, a minute apart, and compare — a single sample cannot tell a live chain from a frozen one.

The Rust and Go nodes build a block only when there is one to build. Rust says so in its own loop: Error::Empty is not an error there but a chain with no transactions is a chain at rest. Go reaches the same place from the other end — --automine is documented as anvil-like, auto-producing on transactions. So both idle at height 0 forever with nothing wrong, and neither logs anything while doing it. The C++ node does the opposite and produces empty blocks, which is why it is the only one of the three that ever looks alive on its own. Neither policy is a bug; the difference between them is worth knowing before reading a height.

The shipped genesis funds nobody who holds a key. configs/localnet allocates the C-chain to one treasury address, which is a Safe. There is no key here for it, so out of the box neither Go nor Rust can execute a single transaction, and by the paragraph above that means neither can leave height 0. Feeding work to them takes a genesis that funds a key the caller holds — the first Anvil account is the one the C++ node's built-in genesis already funds and the one conformance/dex signs with, so funding it on the other two makes one key drive all three. Rust takes such a document with --genesis, and its parser reads a Go network genesis by descending into cChainGenesis, so the single file Go is given with --genesis-file serves both.

The C++ node cannot catch up, and that halts the cluster. Leadership is height % n == index and a follower waits for that leader's block, deliberately never building a sibling. But certification needs live votes, and a proposer certifies on a quorum without waiting for the last follower — so a node that is slow once is left a height behind with no way back, because its peers have moved on and will not vote at an old height again. It stays behind until the rotation reaches its turn to lead, and then the whole cluster stops: observed at height 533, with node 1 stuck on 532 and nodes 0, 2 and 3 waiting on node 1 to lead. The logs say timeout — retrying forever and the RPC keeps answering, so the cluster reads as up.

Closing it needs a block a node can accept on a certificate it verifies rather than on votes it collects. Node2Host::verifyCert already exists, so the verification half is there; what is missing is a frame to carry a certificate (the link defines only kTxMsgType and kBlockMsgType) and a path into the VM that accepts a block without voting on it. That is a real addition, not a patch, and it is not attempted here.

What a Rust validator does after it restarts

A validator that restarts rejoins the cluster it can see.

The signing journal is durable on purpose: it is written before the key is used, so a node that comes back cannot contradict the run before it. A refusal to sign means this node does not vote here — it re-sends the statement it already stands by, which the journal hands back for exactly that, then stays silent and keeps collecting. A certificate is checked by a rule that does not ask whether the checker voted. Quorum is three of four, so the others certify without it and it accepts their block. anchor relies on that, and so does the equivocation case.

The mesh re-forms after boot. connect is the opening rendezvous and it returns; nothing accepted an inbound link afterwards. A validator that restarted dialed into a backlog no one was reading — peers 0 of 3 while the three peers it could see carried on, each still holding a socket that had already died. It is worth seeing how that reads from outside: the node serves RPC the whole time. Up, and not in the cluster. Now the rendezvous runs once a round for as long as the node runs, under the same rule about who dials, so a pair still ends with one link. A whole mesh pays nothing, because every peer is already held and no dial is attempted.

It still cannot catch up, and that one is not fixed. The journal outlives the chain: votes are on disk, the C-chain is not — Evm::new(genesis) builds the world in memory. So a restarted node is at height 0 holding a journal that names heights 1 to N. Blocks do reach it — it is in the mesh — but a node can only accept one whose parent it already holds, and nothing asks a peer for the ones in between. Rejoined and caught up are different things, and the log says which one this is:

peers         3 of 3
following a peer's block: the chain refused the block: no state for the parent

repeating while the cluster moves on without it. Closing it means either keeping the chain across a restart or fetching the history from a peer, and both are additions. Note the two fixes above are still what makes that work reachable: a node cannot rejoin on a certificate that arrives over a link it does not have.

A caution that outlives all three. Every one of these looked healthy from outside — the RPC answers eth_chainId and eth_blockNumber from a chain that has decided nothing. Ask twice, a minute apart, and compare.