Red found no way past the predicate — every refusal was fail-closed. What it found instead was the opposite failure, three times over: a number about a quorum read somewhere that is not the authority for it. THE DOORS IN FRONT OF THE GATE. HandleIncomingCert and VerifyCatchupCertificate each carried a cheap sub-quorum filter over the sample committee — NovaQuorum(K) for Nova, Alpha() for Quasar — in front of verifyCert. That is a THIRD spelling of a quantity with one definition, and it is a different quantity: the gate derives the accept rung's floor from the SIGNING SET, where NovaSignerFloor saturates at three, while the door read the committee's majority, which grows with K. At six seats the gate accepts a certificate declaring three and the door demanded four; at nine it demanded five; at eleven, six. And where the set is SMALLER than the committee the door asked the set for more than the set's own floor: three signers derive two, and a door reading a committee of four asked for three. So the exact bytes a node's own assembler produces never reached the predicate that would have admitted them. A door in front of a gate may only refuse what the gate refuses, and these could not, so they are gone. Nothing is lost by not asking twice: the gate demands EQUALITY with the derived floor, which is strictly stronger than the "at least" a door applied. Nor is any work saved by refusing early — the certificates a floor filter drops are the ones carrying FEW votes, which are the cheap ones, and Verify stops at the first signature that fails. THE CALLER'S ALPHA. BuildVerifiedQuorumCert took the floor as an ARGUMENT, and on the count-only road nothing re-derived it: one vote and an alpha of one minted an authority token on a chain of five, while a certificate carrying those exact bytes was refused on arrival. The docstring said an alpha that disagrees with the set yields no token, and that was true only of the weighted road. So there is no argument. The floor is SignerFloor(tier, SignerCount(epoch)), read at the door, and there is nothing left for a caller to disagree with. With that, a nil stake source is refused at BOTH rungs rather than at the export rung alone: a floor is a property of the set, and with no set there is no floor and nothing to mint. exportNeedsStake goes with it — one rule where there were two, and the rule Rust and C++ already have by their types, each taking a set by reference so the absent case is unrepresentable rather than checked. The equal-stake road keeps minting through the engine, whose committee is its own, and keeps ADMITTING on the arrival road, where signatures have already arrived to be weighed. THE WIDTH. SignerFloor and Quorum returned uint32 while counting a set held in an int, so the floor wrapped at about six billion signers and SignerFloor(Quasar, ~6.4e9) answered 1 — a floor a lone signer meets. They return int now and are narrowed only where a certificate is written, which is one place; and because the derived clause compares at full width, a mint that narrowed is caught by it rather than believed. THE LAST SECOND SPELLING. attestation.go's export trigger said config.TwoThirdsCount(n) where it means SignerFloor(Quasar, n). Same number, and that is the point: a trigger spelled apart from the rule it must never outrun is a trigger that can drift from it. TestTheDoorAdmitsWhatTheGateAdmits walks the four sizes where the two numbers part, on both arrival roads, and fails on the old filter at every one. Co-authored-by: Hanzo Dev <[email protected]>
43 KiB
Lux Consensus -- Agent Knowledge Base
Repository: github.com/luxfi/consensus Latest Tag: v1.26.0 Go: 1.26.1
Quasar Family of Consensus
The consensus system provides two modes (linear and DAG) with optional
post-quantum finality. All sub-protocols live in protocol/.
Sub-Protocols (protocol/)
| Package | Role | Key Types |
|---|---|---|
photon |
K-of-N committee selection, luminance tracking | Emitter, Luminance |
wave |
Threshold voting + FPC | Wave[T], Config, Photon[T] |
wave/fpc |
Fast Probabilistic Consensus selector | Selector |
focus |
Beta consecutive successes counter | Tracker[ID], Confidence[ID], WindowedConfidence[ID] |
prism |
DAG geometry: cuts, frontiers, uniform sampling | Cut[T], Engine, Proposal |
horizon |
DAG reachability, LCA, transitive closure, skip lists | TransitiveClosure, LowestCommonAncestor, SkipList |
flare |
DAG cert/skip via 2f+1 quorum | Flare, HasCertificate, HasSkip |
ray |
Linear chain finality driver | Driver[T], Source[T], Sink[T] |
field |
DAG finality driver with safe-prefix commit | Driver[V], Store[V], Proposer[V], Committer[V] |
nova |
Linear chain mode (wraps ray) | Nova[T] |
nebula |
DAG mode (wraps field) | Nebula[V] |
chain |
Block interface primitives | Block, ChainState |
quasar |
BLS + Pulsar + ML-DSA threshold signing | signer, BLS, EpochManager, BundleSigner |
Consensus Flow
Linear (Nova): Photon -> Wave -> Focus -> Ray -> Sink
ConsensusCert envelope (CANONICAL — supersedes the Groth16 QuasarCert narrative below)
The finality certificate is threshold CERTIFICATION, not threshold signing.
One policy-gated envelope (protocol/quasar/consensus_cert.go,
VerifyConsensusCert), released at v1.26.0. One-sentence spec: policy
defines the required legs; cert bytes provide per-leg evidence; each mode proves
the same weighted-quorum predicate over the same domain-separated finality
message M. Required legs are policy-derived (ConsensusCertPolicy.RequiredLegs)
and recomputed/checked against the cert's RequiredLegsRoot — never trusted from
cert bytes (the anti-downgrade invariant).
Four typed EvidenceKinds (compact_evidence.go) are the chain-facing
vocabulary; each decomposes onto two orthogonal axes, LegKind (WHAT is required)
× EvidenceMode (HOW it is proven):
EvidenceKind |
wire | LegKind |
EvidenceMode |
|---|---|---|---|
EvidenceBeamBLS |
beam-bls |
LegClassical |
EvidenceClassicalAggregate |
EvidencePulsarThresholdMLDSA |
pulsar-threshold-mldsa |
LegPulsarMLDSA |
EvidenceThresholdSig |
EvidenceCoronaRingtail |
corona-ringtail |
LegCoronaLattice |
EvidenceThresholdSig |
EvidenceP3QMLDSARollup |
p3q-mldsa-rollup (fallback) |
LegPulsarMLDSA |
EvidenceP3QRollup |
Pulsar and P3Q satisfy the SAME leg kind (Module-LWE ML-DSA finality) by different
mechanisms — the policy-table "OR". The two enums are LegKind {LegPulsarMLDSA,
LegCoronaLattice, LegMagnetarSLHDSA, LegClassical} and EvidenceMode
{EvidenceThresholdSig, EvidenceWeightedSigSet, EvidenceStarkCompressedSigSet,
EvidenceClassicalAggregate, EvidenceP3QRollup}. So there are four typed
EvidenceKinds plus the LegMagnetarSLHDSA leg (hash-based FIPS-205, Polaris
profile) outside this vocabulary — production-forbidden from EvidenceThresholdSig
(no aggregatable threshold structure). Never "four legs total."
Policy table (cert_policy_table.go) — exactly four operator postures:
| posture | string | required legs |
|---|---|---|
PolicyBLSFast |
BLS_FAST |
Beam |
PolicyHybridPQCheckpoint |
HYBRID_PQ_CHECKPOINT |
Beam ∧ (Pulsar OR P3Q) |
PolicyStrictQuasar |
STRICT_QUASAR |
Beam ∧ Pulsar ∧ Corona — full dual-lattice AND |
PolicyRecoveryMode |
RECOVERY_MODE |
Beam ∧ P3Q-rollup |
AND-enforcement: VerifyConsensusCert rejects if any required leg is missing,
mode-disallowed, or fails its verifier, plus a cardinality check (each required leg
seen exactly once). STRICT permits ONLY the compact Pulsar threshold sig for the PQ
leg; RECOVERY permits ONLY P3Q; HYBRID permits either.
KeyEra (key_era.go, commit 3153aea93 — decomplect PulsarKeyEra → KeyEra,
value-not-place): ONE generic KeyEra type pins one compact group key to one
validator-set era, qualified by its SchemeID (ML-DSA vs Ringtail) — one type for
both lanes, not a Pulsar-specific era plus a Corona one. It serves the two
threshold/group-key lanes — Pulsar and Corona — ONLY; Beam (classical aggregate)
and P3Q (per-validator rollup) are explicitly refused by eraSuite() (not compact
group-key threshold lanes).
P3Q is FALLBACK only, bound to the committed validator set (commit 73c280efe
closed a zero-stake forge): verifyP3QDirect commits the rollup root to the raw
MLDSACertSet, then verifies it through the audited weighted-sig-set verifier —
binding every leaf to validators.Root() by weighted-Merkle membership at the BFT
weight floor. In the normal/STRICT path Pulsar compresses the whole quorum to ONE
threshold signature, so the P3Q rollup of N independent sigs is the
migration / recovery / bridge / audit object, never the primary finality object;
STRICT structurally refuses it.
Compact — O(1), not O(N). Beam / Pulsar / Corona are each ONE O(1) aggregate
signature, so the cert footprint is byte-invariant in committee size
(TestCompact_O1NotON_For1000Validators asserts naive/compact ≥ 100×). At N=1000
(ML-DSA-65) that test MEASURES/logs 957× — a 3466-byte compact Beam+Pulsar
checkpoint vs a 3,320,000-byte naive 1000-raw-ML-DSA object. 957× is the measured
figure; the only asserted bound is ≥100×.
Boring verifier. VerifyConsensusCert is deliberately straight-line: one leg at
a time, typed rejects, registry-mediated suite dispatch (no suite string can reach the
wrong lane's verifier), no clever shortcuts. It is NOT constant-time — it early-returns
on the first failing leg.
Decomplect. The dealerless PQ core (Pulsar / Corona, via luxfi/threshold) imports
NEITHER luxfi/mpc NOR luxfi/tee — those are optional, cross-repo extensions (absent
from this module's go.mod). Attestation verification is the separate luxfi/cc leaf
with orthogonal vendor Kinds.
The MLDSAProof/Groth16 3-tuple below is the SUPERSEDED prior design.
Quasar Certificate (superseded — historical)
QuasarCert (see protocol/quasar/types.go:40) is a 3-tuple:
type QuasarCert struct {
BLS []byte // BLS12-381 aggregate, 48 bytes classical fast path
Pulsar []byte // Ring-LWE threshold (PQ), O(1) after DKG
MLDSAProof []byte // Z-Chain Groth16 rolling up N × ML-DSA identity sigs, ~192 bytes
Epoch uint64
Finality time.Time
Validators int
}
| Layer | Scheme | Hardness | Raw Size | In Cert |
|---|---|---|---|---|
| BLS | BLS12-381 aggregate | co-CDH | 48 B | 48 B |
| ML-DSA | ML-DSA-65 (FIPS 204) | Module-LWE + MSIS | ~3309 B per validator | 192 B (Groth16) |
| Pulsar | Ring-LWE threshold | Module-LWE | O(1) after DKG | variable |
Modes (each layer independently toggleable):
- BLS-only: classical fast path
- BLS + Pulsar: dual PQ
- BLS + Pulsar + ML-DSA: full Quasar (
TripleSignRound1) - Full Quasar + Z-Chain ZKP: production mode (succinct certificate)
IsTripleMode() checks all three signing layers.
Crypto: luxfi/crypto/bls, luxfi/crypto/mldsa, luxfi/corona/threshold.
PQ Mode Selection
config/pq_mode.go defines the configurable PQ mode enum, selectable via
the CONSENSUS_PQ_MODE env var or Parameters.PQMode field:
| Mode | Value | Description |
|---|---|---|
BLSOnly |
bls | Classical fast path, smallest cert |
BLSPlusMLDSA |
bls-mldsa | BLS + per-validator ML-DSA-65 |
BLSPlusCorona |
bls-rt | BLS + Pulsar 2-round threshold |
BLSPlusGroth16 |
bls-z | BLS + Z-Chain Groth16 rollup (placeholder) |
TripleQuantum |
triple | All three layers active |
engine/pq.NewConsensus resolves the mode via config.PQModeFromEnv and
exposes PQMode() getter. bench/pq_modes_bench_test.go covers all modes
with real signing (real BLS aggregate, real ML-DSA-65, real Pulsar
2-round threshold).
Bench (Apple M1, n=21):
| Mode | Sign | Agg | Verify | Cert | Storage 10K |
|---|---|---|---|---|---|
| bls | 312µs | 8.6ms | 714µs | 123 B | 1.17 MB |
| bls-mldsa | 369µs | 8.5ms | 3.4ms | 69 KB | 665 MB |
| bls-rt | 39ms | 3.3s | 1.6ms | 33 KB | 318 MB |
The Pulsar layer is implemented by Pulsar (github.com/luxfi/pulsar/threshold)
— Lux's variant with DKG2 (pulsar/dkg2/) and Pulsar-SHA3 hash suite
(pulsar/hash/sp800_185.go, KMAC over cSHAKE256). Pulsar params are
byte-identical to the original Pulsar: M=8, N=7, LogN=8 (ring degree
256), Q=0x1000000004A01 (48-bit NTT-friendly prime), Dbar=48, Kappa=23.
Cert-size honest accounting (production params, classical 2^142 / quantum 2^130 security):
- Signature = (C: 1 ring.Poly) + (Z: Vector[ring.Poly] len 8) +
(Delta: Vector[ring.Poly] len 8) = 17 polys × 256 coeffs × 8 bytes
raw ≈ 34816 B; measured 33052 B native binary, 33221 B gob (gob
bloat is only 1.01x — see
cert_size_compare_test.go). Native encoder ships inprotocol/quasar/corona_gob.go(replaces gob). - 10K certs ≈ 315 MB native, 317 MB gob.
- The earlier "10 MB / 10K = 1 KB / cert" claim was a different parameter sweep (smaller ring + smaller Q) — would lose ~20 bits classical and ~15 bits quantum security. Not interchangeable with Pulsar production. | triple | 40ms | 3.3s | 4.3ms | 102 KB | 981 MB |
Chain Separation for Threshold Cryptography
Per LP-134: T-Chain MPC and FHE roles (originally per LP-7330) are split into M-Chain and F-Chain. T-Chain is reserved for teleportvm (LP-6332).
Quasar consensus lives here in consensus/, but the threshold-crypto ceremonies
that feed it are split across the primary network chains:
| Chain | Role |
|---|---|
| X-Chain | Verifies already-signed UTXOs via Fx plugins (secp256k1fx, mldsafx, slhdsafx, ed25519fx, secp256r1fx...). Does not run MPC ceremonies. |
| Q-Chain | Runs the Pulsar 2-round threshold for consensus only (this repo's protocol/quasar/ emits those rounds). Not a general MPC host. |
| M-Chain | (was T-Chain MPC per LP-7330; superseded by LP-134) Runs MPC ceremonies (CGGMP21, FROST, Pulsar-general) for bridge custody of external wallets. |
| F-Chain | (was T-Chain FHE per LP-7330; superseded by LP-134) Runs TFHE bootstrap-key generation and FHE compute (encrypted EVM). |
| T-Chain | Now reserved for teleportvm (LP-6332): unified bridge + relay + oracle. |
| Z-Chain | Rolls N per-validator ML-DSA identity sigs into a single 192-byte Groth16 proof per epoch (the MLDSAProof field). |
Why MLDSAProof and not ThresholdMLDSA: threshold ML-DSA has no FIPS
standard; research constructions hit a rejection-sampling circular dependency
(see ~/work/lux/proofs/quasar-cert-soundness.tex App. A). Quasar takes the
non-threshold path — each validator signs individually, Z-Chain compresses via
Groth16 over BLS12-381.
Formal Proofs (LP-105 + Proof Sketch)
The paper + proof sketch carry the soundness/liveness/PQ-safety arguments:
~/work/lux/papers/lp-105-quasar-consensus.tex:- §5 Chain Separation
- §6 QuasarCert Formal Definition (Def 6.1, 6.2)
- Thm 7.5 Soundness
- Thm 7.6 Parallel Liveness
- Thm 7.7 Post-Quantum Safety
~/work/lux/proofs/quasar-cert-soundness.tex:- App B — ML-DSA-65 R1CS constraint count (~2^22.5 per verification; per-cert amortized to ~2^20 via shared-matrix optimization for n=21 validators)
- App C — Static vs adaptive corruption (Fischlin / erasure hybrids)
- App D — Trusted-setup ceremony (Bowe-Gabizon-Miers), PLONK upgrade path
- App E — Pulsar parameter tightness: classical 2^142, quantum 2^130 via BDGL sieving + Grover speedup
Domain separation
All ML-DSA/SLH-DSA callers bind signatures to a context string per FIPS 204/205:
| Context | Used by | File |
|---|---|---|
lux-x-chain-utxo-v1 |
UTXO Fx plugins | utxo/mldsafx, utxo/slhdsafx |
lux-evm-precompile-mldsa-v1 |
EVM precompile | precompile/mldsa/contract.go |
lux-evm-precompile-slhdsa-v1 |
EVM precompile | precompile/slhdsa/contract.go |
lux-reshare-v1 |
Key resharing HKDF | threshold/mpc |
lux-wave-v1 |
Wave voting | consensus/protocol/wave |
No collisions. See crypto/mldsa.SignCtx/VerifySignatureCtx (same for slhdsa).
Transport
Inter-node: ZAP (github.com/luxfi/zap), NOT p2p or gRPC/protobuf.
Package Layout
consensus.go Root facade, type aliases, NewChain/NewDAG/NewPQ
config/ Parameter presets (single, local, testnet, mainnet)
core/ Core interfaces, dag structures
dag/ DAG store, event horizon, ordering
engine/ Consensus engines (Chain, DAG, PQ)
chain/ Linear chain engine
bootstrap/ Fetch+execute initial sync to the beacon-named frontier
summary/ State-summary adoption — runs BEFORE bootstrap
syncer/ Consensus-pointer reconcile after an RLP import (unrelated)
dag/ DAG engine
pq/ Post-quantum engine
interfaces/ State enum (Unknown..Stopped)
protocol/ All Quasar sub-protocols (see table above)
types/ Block, Vote, Config, Decision, bag/
runtime/ VM wiring (chain IDs, validators)
pkg/wire/ Wire credentials (ML-DSA-44/65/87, BLS, Ed25519)
bench/ Benchmarks (ZAP throughput, Lux vs prior-gen EVM)
version/ Re-exports github.com/luxfi/version
Performance (Measured)
All numbers below are measured on Apple M1 Max (10 cores, darwin/arm64), CPU
path only unless noted. See BENCHMARKS.md for full raw output and reproduce
commands.
ZAP Wire Protocol (bench/)
Raw wire message throughput (ZAP transport microbench), NOT consensus finality TPS.
| Config | Wire messages/sec |
|---|---|
| Single connection | 114K msg/s |
| 20 parallel connections | 376K msg/s |
| 50 conns + batch 1000 | 20.26M msg/s |
Protocol Microbenchmarks
| Component | Operation | Time/Op |
|---|---|---|
| Wave | Vote round | 3.38 us |
| Photon | K-of-N selection | 3.03 us |
| Luminance | Brightness update | 72 ns |
| Quasar | QuantumHash | 435 ns |
| Quasar | Validator add | 328 us |
QuasarCert Components (measured CPU path)
Per-component CPU costs for QuasarCert production and verification:
| Operation | Time | Source |
|---|---|---|
| BLS sign (single) | 350 us | crypto/bls BenchmarkSign |
| BLS verify (single) | 820 us | crypto/bls BenchmarkVerify |
| BLS aggregate 100 sigs | 5.3 ms | protocol/quasar BenchmarkBLSAggregation/100 |
| BLS aggregated verify (100 signers) | 875 us | protocol/quasar BenchmarkBLSAggregatedVerification/100 |
| ML-DSA-65 sign | 495 us | crypto/mldsa BenchmarkMLDSA_Sign |
| ML-DSA-65 verify | 181 us | crypto/mldsa BenchmarkMLDSA_Verify |
| ML-DSA-65 verify (via Fx) | 254 us | utxo/mldsafx BenchmarkMLDSA65Verify |
| ML-DSA-65 verify (cached) | 3 us | utxo/mldsafx BenchmarkMLDSA65VerifyCached |
| SLH-DSA-192f verify | 1.92 ms | utxo/slhdsafx BenchmarkSLH192fVerify |
| Quasar full block (BLS+ML-DSA+Pulsar) | 1.85 ms | protocol/quasar BenchmarkQuasarBlockProcessing |
QuasarCert verify (approx CPU, single cert, n=21 validators):
- BLS aggregate verify: ~875 us (constant in signer count)
- Groth16 proof verify: ~1-3 ms (pairing-dominated, not yet in our bench harness — see App B of proof sketch)
- Pulsar threshold verify: variable, amortized O(1) after DKG
- Total: ~2-5 ms per cert, GPU batch can amortize 10-100x across certs
Note on the stale 357 us claim in older papers: The "357 us epoch finality" from earlier Lux drafts (lux-triple-proof-consensus, lux-master-security-model, lux-performance-security-tradeoffs) does not match any measured operation in the current code. Closest real candidates: BLS single keygen (350 us), ML-DSA-65 sign (495 us), Groth16 proof size is 192 B but prover time is ~400 ms CPU / ~5-15 ms GPU for the full ML-DSA-65 verification circuit (App B). Papers should quote the measured 2-5 ms CPU QuasarCert verify.
Signature Schemes Benchmark (crypto + utxo Fx)
| Scheme | Single Verify | Cached | Ratio vs secp256k1 |
|---|---|---|---|
| secp256k1 (C, native) | 658 ns | — | 1.0x |
| P-256 (Go stdlib) | 121 us | 1.0 us | 184x |
| Ed25519 (Go stdlib) | 205 us | 1.1 us | 312x |
| ML-DSA-44 | 140 us | — | 213x |
| ML-DSA-65 | 250 us | 3.0 us | 380x |
| ML-DSA-87 | 420 us | — | 638x |
| SLH-DSA-SHA2-192f | 1.92 ms | 131 us | 2912x |
| BLS (single verify) | 820 us | — | 1246x |
CostPerSignature values in UTXO Fx plugins are benchmarked-proportional.
GPU Primitives (Metal, Apple M1 Max)
| Operation | Time | Throughput |
|---|---|---|
| MatMul (dense) | 399 us | 20.0 GB/s |
| Add (elementwise) | 336 us | 238 MB/s |
| NTT (N=8, CPU fallback) | 461 ns | — |
| PolyMul (N=8, CPU fallback) | 1.5 us | — |
| FieldMul | 2.2 us | — |
GPU batch verify kicks in at ≥64 signatures (accel.BLSBatchVerifyThreshold).
Below that, the CPU single-verify path is faster due to kernel dispatch
overhead. Raw Metal dispatch is ~100 us minimum; the break-even for ML-DSA is
around 64 signatures.
EVM (evmgpu core, CPU only)
| Operation | Time |
|---|---|
| InsertChain empty block | 171 us (5844 blocks/sec) |
| InsertChain value-tx block | 246 us (4067 blocks/sec) |
ring-call benchmarks (ring200, ring1000) currently hit a pre-existing
nil-pointer in core/types.Header.Hash at bench_test.go:306 when running full
chain-read; unrelated to consensus correctness but flagged for evmgpu repo.
Lux vs prior-gen EVM (bench/)
ZAP deserialization: 157x faster than protobuf (21 ns vs 3231 ns, zero allocs).
Wire deserialization throughput: 11.5M msg/s (Lux ZAP) vs 246K (protobuf baseline)
— a raw serialization microbench, NOT consensus finality TPS.
Run: GOWORK=off go test -v -run TestLuxVsLegacy_EndToEnd -bench=. ./bench/
Key Technical Notes
The finality ladder's two floors (engine/chain + config)
Both accept rungs carry a STAKE floor and a distinct-SIGNER floor, and neither
half is sufficient alone. QuorumCert.VerifyWeighted enforces both, recomputed
from the authoritative validator set at the cert's epoch:
| rung | set | signers | stake | authorizes |
|---|---|---|---|---|
| Nova | n ≥ 1 | ≥ chain.NovaSignerFloor(n) |
> config.HalfStakeFloor(signer) |
local execution (reorgable) |
| Quasar | n ≥ minBFTCommittee (4) |
≥ config.TwoThirdsCount(n) |
> config.TwoThirdsStakeFloor(signer) |
export — bridges, DEX, cross-chain |
A certificate also DECLARES a quorum, in QuorumCert.Threshold, and that number
must be exactly chain.SignerFloor(tier, n) — the floor the set derives for the
rung. A certificate states its quorum; it does not choose it. Without the
clause the field is load-bearing: Verify's last clause counts distinct valid
accepts against the certificate's OWN threshold, so one declaring 1 clears it on a
single signature, and only the floors above stand between that and finality. The
rule is EQUALITY, not a lower bound — an over-claim names a bar this set does not
set, and tolerating it would let a certificate redefine the rung upward exactly as
tolerating an under-claim lets it redefine the rung down. SignerFloor is the ONE
definition (Rust finality::signer_floor, C++ signer_floor(Tier, n)), read by
the rung predicates, by the assemblers, and by this clause, so the number enforced
and the number declared cannot drift.
TWO derived counts, because there are two predicates — and reading the wrong one
makes a node refuse its own certificates. chain.SignerFloor is the
distinct-signer GUARD the weighted road carries beside a stake clause;
chain.Quorum is the count that IS the predicate where no stake clause stands
beside it (the count-only road). They agree at Quasar — the export supermajority is
TwoThirdsCount(n) either way — and DIVERGE at Nova past five signers, because
NovaSignerFloor saturates at three: correct as a guard on a road whose majority is
carried in stake, and wrong as a majority on a road that has only the count. So
assembleCertLocked declares NovaSignerFloor(n) on the stake arm and
NovaQuorum(n) on the nil-stake arm, and each road's derived clause must read the
number its own assembler declares. Every committee the test fixtures use is 4 or 5,
which is exactly where the two agree — TestTheTwoRoadsDeriveTheNumberTheyDeclare
walks 1..64 so the divergence is stated rather than discovered at K=6 in production.
Four corollaries, all load-bearing:
- Minting states no quorum, and mints nothing without a set.
BuildVerifiedQuorumCerttook the floor as an ARGUMENT — the caller's alpha — and on the count-only road nothing re-derived it: one vote and an alpha of one minted an authority token on a chain of five, while a certificate carrying those exact bytes was refused on arrival. So there is no argument. The floor isSignerFloor(tier, SignerCount(epoch)), read at the door, and anilstake source isErrCertNeedsStakeat BOTH rungs: a floor is a property of the set, and with no set there is no floor and nothing to mint. This is what Rust'sTally::certandQuasarConsensus::create_certificateand C++'sCert::verify_weightedalready had by their types — each takes a set by reference, so the absent case is unrepresentable rather than checked — and Go now agrees by rule where they agree by construction. - The arrival roads read NO floor of their own.
HandleIncomingCertandVerifyCatchupCertificateeach carried a cheap pre-filter over the sample committee —NovaQuorum(K)for Nova,Alpha()for Quasar — in front ofverifyCert. That is a THIRD spelling of a quantity with one definition, and it is a different quantity: the gate derives the accept rung's floor from the SIGNING SET, whereNovaSignerFloorsaturates at three, while the door read the committee's majority, which grows with K. At six seats the gate accepts a certificate declaring three and the door demanded four; at eleven it demanded six; and where the set is smaller than the committee the door asked a set of three for the four-seat committee's three when the set derives two. An honest certificate — the exact bytes the node's own assembler produces — never reached the predicate that would have admitted it. A door in front of a gate may only refuse what the gate refuses, so the doors are gone and the floor is read once, where it is authoritative. Nothing is lost: the gate demands EQUALITY with the derived floor, which is strictly stronger than any "at least" a door applied, and no work is saved by refusing early — the dropped certificates were the ones carrying FEW votes, which are the cheap ones.TestTheDoorAdmitsWhatTheGateAdmitswalks the sizes where the two numbers part. verifyCertderives on the arrival road's own terms. It ADMITS a certificate that arrived carrying N real signatures from N distinct in-set validators — all that was missing is that its declared quorum was its own. With a stake source it isVerifyWeighted; without one the floor comes fromeffectiveCommittee, that road's authoritative view of its set. Not "an equal-stake chain cannot export", but "it may not export on a quorum the certificate wrote for itself". Without the clause a gossiped Quasar cert declaring a quorum of ONE, over one signature, reachedAcceptWithCert. That committee view is weaker than a stake source (it is K, the sample size), and that asymmetry is why the MINTING door refuses without a set rather than weighing a configured number: a committee is strong enough to CHECK a declaration and not strong enough to ORIGINATE one. The price, and it is real: with no stake model the committee is configured, so this road now needs the fleet to agree on K — two nodes that disagree about their committee disagree about which certificates exist, where before any declaration was admitted and they interoperated by accident.- Every assembler reads the same n the verifier does.
assembleCertLockedderives Nova's declared floor fromstakeSource.SignerCount, not fromeffectiveCommittee(which sizes the SAMPLE and is floored atminBFTCommittee). At three signers the two disagreed — the sampler said three, the set said two — so a node would have built a certificate its own peers refuse.attestation.go's export trigger readsSignerFloor(Quasar, SignerCount), the same function, so the trigger and the rule cannot part.
SignerFloor and Quorum return int, not uint32: the floor is counted at the
width the SET is counted at, and narrowed only where a certificate is written.
Narrowing first wraps — ⌊2n/3⌋+1 passes 2³² at about six billion signers, and a
wrapped floor of 1 is a floor a lone signer meets. Because the derived clause
compares at full width, a mint that narrowed is caught by it rather than believed.
Five things produce the authority token, and every one of them runs the full predicate first or has no votes to run it on:
BuildVerifiedQuorumCert— the exported minting door, from raw votes over a set.HandleIncomingCert— a gossiped certificate, throughverifyCert.VerifyCatchupCertificate→AcceptCatchupBlock— the same, on the catch-up road.assembleVerifiedCertLocked→assembleCertLocked— the engine's own assembly, which verifies before it caches.buildSingleValidatorCertLocked— the K==1 road, and the one that is NOT a predicate: on a genuinely single-validator chain it synthesizes a zero-vote Nova token from the position, because the sole validator's own accept IS the quorum and there is no other party to protect against. It is fenced three ways — both callers gate onK()==1, it prefers a real signed 1-of-1 whenever one assembles, and it returns the ZERO token (whichAcceptWithCertrefuses) once the live count passes one on a chain that has finalized anything. It is Nova only, so it is never an export door.
Both numbers are read over the SIGNERS: signer is StakeSource.SignerStake and
n is StakeSource.SignerCount, never the membership roll. A member the chain
carries without a key is a spectator — it holds stake and can never cast a vote
any verifier accepts — and counting it would raise a bar it cannot help clear.
Past a third of what the chain carries, ⅔ becomes arithmetically unreachable and
export is stranded with every signer agreeing; past a half, so is Nova. The corpus
freezes both halves separately, because a set can be stranded in stake or in
seats: quasar_keyless_stake (six signers, a seventh member holding a third of
the roll — the count floor is 5 either way, so only the stake denominator
decides) and quasar_keyless_count (four signers, two members holding one unit
each — the stake clears under either reading, so only the count denominator
decides).
This is the ⅔ rule read over the right set, not a weakening of it. Quorum intersection is untouched — two disjoint quorums each past ⅔ of the signer stake would sum past the whole of it — and the fault budget simply stops being spent on parties known in advance to cast no vote.
The export rung's third floor: the SET
Reading both floors over the signers lets a set SHRINK to its signers, and a rung
whose floors are read over n is satisfiable at every n — TwoThirdsCount(1) is 1,
so one signer over a one-signer set clears the count and the stake floors
together. Byzantine tolerance is f = ⌊(n−1)/3⌋, which is ZERO for n of one, two
and three: such a certificate absorbs no fault, and one compromised key among its
signers forges export finality outright.
So the export rung refuses a signing set below minBFTCommittee — the same
constant bftCommittee floors committee SELECTION at, read at the certificate
instead of at the sampler. Nova has no such clause and must not grow one: it
authorizes local execution the chain can still reorg away, and a small or
partitioned chain has to be able to make progress. A chain with three validators
therefore PRODUCES and never CERTIFIES; operators need n ≥ 4 to export with any
fault tolerance at all. The corpus freezes this as quasar_keyless_third, whose
three unanimous signers clear both quorum floors and are refused anyway.
The C++ engine enforces the same shape from the other direction: Validator is a
public key there and the set is keyed by one, so a spectator has no slot to
occupy — and since a PubKey is a fixed array, presence proves nothing, so its
constructor now runs bls::key_validate on every seat. A key that is not a
canonical, in-subgroup, non-identity G1 point is refused at construction rather
than admitted and silently unable to sign. "A validator is a signer" is enforced
there, not assumed.
Carried stake vs signer stake, at the seam
StakeSource reports both. SignerStake/SignerCount are the denominators every
floor is read against; CarriedStake is the membership roll's weight and NO floor
may read it. It exists so the gap is visible: a chain whose signable stake has
fallen to a sliver of what it carries keeps certifying correctly at ⅔ of the
sliver, and ResponsiveStakePct — which is voted/signer — stays healthy the whole
way down. FinalityStatus.SignerStakePct (signer/carried) is the number that
falls, and the one to alarm on. Rust's ValidatorSet::carried, C++'s
QuorumCertEngine::total_stake.
The reason for a count floor at all: a threshold read only in stake reports one
party wherever the stake is concentrated in one validator. Two thirds of the stake
is one signature there, and a certificate one key can mint is not a claim that a
Byzantine supermajority agreed. Nova's floor saturates at three (it asks only "more
than one party?" before a REORGABLE local execution); Quasar's is the supermajority
itself, ⌊2n/3⌋+1, and grows with the set.
config.TwoThirdsCount is the ONE definition of that count and it is derived from
config.TwoThirdsStakeFloor over unit weights, so the count and the stake predicate
cannot drift. It has two uses and they are the same rule from two sides:
FeasibleParams sizes α to it, and the verifier enforces it. It is not configurable
— a signer floor an operator can set is a security parameter chosen by the party it
constrains. Rust two_thirds_count, C++ two_thirds_count, same number.
Quasar reads STAKE FIRST and the count second. Both must hold, so the order decides only which clause a refusal is NAMED by: on an equal-stake set the two are one bar in two units and bind together, and the count clause can bind ALONE only where the weights are lopsided — so reaching it means the stake was there and the signers were not. Nova orders the other way because its count floor is a lower, saturating, stake-independent bar, a different question asked first.
Liveness, held by engine/chain/quasar_count_floor_test.go: QuasarAttestor (the
only producer of export certs) assembles at exactly TwoThirdsCount(n) over the live
set, which is the number the verifier recomputes — so the clause refuses nothing the
engine can produce. The gossip door Runtime.HandleIncomingCert applies is
FeasibleParams' α, and what is proven of it is Alpha == TwoThirdsCount(p.K): the
same function, read at the chain's K rather than at the live n. K and n are one number
whenever the params were sized to the set that is running. Where they part the verifier
is the authority — a door under it admits what the predicate then refuses, and a door
over it is the gossip-admission bound the runtime already had, not something this
clause added.
Test Status
- All tests pass except
TestQuantumBundle_ChainIntegritywhich is flaky (Pulsar threshold signing nondeterminism -- passes on retry) TestNovaQuasarMatrix_FourOfFive_ExportsQuasaris also FLAKY (pre-existing, observed at HEAD35ab4b25with the change stashed: 3 fails / 1 pass across isolated and full-suite runs,CGO_ENABLED=0; the fail is the 25s export liveness waitFor expiring withtips=map[]). Not introduced by any test-only change — needs a look from whoever owns the steer-site work.- Build:
GOWORK=off go build ./... - Tests:
GOWORK=off go test -count=1 -short -timeout 300s ./... - Coverage:
go test -covermode=count -coverprofile=c.out ./engine/chain/... ./validator/... ./config/... ./conformance/...thengo tool cover -func=c.out.make coverage-htmlrenders it.
The clauses coverage cannot reach, and why
Four statements in the consensus core are unreachable through any public entry point. They are walls behind walls, they are correct, and chasing them costs an afternoon each — so they are written down instead.
cert.goverifyNovaMajority/verifyQuasarSupermajority's COUNT clauses (VoterCount() < SignerFloor(...)) — unreachable under an IDEMPOTENT source, and not otherwise.Verifyprovescount >= Thresholdand the derived clause provesThreshold == SignerFloor(n), socount >= SignerFloor(n)follows — but each clause readsSignerCountfor itself, and a source that answers the two reads differently makes them two numbers. Red raced 3,600 pairs against a source that moves between reads; in 1,924 of them these count clauses were the ONLY clause that refused, and they refused fail-closed. So they are not dead code kept for a reader: they are the guard that survives a set moving under the predicate, and they are where the floor is DEFINED. Rust and C++ carry the same redundancy for the same reason. The ISSUE road has no such race —Tally::certreads the count once and answers every clause of one issue with it — because there the two numbers would be the assembler disagreeing with itself. This is also why the corpus's under-quorum rows are now namedbelowThresholdbyVerifyrather than by the rung.cert.goVerifyWeighted'sdefault:unknown-tier arm.Verifyruns first and refuses any tier outside {Nova, Quasar}, so the switch below it never sees one.TestATierIsRefusedByVerifyBeforeVerifyWeightedSeesItpins that ORDER, so a reordering that made the arm reachable fails a test rather than passing quietly. Rust carries the identical dead arm atcert.rs:416.pop.goVerify's canonical re-compress check, andVerify's ownMessageerror.bls.PublicKeyFromCompressedBytesis canonical — probed over all 256 spellings of byte 0 and every single-bit flip of a real key, zero decode to a point that re-compresses differently — a property of the RE-CAPTURE, where 9 of the 13 probe inputs were rewritten, and not of a run that happened to find none — and the width was checked two lines above. Rust'spop.rs:83and C++'sbls.cpp:145are the same wall.registration.goRegister's decode afterpop.Verifysucceeded, which decoded the same bytes. C++ marks its twinunreachable: pop_verify decoded it.attestation.goIngest'sAssembleQuorumCerterror: every input is already known good there (Quasar tier, threshold ≥ 1, accept-only, map-distinct voters, count ≥ threshold).
Reachable only with a filesystem fault seam, which does not exist: the four I/O
failure legs inside writeFileAtomic (write, sync, close, rename) and
fsyncDir's Sync error. The rest of both paths — a temp file that outlives a
write, a destination whose directory is gone, a directory that cannot be opened
— is covered.
Not a coverage question but found by asking one: ErrQCVotePosition is declared
in cert.go and returned by nothing. The var block above it says each error maps
1:1 onto a predicate clause; that one maps onto no clause, because a vote that
signed a different position fails as a bad SIGNATURE — the message is rebuilt
from the cert's own position and never from the vote's, so there is nothing left
to compare. A reader building a cross-implementation error corpus would take the
comment at its word and look for the clause.
Restart preserves state — a REAL assertion now (engine/chain)
TestRestartPreservesState used to check only IsBootstrapped/HealthCheck flags
across Stop/Start — an engine that dropped every accepted block on Stop still
passed. It now drives a real signed 4-of-5 quorum accept (Nova) through the
Quasar export cert BEFORE the stop, asserts the consensus-critical triple
(accepted set via finalizedByCert, Preference(), QuasarHeight()) survives
the restart unchanged, and then finalizes + certifies a SECOND height on the
restarted engine. Negative control executed: a Stop that clears
finalizedByCert fails the test at "restart must not lose an accepted block"
— the old body passed under that same mutilation.
State-summary adoption (engine/chain/summary)
The client half of state sync: it decides which summary a node adopts before it
bootstraps, so a node whose trie is damaged or whose gap exceeds the descent
window has a way back that is not a reseed. New(Config).Run(ctx) returns
(Outcome, error) — OutcomeAdopted (wait for the VM's done signal, then
bootstrap the tail from the summary height), OutcomeSkipped (bootstrap from the
local tip; nothing was destroyed), and a zero OutcomeInvalid that can never read
as adopted.
Two rounds. DISCOVERY (Source.Offers) asks the connected beacons what each
holds; its only product is a list of candidate heights and it carries no
threshold — a wrong height costs one entry in the next request. RATIFICATION
(Source.Ballots) asks the WHOLE beacon set, never a sample, and adopts only when
a summary's stake strictly exceeds ⅔ of the stake that answered AND the stake that
answered exceeds half the whole set (config.TwoThirdsStakeFloor /
config.HalfStakeFloor — the same definitions the cert verifier uses). The second
term is what stops an eclipse from shrinking the denominator until a sliver of
stake looks like a supermajority.
Load-bearing details, each pinned by a test that a mutation kills:
- A candidate at or below the local tip is dropped before the vote, not left
for the VM to refuse inside the state-destroying
Accept. - One beacon, one voice: ballots dedup by NodeID, and ids dedup within a ballot.
- Selection ranges over the candidates, so an id nobody offered cannot win however much stake names it.
- Stake overflow refuses the round; it never saturates.
- An interrupted sync (
GetOngoingSyncStateSummary) enters as an ordinary candidate — that is what lets it be ratified when no beacon offers it — and wins only among the summaries that clear the bar. - Static and dynamic modes both wait for the VM. Bootstrap re-executes from the summary height against the state below it, so "fetch in the background" fails those executions for reasons unrelated to the network.
Zero transport lives here: no request ids, no pending/failed sets, no timeout
manager, no validator set. Source is two blocking, window-bounded calls the node
implements, exactly as bootstrap.BlockSource is. Not to be confused with
engine/chain/syncer/, which reconciles consensus pointers after an RLP import.
SDK Status (Honest Assessment)
- Go: Production-ready (protocol/, engine/, core/)
- Python (
pkg/python/): Only complete non-Go SDK with real consensus logic - C (
pkg/c/): Data structures only, not real consensus - Rust (
pkg/rust/): pure Rust, no FFI. Conformant on the finality standard, partial on the protocols — see below. - C++ (
pkg/cpp/): Stub
Rust binding — what it is held to
conformance/corpus.json is the standard as data, generated from the Go
definitions by conformance/corpus.go. pkg/rust/tests/conformance.rs reads
that file and holds the crate to it. Regenerate with
go test ./conformance -update; the Rust tests fail if the two drift.
Conformant and checked against the corpus: the signed vote message (226 bytes
under LUX/chain/vote/v2\0), the certificate wire form byte for byte, both
stake floors across the full u64 range, the count thresholds, the ladder, the
per-epoch FPC seed, and θ/α to the exact IEEE-754 bit over 192 recorded cases.
NOT conformant, and known:
- The sampler's
NodeIDis 32 bytes;luxfi/ids.NodeIDand every certificate on the wire use 20.finality::Nodeis the correct width;quasarhas not been moved onto it, so a node's sampling identity and its signing identity are populated separately by the caller. waveaccumulates one cumulative tally per block. Go samples K fresh votes per round. The phase now advances per counted round rather than per vote, which is Go's placement, but the round model itself still differs.QuasarConfig::alphais anf64.config/constants.gorequires an integer quorum predicate and says so.- There is no P2P, no block store and no engine loop. The crate computes; it does not participate.
bls carries the ciphersuite Lux signs under —
BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_, public keys 48 bytes in G1,
signatures 96 bytes in G2, matching luxfi/crypto/bls.
benches/consensus_bench.rs measures the round and certificate hot paths.
Signature verification is per-vote, as Go's QuorumCert.Verify is, and it
dominates: ~0.8 ms a signature, so a 21-signature certificate is ~14.5 ms
(Ryzen, --measurement-time 1.5). Aggregate verification would collapse that
to one pairing, but only behind proof-of-possession — without it, aggregation
is a rogue-key attack.
Dependencies (Critical)
github.com/luxfi/crypto-- BLS, ML-DSA, threshold signinggithub.com/luxfi/corona-- Ring-LWE threshold signaturesgithub.com/luxfi/zap-- Zero-copy wire protocolgithub.com/luxfi/ids-- ID typesgithub.com/luxfi/version-- Version management
ValidatorSchemeID — cross-axis NodeIDScheme gate
config/validator_scheme.go (v1.23.4) owns the rule that pins which
NodeIDScheme byte a peer / proposer / validator / mempool sender
MUST present on this chain.
ChainSecurityProfile.ValidatorSchemeID()reads throughIdentitySchemeID(one byte, one source of truth). ReturnsSigSchemeMLDSA65(0x42) on the canonical strict-PQ profile.ChainSecurityProfile.AcceptsValidatorScheme(presented, classicalCompatUnsafe)is the cross-axis check the node-sidepeer.SchemeGatefunnels every inbound NodeID through. Strict-PQ profiles refuse classical (0x90) regardless of the operator flag (defence-in-depth); permissive profiles honour the flag.ErrValidatorSchemeMismatchis the typed error consumers match against; mirrorsids.ErrNodeIDSchemeMismatchat the wire-decode layer.
The wire bytes are shared with luxfi/ids.NodeIDScheme. A test pins
the alignment in literal form so a renumber in either enum trips CI.
Bag Package
Canonical location: types/bag. All repos should import from here.
Version
Managed via github.com/luxfi/version (re-exported in version/).
Do not hardcode version strings in this repo.
Rules
- ALWAYS use
GOWORK=offfor go commands in this repo - NEVER bump packages above v1.x.x
- NEVER use go-ethereum or ava-labs packages -- use luxfi
- Update THIS file (LLM.md) with significant discoveries
- CLAUDE.md and AGENTS.md are symlinks to LLM.md -- do not commit them
- Show tests passing, do not just claim "done"