Critical Invariants
Thirty-two properties this tree must not lose, each with the reason it
exists. They are numbered, the numbers are referenced from code comments and
from docs/adr/, and a number is never reused: if an invariant is
retired, its entry stays with the retirement recorded.
An invariant earns a number only once a test pins the behaviour. A rule nobody checks is a comment, and it belongs beside the code it describes.
Moved out of CLAUDE.md on 2026-09-20, verbatim and unrenumbered, so that
file could stay short enough to be read in full. See
adr/ADR-001-workspace-layout.md for the
phase that did it.
-
ledger-mathmust never gain a C/C++ dependency. Kani compiles a crate with its full dependency graph; pulling in RocksDB breaks model checking. -
crypto-pqmust remain the crate that instantiatesslh-dsa. Measured: 4468 ms → 140 ms signing when the instantiating crate is optimized rather than the generic one. Moving the instantiation moves the optimization. -
cuda-miner’scudafeature stays default-off socargo build --workspaceworks on GPU-less runners. -
fips204compiles only theml-dsa-65parameter set. A compiled-in set is a set someone can select by accident. Extended by 29 (2026-09-21): still true offips204; ML-DSA-87 arrives throughml-dsain the closed suite registry, which is the form of the same rule that survives crypto-agility. -
[profile.dev.package.*]overrides are load-bearing, not tuning. Without themcargo testreads as hung, not slow. Do not “clean them up”. -
dexmust stay dependency-free, for the same reason asledger-math. The visible cost is that it cannot hash, so pair and share-asset identifiers are derived incustom-l1-nodeand passed in as opaque bytes. That is the price of the boundary, not an oversight to tidy up. -
A trade that merely loses is a no-op, never an
Err. A missed slippage bound, a lost arbitrage race, a batch the pool cannot price: nonce advances, nothing moves. A failing transaction fails its whole block here, so making any of these an error hands every trader a way to void a block. Seedocs/dex.md;crates/node/tests/dex_tests.rspins it. -
Every trading record’s prior value goes in the undo journal. A reorg that left a pool holding the abandoned chain’s reserves is not a detectable corruption — it is two plausible numbers that go on quoting a price. The same applies to the oracle’s
o:records. -
Oracle freshness is measured in block height, never in timestamps.
chain.rsreadsheader.timestamponly for difficulty retargeting; there is no future-drift bound and no median-time-past, so a miner may write anyu64. A timestamp-based freshness check would read as safety and provide none. Seedocs/oracle.md. -
The VRF suite octet is
0x03.0x04is…-SHA512-ELL2, the same curve and hash under a different hash-to-curve map. Using it produces proofs that are internally consistent, pass every round-trip test, and match no other implementation on earth.crates/vrf/tests/rfc9381_vectors.rsis what catches it — do not “simplify” those vectors away. -
The oracle is optional and absent by default. A genesis file without an
oraclesection produces noo:records and the state root the chain would have had without the subsystem. Introducing the chain’s only trusted party is a decision somebody writes down. -
Governance must not be able to make governance unsafe. The quorum floor, the approval floor, the minimum voting period, and the minimum timelock are compiled into
crates/governance/src/limits.rs, appear in noParameterKey, and are reachable by no transaction. Every governable value carries a hard range checked twice — when proposed and again when executed, because a release between the two could have tightened it. -
No governance key’s value is a program. Native code is never fetched from chain state and run. A rule change either moves a number or flips between two implementations the binary already ships, as
crypto/dag/registry.rsalready does withactivation_height. Seedocs/governance.md. -
Nothing on the telemetry dashboard is verified. Every figure but difficulty is an unauthenticated claim. Heights and propagation are medians so one liar cannot set them, reports replace rather than accumulate, and claims outside a hard bound are rejected rather than clamped — a clamped report is a number the reporter never sent. See
docs/telemetry.md. -
The telemetry map is country-granular and has a floor. No address is stored, no coordinate exists anywhere in the crate, and a country with fewer than
MIN_REPORTERSis folded intoZZ. One miner in a small country is an individual, not aggregate data.MIN_REPORTERSis a compiled-in constant and no configuration key, because tuning it to 1 to “see more detail” is the failure it prevents. -
The faucet’s two rate-limit buckets are independent. Keying on the
(IP, address)pair is not a limit: keypairs are free, so one IP with a thousand fresh addresses is a thousand payouts. Both buckets must clear, and both are consumed only if both pass. The daily cap, not the limiter, is what bounds a distributed drain. Seedocs/faucet.md. -
A governed value is read from state, never from a
const. The constants that remain (MAX_FILLS_PER_BLOCK,DEFAULT_PROTOCOL_FEE_BPS, …) are the parameter table’s defaults. Reading one directly at a call site silently un-governs that rule. -
custody-mpcreconstructs the vault key in one place, and that is the design, not a defect to fix quietly. A Maya2C signature is a hybrid pair and both halves must verify; there is no threshold construction for SLH-DSA at all, andfips204exposes nothing that decomposes into partial ML-DSA signatures. So the crate protects the 32-byte chain key — whichsigning_key_from_seedexpands into both halves — rather than thresholding either signature. The combiner holding the key for the length of one signature is the whole cost, it is stated at the top ofcrates/node/src/lib.rs, and anything that quietly relaxes it (a “partial signature” API, a second combiner, caching a reconstructed seed) breaks the only claim the crate makes. Seedocs/custody-mpc.md. -
Every reconstruction is checked against the vault’s commitment before a key is derived from it. Interpolating from too few shares does not fail — it returns a different secret, silently.
vss::check_openingis what turns a short quorum, a corrupted safe, or an inconsistent dealer into an error instead of a signature under a key that owns nothing. It is the reasoninterpolate_openingreturns the blinding factor alongside the secret, and the reason there is no public way to obtain one without the other. -
[Retired 2026-09-21 with
crates/zkmlandcrates/zkml-prover— ADR-008. Kept for the record; the number is not reused.] No ONNX runtime on the consensus path. The node depends onmaya-zkml, which is the verifier only;tract-onnxlives inmaya-zkml-prover, which the node takes as a dev-dependency and nothing more. A separate crate rather than a feature, because a feature can be switched on by any crate in the graph through unification and a crate the node does not depend on cannot. Most ONNX models are floating point, and a float in a consensus rule is a rounding mode two validators can disagree on. Verification checks a proof; nothing in a block runs a model. Seedocs/zkml.md. -
[Retired 2026-09-21 with
crates/zkmlandcrates/zkml-prover— ADR-008. Kept for the record; the number is not reused. 21 still bindshost_verify_zkml_proof, which stays in the VM ABI and answers “no verifier”.] A host function that does native work charges fuel for it, first. Gas is wasmtime fuel and cannot see native work, sohost_verify_zkml_proofcharges a measured price (crates/vm/src/zkml.rs, calibrated bycrates/vm/tests/fuel_calibration_tests.rsandcrates/zkml-prover/benches/verify.rs) before it reads a byte, and traps out-of-fuel before the verifier runs. There is deliberately no tensor host function: guest wasm is priced exactly by the fuel meter, and a hand-set per-MAC price would be consensus-critical and wrong on some machine. -
[Retired 2026-09-21 with
crates/zkmlandcrates/zkml-prover— ADR-008. Kept for the record; the number is not reused.] zkML stays dark until its SRS is real and gas is capped. The SRS incrates/zkml/src/srs.rsis derived from a public seed, so anyone can forge proofs; and no cap bounds a call’sgas_limit, so a fuel price bounds nothing absolutely.ZKML_ACTIVATION_HEIGHTisu64::MAX, andstate::zkml::check_setuprefuses mainnet the moment it is anything else whileSRS_IS_TRUSTEDis false. -
[Retired 2026-09-21 with
crates/zkmlandcrates/zkml-prover— ADR-008. Kept for the record; the number is not reused.] Every constraint incrates/zkml/src/circuit.rshas a test that fails without it. Negative tests hand the circuit a lie that is consistent — everything downstream recomputed — so only the guard under test can refuse it. A lie left inconsistent is caught by some other constraint, and the test then passes with its own guard deleted; that happened, and the mutation sweep indocs/zkml.mdis how it was found. Changing the circuit means re-running that sweep. -
A block’s id and proof of work cover its transactions, and its declared state root is checked.
BlockHeader::tx_root(bytes 112..144, after the nonce soNONCE_RANGEnever moved) is astate::merkleroot overtransaction_leaf(txid).Chain::insert_blockcallscheck_tx_rootfirst, ahead of the duplicate check and before anything is stored: a mismatched body filed under an honest id would turn the genuine block into aDuplicate, which is censorship by one relay.apply_block_journaled— the chain’s only apply path — refuses astate_rootthat execution does not produce. Block producers take both roots fromChain::candidate_blockand never fromstate_root(), which is the pre-block root. Before this, one block id could carry two transaction lists (crates/node/tests/chaos_simulator.rsreplays that attack). Do not add an unchecked apply path toChain. -
Every persisted consensus record is under the state root; everything else is on an explicit local-only list.
state::commitmentsholds the lists:RECORD_LAYERS, the one source for the generic-record prefixes;committed_prefixes();LOCAL_ONLY_PREFIXES(undo:,blk:).
Until 2026-09-12 contract code, contract storage, the nullifier set, and the shielded pool’s anchor window and balance were all outside the root. Nothing checked them, a snapshot could forge them, and a reorg did not even restore contract storage. A new prefix goes into those lists and into a layer, and the undo journal records its prior value. Otherwise
uncovered_keys()fails the subsystem’s tests, and thewrite_overlaydebug assertion fails any test that writes a stray record. -
Blocks and the state they produced commit in one batch. The block store (
state/blocks.rs) lives in the state’s RocksDB:apply_canonicalwrites the canonical-index entry and the tip pointer in the sameWriteBatchas the block’s state;revert_canonicaldoes the same for a revert.
Chain::openrebuilds from it and adopts a heavier stored branch, which is how a crash mid-reorg recovers.seed_stateruns on a fresh database only: re-seeding an evolved one overwrote it, which is why no node could restart before 2026-09-12. -
No block body is deleted before a verified copy exists, and a pruned node never reorgs below its horizon.
prune_roundarchives to every store and reads every copy back beforeChain::prunedeletes anything.Chain::prunerefuses a batch the active chain no longer holds.insert_blockandreorganizerefuse to reach at or belowprune_horizon, before anything is reverted. Pruning is opt-in: an archive node has no horizon. A snapshot is imported only if it reproducesheader(H).state_root; otherwise it is wiped. -
The guard refuses invalid blocks and halts modules, but never halts a transfer.
StateDB::stage_blockis the one place an overlay is built, so the hook at the end of it covers all four commit paths — includingpreview_root, which is why an honest miner refuses to build a bad block rather than minting one the network rejects. A conservation failure is a block-levelInvariantViolation, refused like a wrong state root, with no breaker written because there is no committed state to protect; an anomaly is a judgement, so the block commits and only the module halts, forBREAKER_BLOCKS= 100.Module::of(TxKind::Transfer)isNoneand the match has no wildcard arm, so peer-to-peer payments are ungated by construction and adding aTxKindis a compile error until somebody assigns it. The guard reads only committed state and the block — no clock, no configuration, no node-local value — and the anomaly checks run in a fixed order, because a breaker that trips on one node and not another is a chain split. Every anomaly threshold carries a floor or a tolerance chosen so the breaker cannot be bought:SHIELDED_DRAIN_FLOORexempts a pool too thin for its percentage to mean anything, because a rate with no floor is a lever anyone can pull to halt the module for the price of one fee.g:guard:sits under the governance prefix (asserted at compile time) so it is already under the state root per 25.crates/node/tests/exploit_replays.rspins it; see docs/invariant-guard.md. -
Only parameter sets named in the signature-suite registry are compiled into a signature path, and every FIPS one has NIST known answers.
SuiteIdis a closed#[repr(u8)]enum; an unknown byte is a decode error, never a fallback, because a suite some nodes know and others do not is a fork. Adding a suite is a code change plus ACVP vectors, never a governance act — governance only chooses among compiled suites. Pinned bycrates/crypto-pq/src/suite/tests.rs(every_byte_either_names_a_suite_or_is_refused) andcrates/crypto-pq/tests/acvp_tests.rs— ADR-007. -
Suite
0x30is the node’s hybrid, byte for byte. Same seed domains, same deterministic FIPS 204/205 signing, sameml_dsa_pk ‖ slh_pkandml_dsa_sig ‖ slh_siglayout — so every account and every historical signature is already a valid0x30envelope and the envelope can land without migrating anyone.fips204(node) andml-dsa(suite) must stay the same function of the same seed;crates/node/tests/suite_parity_tests.rspins keys and signatures across both — ADR-007. -
Suite-tagged (v7) and multisig (v8) transactions verify only through
verify_at, undercrypto::suites::verification_policy(), andTransaction::verifynever accepts one.verifyhas no height, so it refuses outright: a path that forgets to pass one fails closed. The policy is a constant — the genesis schedule under mainnet rules — so neither a governance vote nor a node’s config can change which signatures a block’s validity rests on.SUITE_ENVELOPE_ACTIVATION_HEIGHTis 0 (ADR-013). Pinned bycrates/node/tests/suite_parity_tests.rsandcrates/node/tests/suite_envelope_live_tests.rs— ADR-007, ADR-013. -
A vault account moves at most its
limitper window ofdelay_blockswithout a delayed, guardian-cancellable request, and by no door but a plain transfer or a vault action. Outputs to the fee collector count toward the limit above 4x the required fee. Before review closed that door, one transaction could send a vault’s whole balance to the collector. A settled request is deleted, so it cannot be settled twice, and open withdrawals are counted by the conservation guard. Pinned bycrates/node/tests/vault_tests.rs(includingthe_fee_collector_is_not_a_way_around_the_limitandthe_limit_bounds_a_window_not_a_transaction) — ADR-030.