README
¶
conformance
Two harnesses live here, and they check different things.
make conformance— the consensus-layer corpus: what a validator signs, what a certificate looks like on the wire, what the finality predicate decides. It already existed, and all three implementations already pass it.make chains— the chain-layer differential: one corpus, covering seven chains, handed to every implementation of each of them, with every answer compared against every other answer.
Until make chains existed, every port checked itself against Go in isolation,
each one deciding for itself which cases to check. 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 no test anywhere put those two
answers next to each other.
The seven chains
| vectors | Go | Rust | C++ | |
|---|---|---|---|---|
| P platformvm | 159 | yes | yes | yes |
| X xvm | 49 | yes | yes | yes |
| Q quantumvm | 81 | yes | yes | yes |
| Z zkvm | 137 | yes | yes | yes |
| D dexvm | 35 | yes | yes | yes |
| F fhevm | 264 | yes | yes | yes |
| O oraclevm | 217 | yes | yes | yes |
Most of those counts are damage. Every vector this reference reads back as a block or a transaction is also cut to a quarter, cut to a half, cut by one byte, extended by one and extended by four — which is where a parser reads past a buffer. Which vectors get that treatment is not a list anyone maintains: a vector is damaged if the reference parses it, because half of a truncation is a truncation and a vector already damaged on purpose is one.
F's count includes an F_JSON_* group added after the C++ port and the
reference were found to disagree about what their JSON decoders accept. Each is
a correct transaction with one thing done to its payload — a stray closing
brace, a member name folded by unicode.SimpleFold rather than by ASCII case,
two keys naming one field, a discarded array element of the wrong type, a base64
word broken across a line, a literal null where a struct belongs — and every
one of them is a transaction the Go chain ADMITS, so an implementation that
refuses it is named rather than quietly stricter. See chains/cpp/fhevm/LLM.md.
P and X come from luxfi/node; Q, Z, D and F from luxfi/chains. All are
PUBLISHED versions and there is no replace directive, so the corpus regenerates
on any machine rather than on one.
O, and where the O-chain actually is
chains/oraclevm is 213 lines and none of them are the chain: it re-exports
github.com/luxfi/oracle/vm, which is 1628. Reading the shim's size as the
chain's size understates it by an order of magnitude. The same is true of
chains/relayvm — 161 lines over luxfi/relay/vm's 2077. Of the eight chains
the node runs that this differential did not cover, the two that looked
smallest by an order of magnitude were the two that were not there at all.
O is JSON, and its id is a hash of the RE-MARSHAL
The O-chain has no codec frame. ParseBlock is json.Unmarshal and Bytes is
json.Marshal, so the wire is whatever encoding/json writes for the chain's
structs: CB58 for an id, base64 for a byte slice, a list of numbers for a fixed
array, RFC 3339 for a time, and field order taken from the struct declaration.
None of that is written down anywhere but in the standard library's behaviour,
and all of it is consensus here — because computeID marshals the parsed block
AGAIN and hashes the result rather than hashing the bytes it was handed.
Two things follow, and the corpus pins both:
- a block does not round-trip its own id.
BuildBlockhashes the block while itsidmember is still empty and then writes that id INTO the struct, soBytes()is not the preimage ofID().O_BLOCK_ID_SETis the block the chain wrote, and it takes a different id when read back. - a member the reader accepts and
omitemptythen drops does not change the id."observations":[]parses to an empty slice and re-marshals to nothing at all, soO_BLOCK_EMPTY_ARRAYandO_BLOCK_EMPTYare two different buffers under ONE id.
The O-chain's genesis id depends on the machine's timezone
Initialize builds the genesis block with time.Unix(genesis.Timestamp, 0) —
LOCAL time — and MarshalJSON writes the offset. Measured: one genesis file
gives block id 6cf00752… with TZ unset on a -08:00 box and f4970a27…
under TZ=Asia/Kolkata. Two validators in two timezones derive different
genesis ids from identical genesis bytes and are on different chains from
block zero.
No vector carries that id — a corpus answer that changes with the machine is
not an answer — so O_GENESIS pins the genesis by the one thing that IS a
function of its bytes: the SHA-256 of the canonical re-marshal of the parsed
struct. The bug is in the chain and not in the corpus, and it is written down
here because a differential that quietly worked around it would have hidden it.
What O's five ops ask
block is the wire and the id above. genesis is the feed configuration the
observation vectors are judged under, so "we applied the same configuration" is
a compared field rather than an assumption. requestid is the derivation that
names a request — sha256("LUX:OracleRequest:v1" ‖ service ‖ session ‖ be32(step) ‖ be32(retry) ‖ tx) — where nothing separates the three ids, so
their ORDER is the whole of what keeps two requests apart. commit carries a
request and the records executed against it and answers with the Merkle root
the chain commits, which is the one number a light client checks an oracle
answer against. observation is offered to the seeded chain and separates the
feed lookup, the staleness rule and the operator check into three refusals.
Block.Verify is return nil with no condition in it, so on O's block vectors
syntactic and exec are OK for everything that parses. That is the chain and
not a gap in the corpus: what O decides lives in the other four ops, and a port
that invented a block rule would fail the differential for being right.
Three O vectors are PAIRS, and neither half of a pair means anything alone.
O_COMMIT_THREE and O_COMMIT_THREE_PADDED are different record sets that
commit to the SAME root, because the tree pairs a lone last leaf with itself —
reproduced here rather than corrected, since a port that fixed the malleability
would derive a different root for every odd record count and fork the chain in
the act of improving it. O_BLOCK_DUPLICATE_HEIGHT_FIRST and _LAST are one
intruding member placed either side of the original, and only the pair says
which of two members naming one field wins. O_COMMIT_ONE sits against six
O_COMMIT_OTHER_* copies, five of which must move the root and one — the
signature, which the leaf does not hash — must not.
The four chains were added because they had no vector at all, which is the same shape the P-chain fork hid in for weeks: a chain nothing is pointed at agrees with itself. Every fork this program has found, the differential found.
All three columns answer all seven chains, and every compared field of all
942 vectors agrees. A column that answered only some of them would put its
unanswered vectors under NOT ANSWERED and fail the run: silence is not
agreement, and a target that went green while a column said nothing about four
chains would be reporting the agreement of whoever was left. A chain joins as
one more -eval "rust=…" line, which is the whole of what the runner learns.
What a port finds on the way in is worth reading: the F-chain's F_JSON_*
group is where a decoder stricter than the reference's shows up, and those are
described under What is compared below.
D is not a block chain here. Go's dexvm is a REGISTRY: it decides what an
asset IS, what a market IS, which kinds may be registered, and whether native
value may activate. Those are consensus decisions with no wire of their own —
two implementations deriving different bytes for one asset have forked the
value plane without ever disagreeing about a transaction — so a D vector's wire
column carries the ARGUMENTS to a derivation rather than a serialization.
The shape
conformance/gen the Go generator and the Go evaluator (its own module)
conformance/corpus vectors.tsv — the bytes; expected.tsv — Go's answers
conformance/runner compares answers; understands no chain
chains/rust/platformvm/src/bin/conformance.rs the Rust P-chain's answers
chains/rust/xvm/src/bin/conformance.rs the Rust X-chain's answers
chains/cpp/platformvm/test/conformance.cpp the C++ P-chain's answers
chains/cpp/xvm/test/conformance.cpp the C++ X-chain's answers
chains/cpp/quantumvm/test/conformance.cpp the C++ Q-chain's answers
chains/cpp/zkvm/test/conformance.cpp the C++ Z-chain's answers
chains/cpp/dexvm/test/conformance.cpp the C++ D-chain's answers
chains/cpp/fhevm/test/conformance.cpp the C++ F-chain's answers
chains/rust/oraclevm/src/bin/conformance.rs the Rust O-chain's answers
chains/cpp/oraclevm/test/conformance.cpp the C++ O-chain's answers
chains/cpp/conformance/include/…/corpus.hpp the format, the verdict words
and the error-word table, once
One corpus. Every vector's bytes come out of a Go constructor —
txs.New*Tx, block.New*Block — and are signed the way the Go node signs.
Nothing writes a byte by hand except the deliberately damaged vectors, which
are damaged copies of well-formed ones and say so. A corpus written by a third
party would be a fourth opinion about the wire, and there would be nothing to
say which of the four was the chain.
One line format, three readers. Both files are tab-separated, for one reason: the C++ evaluator must read the same file the Go and Rust ones read, without a JSON library entering a chain's dependency graph.
V <id> <chain> <op> <wire-hex>
R <id> <parse> <kind> <hash> <syntactic> <exec> <note>
op is tx, block, seam, identity, genesis, or — on D — the name of
the derivation being asked for. The first five fields after the id are
compared; the note is not, and it carries each implementation's own words so a
disagreement can be read without opening three debuggers.
What is NOT on the wire is corpus contract
Three of the seven chains hash something that never travels into every id they
derive. The Z-chain's block id opens with sha256(ChainID ‖ NetworkID); the
F-chain does the same and binds its signing preimage to the chain id besides;
the Q-chain carries the pair in the block and refuses a block whose pair is not
the one the node serves. An evaluator that picks its own numbers therefore
derives a different id for every well-formed vector on that chain.
That is not hypothetical. The first Z run disagreed on the id of all twenty-five vectors and on none of the malformed ones — which is what a hash fork looks like — because one evaluator had been built for chain 40 and the other for chain 4.
So the identity is stated once, in conformance/gen/identity.go, and asked
back as a vector: Q_CHAIN_IDENTITY, Z_CHAIN_IDENTITY, F_CHAIN_IDENTITY.
Each evaluator prints the numbers IT was built with, never the ones the corpus
asked about, so a mismatch is one row naming both and the rows underneath can
be read for what they are.
F_GENESIS goes further and carries the genesis bytes themselves. An F
transaction is judged against a funded payer, a committee, a threshold and a
network key, none of which is in its bytes; both evaluators stand their chain
up on those exact bytes, and the vector's answer is the id the chain's own
genesis block takes — so "we applied the same configuration" is a compared
field rather than an assumption under all the others.
The runner understands nothing. It runs programs and compares strings. A runner that understood the rules would be a fourth implementation, and the day it was wrong it would hide a disagreement instead of reporting one.
What is compared
| field | what it is |
|---|---|
parse |
did these bytes read back as a transaction or a block |
kind |
which of the nineteen P-chain kinds, or five X-chain kinds, it is |
hash |
the transaction id or block id — sha256 of the very bytes given |
syntactic |
the well-formedness verdict, as a class |
exec |
the execution verdict, as a class |
The verdict vocabulary
OK · MALFORMED · SYNTACTIC · OVERFLOW · LEDGER · AUTH · WARP · UNSUPPORTED
Each implementation maps its own error type into exactly one of these before
printing, because "failed to fetch UTXO", MissingUtxo and kUtxoNotFound are
three spellings of one answer, and comparing the spellings would report a
disagreement that is not one. The mapping is the same word table in the same
order in all three evaluators, and the raw words survive in the note beside the
class — a mapping that flattened a real difference is visible to anyone reading
the row.
LEDGER is deliberately coarse. Execution is judged against a chain that holds
nothing: no UTXO, no validator, no network. That is a state all three
implementations stand up identically, and using a funded one would mean
building three state builders and then comparing those instead of comparing
three chains. On an empty chain a missing UTXO and a missing validator are both
just "the chain does not hold this", and splitting them would report which
lookup each implementation happened to reach first as if it were a difference
of opinion.
What an empty chain still separates — and the whole reason it is enough — is
UNSUPPORTED: a refusal that never looked at the chain at all, because the
implementation does not run that kind of transaction. A chain that refuses a
kind by name answers UNSUPPORTED where a chain that tries to execute it
answers LEDGER. That difference is the shape of a fork.
syntactic where verify is one pass
syntactic and exec name two passes, and most of the corpus has two to read.
P and X run SyntacticVerify and then execute. F runs SyntacticVerify — well
formed and priceable, without state — and then SubmitTx. D's vectors are not
blocks at all but pure functions, and its two fields are whether the inputs were
admissible and what the function returned. On those there is nothing here to
decide.
Q and Z have ONE. Verify runs the block's own rules, then the transactions,
then the parent, and returns the first refusal — there is no second entry point
to call, so an evaluator that wants two answers has to find the seam itself.
Three of them did, and each found it somewhere else. That is not a fork in any
chain; it is a question the corpus was asking without ever having defined it.
The definition, for any chain whose verify is one pass:
syntacticis the verdict that pass reached before it read the chain. The boundary is that implementation's first read of the store. A refusal ahead of it answers BOTH fields — the block never got far enough for the two to differ. A refusal past it answersexec, andsyntacticisOK.
The boundary is a place in the code, not a category of rule. Q reads its parent
and only then checks the clock; Z checks the clock first. So the same rule is
exec on one chain and syntactic on the other, and that is the answer, not a
wrinkle to iron out — it is the difference between two verifies, and this field
is what says so. An implementation that moved a rule across its own first read
answers differently and fails the run, which is the whole point of asking.
Each implementation answers for itself, out of its own code. A port that
owns its source names the boundary there and the evaluator calls it:
Block::syntactic_verify in the C++ Z-chain, on_chain then well_formed in
the Rust Q-chain — the same two the Rust VM's own verify runs before it looks
for a parent. Where the reference is a published module nobody here can add a
method to, the evaluator names the refusals instead: zBlockAlone and
qBlockAlone quote luxfi/chains' own sentinels, because that package keeps
every one of them unexported and there is no symbol to name. Nothing is copied
between languages. Each list is a walk of one Verify, in the order that
Verify runs.
Not by counting reads. A store that counted its own reads would answer "did this run touch the chain" exactly, in every language, with no list of words anywhere. It answers a different question. A block whose transactions carry nullifiers reads the spent set long before it reaches the state root, and a block carrying none never reads it at all — so one rule refusing in one way would land in a different class depending on what the block happened to hold. A class that moves with the payload is not a class.
SKIPPED is never a pass
A field an implementation declines to answer prints SKIPPED. The runner
excludes it from comparison, counts it under DECLINED, and if it leaves the
field with fewer than two running implementations, lists the field under NOT
COMPARED and fails the run. A field nothing compared is not agreement, and it
does not get to exit zero.
The reference's own recorded answers cannot make up the shortfall. expected.tsv
is what gen eval printed, so go and corpus are one function's output twice
over — byte for byte, sha256sum of both is the same string. Counting them as
two answers is what let every port decline every field of every vector and still
print AGREED. They are marked as a recording now: they may disagree with
anyone, and they may not stand in for a second implementation.
No column declines a field today. Every one of the 942 vectors is answered by all three, and the rule stands for the day one of them cannot: a decline is reported per implementation and per field, never counted as agreement, and a field left with a single running answer fails the run rather than passing on one voice.
What encoding/json accepts
An F operation payload is JSON, so what the chain admits depends on what its
decoder admits, and Go's decoder is not the strict reading anyone writes by
default. The F_JSON_* group asks the question directly, and it caught four
rules the Rust F-chain did not have. Each is one member of one payload, and each
would have refused a transaction the reference admits — which on a live chain is
a node that refuses a block its peers accepted.
null where a struct belongs is the ZERO struct, not a refusal. What then
refuses the transaction is whatever rule the zero payload breaks, and that is a
different rule per operation: F_JSON_NULL_REVOKE reaches the ledger and is
refused for a permit nobody holds, while the register and advance payloads are
refused for their own shapes. A decoder that refused null outright names the
wrong rule on all three and the wrong verdict on one.
Trailing content is two rules and not one. Go reads a payload through a
Decoder and asks dec.More(), and More answers "is there another ELEMENT" —
it reports false for ] and }. So a stray closing bracket after a complete
value leaves the payload standing, while a comma or a second document does not.
Member names fold by unicode.SimpleFold, which is not ASCII case folding.
U+017F folds onto S and U+212A onto K, so ſize names Size and publicKey
spelled with a Kelvin sign names PublicKey. And when two members name one field
both are resolved and the LATER one is written, whichever way each of them
matched — so a decoder that preferred the exact match reads a different value
out of the same bytes, and one that sorted its members cannot express the rule
at all.
A base64 word broken across a line is the same word: encoding/base64 ignores
CR and LF wherever they fall. F_JSON_BASE64_NEWLINE carries a complete epoch
proposal with its network key wrapped, so the answer turns on whether the key
was read — a reader that refused the newline reaches a proposal with no key and
refuses the committee, which is a rule about the committee standing in for a
rule about base64.
Seam vectors
Two vectors carry no bytes. They ask each implementation whether its
block-decision seam has a reject, and each answers from its own compiler —
a method expression in Go and Rust, a requires in C++. Nothing about that
answer is typed by hand, and it fixes itself the day the method appears.
A chain that cannot reject a block cannot hand back what that block was carrying, so the two sides disagree about what is still pending. That is not visible in any transaction's bytes, so no wire vector could ever catch it.
Derived wire damage
Every vector carrying a complete encoding — the twenty P-chain transaction
kinds, the four P-chain blocks, the five X-chain transactions and the two
X-chain blocks — is carried again cut short at three points and run long at
two, so length handling is checked once per kind rather than once for the
P-chain's BaseTx. The three cuts are a quarter in, half way in, and one byte
short of complete; the two extensions leave one and four unread bytes after
the structure. They are derived from the vector they damage and named after
it: P_BASE_TRUNC_HALF, P_BASE_TRAIL_1.
Truncation asks whether a decoder notices it has run out of buffer or reads past the end of one. Trailing bytes ask the opposite and sharper question: whether a decoder that has finished reading a structure cares that the buffer has not ended. The two behaviours are indistinguishable on well-formed bytes, which is why every kind needs both.
What the three implementations agree on today, and what they are now held to:
- Every truncation is refused, on both chains and all thirty-one kinds.
- The P-chain's block decoder refuses trailing bytes and names them —
trailing bytes after zap message. - The X-chain's block decoder and every transaction decoder on both chains
accept them.
RewardValidatoris the one transaction that does not, and it is not refusing the remainder: it carries no credentials, and the appended bytes are read as a credential list that then runs out of buffer. - Where trailing bytes are accepted, all three compute the id over the buffer
they were given.
sha256of the padded bytes is the id, so one transaction has as many ids as there are ways to pad it.P_BASEandP_BASE_TRAIL_4are the same transaction under two ids; the second is the vector that was hand-written asP_EDGE_TRAILING_BYTES, and the derivation reproduces its bytes exactly.
Running it
make chains build all fourteen evaluators, run the differential
make chains-corpus regenerate the corpus from the Go reference
make chains passes today. 942 vectors, three running implementations —
go, rust and cpp — plus the committed corpus as a recording of the first.
Agreement on every field, every field answered by at least two of the three,
and nothing under NOT ANSWERED.
A handful of fields are still DECLINED, and the runner prints which: exec on
the X-chain in C++ and exec on the Q-chain in Rust. Those two sets do not
overlap, which is the only reason the run is green rather than short a voice —
and the count is deliberately not written down here, because it moves as those
two ports close and a number in prose would go stale silently. Read it off the
run.
The O column declines NOTHING. A port that agreed by declining is the failure mode this differential already had once, so both O evaluators were held to answering every field of every O vector before either was wired in.
The corpus is committed, so a reference that changed its mind shows up as a
diff. expected.tsv — the Go chains' answers at generation time — also joins
the run under the name corpus, so drift in Go itself is a disagreement rather
than a silent new normal. It is a recording, not a second opinion, so it does
not count toward the two answers a field needs before it counts as compared.
Every evaluator is built before the run and a build that fails stops the target. A differential that quietly lost one of its voices would report agreement among whoever was left.
Timing it
make chains asks whether the three implementations agree. make bench asks
how long each of them takes to answer, over exactly the corpus they agreed on.
make bench build the evaluators, time each one, print the table
Every evaluator takes an optional repeat count after the corpus path:
conformance/gen/gen eval conformance/corpus/vectors.tsv 200
chains/rust/platformvm/target/release/conformance conformance/corpus/vectors.tsv 200
chains/cpp/xvm/build/xvm_conformance conformance/corpus/vectors.tsv 200
With a count it walks the whole corpus that many times and prints one extra line to stderr, where the differential's runner does not read:
B <impl> <vectors> <repeats> <seconds>
B rust/platformvm 159 20 0.286851
Without a count nothing changes: one pass, no timing line, the same verdicts on
stdout. And the verdicts are byte-identical at any count — every round computes
them, the last round's are printed — so make chains reads the stream it always
read.
One chain at a time
A port is one chain: chains/rust/platformvm answers the P rows and walks past
the rest, so what it reports is a P-chain time. The Go reference is all six
chains in one program, and a whole corpus is not a time to set beside a sixth
of one — so it is told which chain to answer:
conformance/gen/gen eval -chain Q conformance/corpus/vectors.tsv 200
It selects before the clock starts and calls itself go/quantumvm on the B
line, in the words the ports already use for themselves, so the three rows for
one chain read as three rows about one thing. make bench asks it once per
chain: eighteen rows, and a total per language over the same 725 vectors.
What is measured
The differential's own work, and only that: for each vector, decode the hex, parse the wire, verify it, and — as far as the implementation goes — execute it against the empty chain. Each evaluator runs its own monotonic clock around its own loop, starting after the corpus has been read and split into fields, and stopping before the first verdict is printed.
What is not measured
Process start: the dynamic loader, the Go runtime coming up, the 27 MB of
linked reference the Go evaluator carries. Reading and splitting vectors.tsv.
Formatting and writing the result lines. Process exit. The build.
That exclusion is not a detail. Timing each evaluator's whole process from the outside at one repeat, fastest of twenty, against what its own clock reported:
| evaluator | whole process | the work | everything else |
|---|---|---|---|
| go | 63.9 ms | 20.3 ms | 43.6 ms |
| rust/platformvm | 28.1 ms | 17.7 ms | 10.3 ms |
| rust/xvm | 7.8 ms | 0.3 ms | 7.5 ms |
| cpp/platformvm | 39.2 ms | 25.4 ms | 13.7 ms |
| cpp/xvm | 5.9 ms | 0.2 ms | 5.7 ms |
Go pays 43.6 ms for things that are not chain work — four times what the C++ X-chain's entire process costs, and more than the C++ P-chain's entire process. A benchmark that timed the processes would have reported that as Go being slow at the P-chain.
Where the comparison is fair
One corpus, byte for byte, the same vectors in the same order, and one row per
chain per language over exactly that chain's vectors. make chains is what
says whether it is the same work: a field all three answer is a field nobody
was timed on while quietly answering an easier question. Each evaluator is run
five times and the runs are interleaved — every evaluator once, then every
evaluator again — so a machine that slows down halfway through slows all of
them down rather than one.
Where it is not
A declined field is work that did not happen. make chains prints what
each port skipped, and a row that skips a pass is fast for that reason and not
for a reason about the language. Read DECLINED before reading the table: a row
named there is a parse-and-syntax number over part of its corpus, and the
harness cannot say how much of the gap is the pass that did not run. A chain's
three rows are comparable when nothing under DECLINED names it, which is the
same fact the SKIPPED field reports to the differential.
The evaluators do not stand their chains up the same way, and on Q that is
the whole number. The C++ Q-chain builds a fresh QuantumVM for every
vector, on purpose: "so no vector can be answered differently because of one
that ran before it". The Go one builds it once per process behind a
sync.Once, on purpose too: "starting it opens a committee, which is work".
Handed a corpus of ONE Q block and timed at 1, 10, 100 and 1000 repeats, Go
fits a line of 0.30 ms fixed plus 4.6 µs a vector, and that line predicts all
four measurements to within 2%: one chain, then cheap blocks. C++ has no fixed
cost to fit — from ten repeats to a thousand it is 1.86 ms EVERY vector. (Its
first vector in a process costs 0.36 ms and every one after it the full 1.86;
that is not a cached chain, since each vector gets its own, and it is not
explained here.) A Q block that dies on its FIRST BYTE costs C++ 1.85 ms,
within 1% of one that verifies to the end, so 99% of that row is standing the
chain up and 1% is the block.
Two things are then true and only one of them is about C++. It builds 81 chains a round where Go builds one a process, which is the whole ratio; and its build is 1.86 ms against Go's 0.30, which says the C++ Q-chain is six times more expensive to stand up. The second is a real number about the chain. The first is a choice about the evaluator, and it is what the row is measuring.
Nothing here says which policy is right. Fresh-per-vector is the stronger
isolation and Go's sync.Once is the faster measurement; what is not tenable is
reading the two numbers as a fact about C++.
Go builds its chain once, inside the clock. execp.go stands up a real
platformvm state — an in-memory database, a genesis, a metrics registry, a
validator manager — behind a sync.Once, on the first vector that executes, and
that lands inside the first round. Fitting a line through the fastest of twelve
runs at one repeat and at two hundred puts the build at 2.4 ms against a pass of
16.9 ms: 0.07% of a 200-repeat run, and most of a single-repeat one. Rust and
C++ build a trivial in-memory state per vector instead, which is inside every
round, and the same fit gives them a fixed cost of 2 ms and -1 ms — which is to
say none, measured to the noise floor. The setups are not the same shape, and
the repeat count is what stops that from being the thing measured.
The optimisation settings are alike, not identical. Rust is cargo's default
release profile: opt-level = 3, no LTO, sixteen codegen units, no debug
assertions, no overflow checks. C++ is CMake's Release: -O3 -DNDEBUG, no LTO
and no interprocedural optimisation. Go has no such dial to set. Neither the
Rust nor the C++ binary was built with LTO or profile-guided optimisation, and
turning either on would move two of the three numbers and not the third.
The allocators are not the same. Every verdict is seven heap-allocated strings and this workload allocates heavily. Rust and C++ both reach the system allocator; Go uses its own and collects behind it. That difference is inside the measurement and cannot be taken out of it without changing what is computed.
They are not three ports of equal maturity. Go is the reference the fleet
runs, executing against the node's own state.State behind a diff, with metrics
and a validator manager attached. Rust and C++ execute against state types of
their own. That is not a like-for-like runtime even where the verdict is
identical.
Nothing memoises a verdict, and that was checked rather than assumed. Every
round decodes the hex again, parses again, verifies again and executes again;
what survives a round is Go's chain state and nothing else. Two things say so.
The printed rows are identical whether the count is 1 or 200, and every round
writes into a vector that is printed afterwards, so no round can be dropped by
an optimiser. And the cost of ONE pass does not fall toward zero as the
count rises. Over the Z corpus, fastest of six at 25, 50 and 100 repeats,
cpp/zkvm spends 0.197, 0.201 and 0.199 ms a pass and rust/zkvm 0.227, 0.229
and 0.237 — flat to 2% and 4% across a fourfold change. go/zkvm does fall,
0.130 to 0.108, and it fits a line: 0.101 ms a pass on a fixed 0.74 ms, which
is a chain stood up once and not a verdict remembered. A cached verdict would
not shave a fifth off the later passes; it would make them nearly free.
One machine, one architecture. The table below is x86-64, one AMD part, Linux, and says nothing about arm64. An earlier sitting on an Apple M1 Max, on a smaller corpus, also put C++ about 30% behind on the P-chain and Go and Rust level with each other; that ordering is the only thing carried across from it, and the absolute numbers are not comparable at all. Machines with cores of two kinds are a further trap — a run scheduled onto a slow core is several times slower than one that is not, which is one of the things the spread is there to show.
Reading the table
evaluator vectors repeats runs fastest s median s slowest s spread µs/vector
µs/vector is per vector per repeat, taken from the fastest run. Everything
else on the machine can only ever add time to a run, never subtract it, so the
fastest one is the least contaminated. The spread beside it — slowest over
fastest — says how contaminated the others were. A large spread means the
machine was busy, not that an implementation is erratic, and the honest response
is to say so and run it again somewhere quiet rather than to quote a mean over
the noise.
That is not a hypothetical. A run of this on an Apple M1 Max carrying a load average around 35 came back with spreads between 341% and 1001% — a slowest run seventeen times the fastest. Nothing in that table was a measurement of anything. Repeated near a load average of 13, the same measurement came back with spreads between 6% and 19%.
And the minimum is what survives a busy machine. The table below was taken at a
load average of 56 and repeated immediately at 46: one spread reached 12304%,
and every µs/vector in the two tables agrees to within 6%, most within 2%,
cpp/quantumvm to within 0.06%. The noise went into the spread, which is what
the spread is for, and left the minimum where it was.
What it said
Thirty-two cores of an AMD Ryzen AI Max+ 395, Linux, and a machine shared with other work: the one-minute load average was 56 when the run started and 54 when it ended. Those numbers belong to that machine on that day; the ratios are what carries, and the spreads say how much even they should be leaned on.
evaluator vectors repeats runs fastest s median s slowest s spread µs/vector
go/platformvm 159 200 5 2.059 2.459 3.026 47% 64.74
go/xvm 49 200 5 0.022 0.040 2.754 12304% 2.27
go/quantumvm 81 200 5 0.016 0.018 0.349 2017% 1.02
go/zkvm 137 200 5 0.022 0.027 0.035 58% 0.80
go/dexvm 35 200 5 0.004 0.004 0.005 18% 0.59
go/fhevm 264 200 5 1.055 1.083 1.441 37% 19.97
go (all) 725 200 5 3.184 4.085 6.911 117% 21.96
rust/platformvm 159 200 5 1.935 2.041 2.408 24% 60.85
rust/xvm 49 200 5 0.021 0.022 0.024 15% 2.13
rust/quantumvm 81 200 5 0.245 0.262 0.317 29% 15.14
rust/zkvm 137 200 5 0.047 0.050 0.062 30% 1.73
rust/dexvm 35 200 5 0.004 0.004 0.005 15% 0.59
rust/fhevm 264 200 5 0.740 0.756 1.234 67% 14.02
rust (all) 725 200 5 3.011 3.115 4.050 35% 20.76
cpp/platformvm 159 200 5 2.655 2.743 4.171 57% 83.49
cpp/xvm 49 200 5 0.012 0.016 0.024 102% 1.21
cpp/quantumvm 81 200 5 29.503 29.653 31.655 7% 1821.19
cpp/zkvm 137 200 5 0.042 0.044 0.047 11% 1.54
cpp/dexvm 35 200 5 0.003 0.003 0.007 128% 0.44
cpp/fhevm 264 200 5 1.568 1.700 2.198 40% 29.70
cpp (all) 725 200 5 33.886 34.509 37.591 11% 233.69
Run it twice and every row of the second lands within 6% of the first, most
within 2% and cpp/quantumvm within 0.06% — on a machine whose spreads reach
12000%. That is the case for quoting the fastest run and reading the spread as
weather.
P and F are the measurement. They are 423 of the 725 vectors and all but a few percent of every total, they are the two chains that verify signatures, and all three implementations answer every field of both. Rust and Go are within 6% on P (60.9 against 64.7 µs a vector) and C++ is 30% behind them (83.5). On F, Rust is fastest at 14.0, Go is 20.0 and C++ is 29.7 — the same ordering, a wider gap. Nothing here separates Go from Rust by more than the machine does.
Z is the one chain Go wins outright, 0.80 against C++'s 1.54 and Rust's 1.73, and the harness cannot say why. D and X are 0.4 to 2.3 µs a vector — derivations and fail-fast parses on 84 vectors between them, near enough the floor that they should not be argued over.
Q is not a comparison, for the reason under "Where it is not": the C++ evaluator stands a fresh chain up for every vector. 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 — C++ about 35% behind Go, which is the same shape P and F give and the same shape an earlier sitting on an M1 gave for a smaller corpus.
The one place luxfi/node is allowed
conformance/gen depends on it. That is the whole point of a reference. It is
a separate Go module for exactly that reason, so it cannot reach node2's own
dependency graph — which make luxd still greps and still fails on.
What it found on the four new chains
The first thing to say is that it bites. Flip one nibble of one vector's wire on each of Q, Z, D and F, and the derived id moves on all four; Go and C++ independently compute the SAME new id, and both differ from the corpus. A harness that could not do that would agree with everything.
Q — 81 of 81 agree. The wire, the canonical re-encode, the block id, the
chain binding and the ML-DSA verification all match. What the differential
caught was in the evaluator, and it is worth writing down: built on a
default-CONSTRUCTED Config rather than the chain's default_config(), the
C++ Q-chain accepted four blocks Go refused — an expired quantum stamp, a
duplicate transaction, and an unsupported ML-DSA parameter set. The zero value
of that struct has quantum_stamp_enabled false, and Config::validate
normalises every other unset field to its default and leaves that one alone.
A Q-chain configured by omission checks no post-quantum signature, and nothing
downstream says so.
The Rust Q-chain became the third voice on those 81 later than the other two,
and it arrived with the SAME hole the Z-chain had just closed. Its evaluator
read syntactic off well_formed() alone, so Q_BLOCK_FOREIGN_CHAIN and
Q_BLOCK_FOREIGN_NETWORK came back OK where Go and C++ both answered
SYNTACTIC — a block naming another chain, waved through the field that is
supposed to catch exactly that. The Rust chain was never wrong: on_chain is
right there, and Vm::verify calls it first, ahead of well_formed and well
ahead of the parent lookup. Only the evaluator had stopped one call short. Two
readers can invent the seam in two places; three can invent it in three, which
is why the definition is written down now instead of inferred a fourth time.
It also declined the identity vector outright — unknown op identity, five
INTERNAL fields — so the one row that would report a Rust evaluator built for
the wrong chain was the row it did not print. It prints the pair it serves now,
and derives it from nothing the corpus hands it.
D — 35 of 35 agree. Every asset id, every market id, the kind and mode parsers, the network class and the value-activation guard.
F — 168 of 168 agree. The six operations, every payload rule, the four ways a signature can fail to be the payer's, and the id the genesis block takes.
Z — 137 of 137 agree. This is where the field got its definition.
Z_BLOCK_TIME_AHEAD, Z_BLOCK_GENESIS_WITH_PARENT,
Z_BLOCK_DUPLICATE_NULLIFIER and Z_TX_EXPIRED answered syntactic OK in
C++ and syntactic SYNTACTIC in Go. Both refused the block, both gave the
same reason, and exec agreed on all four. They disagreed about which phase
caught it, and neither of them was wrong, because nothing said what the phases
were. Three of the four rules are block-level besides, so no per-transaction
pass could have held them however the two readers had split it.
The Z-chain reference has ONE verify. So Go matched the sentinels it knew were
decided before any lookup, C++ called the per-transaction validate_basic its
port happened to expose, and the two inferences did not coincide. The fix is
the definition above — syntactic is what verify decided before it read the
chain — plus one change per implementation to answer THAT.
C++ could name the boundary in its own source, and does:
Block::syntactic_verify(), the name the C++ P-chain already uses at twenty
sites, holding the five rules that are settled from the block in hand — the
genesis/parent pairing, the transaction cap, the clock, a nullifier repeated
inside the block, and per transaction ValidateBasic and expiry < height,
where the height is the block's own, on the wire, not the chain's. check()
calls it first and then asks admit per transaction as before, so the evaluator
asks the chain rather than holding an opinion about it.
Go cannot: its reference is a published module, and luxfi/chains/zkvm keeps
every sentinel unexported, so there is no symbol to name and no method to add.
zBlockAlone quotes the words instead, matched whole rather than by substring
— the proof verifier raises transaction missing proof too, from the far side
of the boundary, and a Contains would have called that one syntactic.
One thing the corpus cannot prove: that the pass really avoids the ledger.
Every vector names the genesis block as its parent, and genesis exists, so a
syntactic_verify that secretly did a lookup would pass all 137. That is
asserted in block_test.cpp instead, on a block whose parent no chain holds.
What it found on P and X, and how each one closed
Thirteen vectors disagreed when the harness was written. All thirteen are closed: the three chains and the corpus now agree on every compared field of all 208 P and X vectors, and the NOT COMPARED list is empty. What follows is the record of what it caught, because a differential that reported nothing would be indistinguishable from one nobody had run.
The two the harness was built to catch:
The P-chain fork. On RegisterL1Validator, SetL1ValidatorWeight,
IncreaseL1ValidatorBalance, DisableL1Validator, ConvertNetwork and the
sovereign form of CreateNetwork, Go and C++ executed — reaching the ledger and
failing only for want of state — and Rust answered UNSUPPORTED, refusing each
by name. That is the sovereign-L1 birth path every downstream L1 depends on.
AddPermissionlessDelegator diverged the same way, through
NetworkTermsNotHeld.
CLOSED by the Rust port growing the plane: an L1 validator register, a
network's own staking terms and validator set, and a chain identity to check a
transaction against. The three …NotHeld refusals are not error
variants, and all seven vectors now reach the ledger.
The X-chain reject. X_SEAM_BLOCK_REJECT: Go and Rust had a reject, C++ did
not — and P_SEAM_BLOCK_REJECT showed the same hole on the P-chain. The root of
both was the C++ host seam itself: lux::node::Block declared verify and
accept and no reject, so neither C++ chain could be told a block lost.
CLOSED at the seam, which is where it had to be: lux::node::Block now
declares virtual void reject() = 0, the engine calls it on a block it gives
up on, and both C++ chains override it — xvm re-verifying each transaction
before returning it to the pool, platformvm reissuing its decision transactions
unverified, each following its own chain's reference rather than one rule for
both. The evaluators ask the question of lux::node::Block rather than of the
concrete class, so the vector goes green only when consensus can genuinely
reach the reject.
Three more it found that were not on anyone's list:
P_EDGE_WRONG_NETWORK— a transaction addressed to network 2 passed the Rust P-chain's syntactic check, which Go and C++ both refuse with "wrong network ID". A transaction that is well-formed on two networks is one signature that spends on both. CLOSED:syntactic_verifytakes the chain it is being verified for, and refuses a transaction naming another network or another blockchain.P_EDGE_EMPTY_NODE_ID— C++ refusedAddValidatorby kind before it looked at the validator; Go and Rust report the empty node id. The transaction is refused either way, for two different reasons, which is a rule that will one day accept for the wrong reason. CLOSED: C++ checks the node id first, which is the order Go'sstandard_tx_executor.gouses.P_IMPORT— Rust refusedImportoutright where Go and C++ execute it. CLOSED: execution takes anAtomic, the shared half an import reads from. A node with none finds nothing rather than refusing — the answer Go gives from an empty shared memory.