The proof cache was keyed on tx.ID as read off the wire, plus the proof
type, the proof and its public inputs — all four the sender's to choose.
Copy an accepted transaction's four onto a transaction spending entirely
different notes and the key matched; the hit returned nil from the cache,
ahead of verifyPublicInputs, which is the only thing tying those inputs to
what the transaction spends. Shielded value, backed by a proof that
attests to nothing about it.
The id is no longer on the wire. parseTransaction derives it, the cache is
keyed on ComputeID(), and a block's id folds in ComputeID() rather than
whatever id arrived — which also ends the block and vertex id malleability
that one flipped byte bought.
Every block and vertex id, and the first public input of every proof, now
open with sha256(ChainID ‖ NetworkID). It is not on the wire, so a block
from another chain has no parent here and its proofs do not verify, rather
than passing a check someone can forget to write.
Also on the consensus surface:
Verify required height == parent+1 and nothing more, so a block whose
parent was a long-accepted ancestor verified, and accepting it rewound
the tip and left the height index naming an orphan to every peer that
bootstraps from it. Verify now requires the parent to be the tip or a
block above it, and Accept re-checks against the tip, which moves
between the two.
Vertex.Verify checked its transactions and nothing else — no parent, no
height — so a vertex naming no parent at height 1<<40 verified, and
accepting it set the store's height to 1<<40 and left the chain unable
to propose again. Its decoder also ignored trailing bytes, so one
logical vertex had unboundedly many encodings under one id, each of
which the store writes to disk.
BuildBlock skipped the ValidateBasic that Verify runs, so a transaction
with an out-of-range Type — read straight off the wire — was assembled
into every block and refused by every node including the proposer, and
nothing evicted it. admit is now that one predicate, assembly drops what
it cannot build, and a transaction names an expiry so a bounded pool
drains.
BuildBlock stamped an unclamped time.Now(), which is below a parent
legally up to maxClockSkew ahead — a block its own Verify refuses.
ParseBlock never tracked what it parsed and SetPreference discarded it,
so a follower could verify the first block of a run and not the second.
A genesis naming no timestamp was stamped with the wall clock, hashed
into the genesis id: one genesis file, a different chain per node and
per restart.
VerifyBlockProof took a GPU path on accel.Available() — a second, inline
copy of the Groth16 checks that consulted neither the cache nor the
dummy-key refusal — so whether a block was accepted turned on whether
the node had an accelerator.
Reads that failed were reported as absence: the spent set answered "not
spent" for a set it could not read, which is the whole double-spend gate;
the state root answered "empty" at boot AND on rollback, where it also
returned nil; the UTXO store answered "not found". Each now reports the
failure.
Deleted, none of it reachable: a node-side wallet holding plaintext
spending keys behind an HTTP endpoint, with a loadAddresses that returned
nil so the keys were unusable anyway; note construction and the package's
only panic; an FHE processor whose verifier tested len(proof) >= 128 and
whose "encryption" wrote the plaintext into the first eight bytes; a
zwitness carrying a TODO; a 256-level sparse Merkle tree that computed no
root and wrote its cache under a read lock; six endpoints answering "Not
implemented"; a Signature field nothing verified; and eight config knobs
nothing read. The endpoints that remain answer at the path the node mounts
them on, rather than behind a mux that dispatches on a path it never sees.
Coverage 58.9% -> 76.7% on a denominator 539 statements smaller.
Accept marked the block accepted and moved lastAccepted before writing
anything, then issued a Put per nullifier and per output, each returning early.
A failure partway left some notes spent and some outputs created under a tip
the chain had already advanced.
Worse, the spent-nullifier cache advanced as it went, and MarkNullifierSpent
refuses a nullifier it already holds — so the block that failed could never be
applied again. The chain was stuck at that height holding notes it believed
spent with no block recording the spend.
Everything a block writes — nullifiers, outputs, the state root — now stages
through the store's view and commits in one batch with the block and the tip.
On failure the view is rolled back and the three caches are rebuilt from what
committed, so the block can simply be applied again. The DAG vertex had the
same defect and goes through the same store.
That is why the store's Block is not the engine's block interface: a vertex has
several parents and no timestamp, so it cannot satisfy it, and it changes state
exactly the same way. The store is generic in what a chain tracks, so Propose,
Open and Block hand back the chain's own type and no caller asserts.
Also here: genesis is seeded through Seed, which commits on its own, because a
staged allocation would ride on whichever block landed first and vanish from a
chain that never accepted one. StateTree.Finalize records the root before
holding it, rather than after.
Restoring the original Accept fails the atomicity test; removing the cache
rebuild fails it on the line that says the cache must agree with the database.
Across these files the comments explained a rule by recounting what happened
without it — which key was mistaken for which, which payer a full pool released,
what a verifier accepted, what a chain did with work it never announced. That
tells a reader what changed rather than what holds, and it dates: the account
stays in the file long after anyone needs it, while the rule it was meant to
convey has to be inferred back out.
They now state the rule. A circuit is judged on its own key. A witness is
exactly as long as the key's circuit says. A count describes the records it is
read from. A full pool releases what pays least. Custody membership costs stake
because an identifier can be aimed and stake cannot. Consensus builds when
WaitForEvent returns, so accepting work and reporting it are one step.
Behaviour is unchanged; the diff is comments, with one exception worth naming:
gofmt realigned a block of one-line methods in quantumvm/feegate_test.go that
was already unformatted before this commit.
Co-authored-by: Hanzo Dev <[email protected]>
loadUTXOs was `return nil`. The in-memory set was never rebuilt, so a restarted
node came back with an empty one: it accepted a commitment it already held —
reaching the opposite verdict from a running node on the same block — and
reported no commitments and no UTXOs at any height. The count came from a
separately persisted key, a second copy of the truth that a crash between the
two writes leaves disagreeing with the records, and one removal from there
drives an unsigned counter below zero. That is the same defect just removed from
the nullifier count, so the UTXO set now works the same way: the records are the
set, and the count is read off it.
Blocks live in the same database under their raw ids, and one id in 256 begins
with the UTXO prefix. The loader now counts a record as a UTXO only when it
names the commitment its own key is made of; a block frame parses as a UTXO
otherwise, since the accessors are bounds-safe and return zeros, and a garbage
commitment would join the set.
Both read paths memoized what they loaded while holding only a read lock. The
memo is deleted rather than the lock upgraded: the map is loaded with the whole
set at startup and maintained by the writers, so the disk read already returns
what the memo would have, and a write lock on a path consensus and RPC share
would serialize every reader to save a lookup. A nullifier value that is not
eight bytes is now a miss rather than a panic in a consensus path; the loader
already skipped those.
Co-authored-by: Hanzo Dev <[email protected]>
The spent-nullifier count was a running total persisted next to the records it
described. Two writes, so a node that died between them came back with a number
that disagreed with its own set — and RemoveNullifier decremented an unsigned
counter with no floor, so unwinding from there wrapped to 2^64-1 and every
dashboard reading it was wrong from then on.
Flooring the decrement would have hidden that; the count would still have been a
second thing that has to agree with a first. Every nullifier record is loaded at
startup and nullifiers are never pruned, so the cache is the whole set, and the
count is now read off it. It cannot disagree with what it describes, the stored
key is gone, and so is the arithmetic that could underflow.
Starts the test from the state that used to be unrecoverable: a record on disk
with no counter beside it.
Co-authored-by: Hanzo Dev <[email protected]>
VMs now live in ONE place: chains/<name>/ owns the full implementation.
Previously split across node/vms/<name>/ (impl) + chains/<name>/main.go
(plugin wrapper). Now consolidated.
New layout:
chains/<name>vm/*.go — package <name>vm (VM code)
chains/<name>vm/cmd/plugin/main.go — package main (plugin binary)
chains/<name>vm/go.mod — module github.com/luxfi/chains/<name>vm
Moved 11 VMs: aivm, bridgevm, dexvm, graphvm, identityvm, keyvm,
oraclevm, quantumvm, relayvm, thresholdvm, zkvm.
External callers now import github.com/luxfi/chains/<name> directly.