verifyCert's count-only branch verified a certificate and checked its declared
quorum against the number the committee derives, and read nothing else against
that committee. The derived clause cannot see a small one, because it shrinks
with it: TwoThirdsCount(2) is 2, so two signatures over a two-member committee
ARE that committee's quorum and the certificate passes as the one this node
would have built itself. What passed was export finality — irreversible, the
rung bridges read — over a committee whose Byzantine budget f=⌊(n−1)/3⌋ is zero,
where every signer is load-bearing and one compromised key is not a fault
absorbed by a margin but a forged certificate.
It did not stop at the node that admitted it, and that is the part that made it
worth a clause. TryAccept hands the accepted cert's bytes to GossipCert, and
applyBranchFinalization files them as the proof served to stragglers on catch-up.
So one admission turned a stake-less node into a second source for a certificate
carrying no fault budget, and every node it convinced became a third. The floor
stops that at each hop, which is why it belongs at the arrival gate and not only
at the accept.
The clause is the one verifyQuasarSupermajority already holds on the WEIGHTED
road, read here against the committee because that is this road's authoritative
view of its set — the same sentinel and the same TwoThirdsCount number, so the
two Go roads refuse the same certificate by the same name.
Both roads are Go's, and that is the whole comparison. No other implementation
has a stake-less accept road for this clause to exist on: Rust's Cert::verify is
the structural predicate its weighted road runs first, never an accept rule on
its own, and C++'s QuorumCertEngine::verify_cert fails closed on an empty stake
model before it reads a vote. Go alone accepts with no stake source, so Go alone
needed this, and it is Go's own weighted road it is brought up to.
The predicate is AuthorizesExport, not an equality on Quasar. The weighted road
is fail-closed by construction — its tier switch ends in ErrQCUnknownTier — and
an equality here would be allow-by-omission: Horizon sits above Quasar on the
ladder, so the day it becomes attestable a Horizon cert would cross this road
with no floor at all. Behaviour is identical today, because Verify refuses any
tier that is not Nova or Quasar before this line; the rung's own predicate makes
tomorrow safe by construction rather than by review.
Nova keeps no such floor and must not grow one: it authorizes only local
execution the chain can still reorg away, and a small chain has to be able to
make local progress. The Nova case pins that.
Not reachable on a stake-wired chain, which takes the weighted road, and not
mintable — quasarAttestor, the one Quasar producer, is wired only when a stake
source is present, so a stake-less chain has no export cert of its own. It had
to ARRIVE. Fail-closed once closed, at no cost to the deployment that has always
run Nova-only.
TestExportCommitteeFloorHoldsOnTheCountOnlyRoad is the regression, on the road
TestExportNeedsAByzantineCommittee could not reach: a stake-less engine whose
committee is exactly n, and a certificate unanimous over it declaring the quorum
it derives, so nothing but the committee can refuse it. It admits at n of one,
two and three without this clause.
Also widens the reject table's G2 identity to its real width. At 95 bytes it
died at the proof-width gate and never reached the decode it exists to test — a
green duplicate of the short-proof row above it. At 96 it is refused where the
frozen corpus records the refusal.
Co-authored-by: Hanzo Dev <[email protected]>
luxfi/math v1.5.1 marks Add64 deprecated in favour of Add[uint64]. The two
are the same function by construction — safe.go declares the deprecated
name as `Add64 = Add[uint64]`, a var holding that exact instantiation — so
this moves no arithmetic: same overflow predicate, same (sum, error), same
refusal at the same boundary. It also drops a call through a package-level
func value, which nothing should be able to reassign.
Instantiated explicitly rather than left to inference. These three are the
overflow-refusal sites for stake: the two weight tallies Register and
FlattenValidatorSet run, and the cert tally votedStake runs. If a weight
field ever narrowed, `Add` would infer the narrower T and quietly start
refusing at a lower ceiling while the caller still believed it was guarding
a uint64; `Add[uint64]` fails at compile time instead. A tally that refuses
at the wrong ceiling is the fault these calls exist to catch.
Co-authored-by: Hanzo Dev <[email protected]>
QuorumCert.Verify returns a different typed error for each clause of the finality
predicate, precisely so one refusal can be told from another. Fourteen are
declared. Four were ever named by a test — the two the R5 floors turn on, plus
ordering and a bad signature — and the other ten had nothing asserted on them at
all: nil cert, nil verifier, version, role, unknown tier, zero threshold, no
votes, a non-accept vote, the nova stake floor. Every one of those clauses would
have passed with its body replaced by `return errors.New("no")`, which is the
shape of test that measures nothing.
So every clause now has a row: a certificate proven valid by the control above
it, one field changed, and the error that clause owes. Wrong version, wrong role,
a tier that is neither rung, a threshold of zero, no votes, a duplicate voter,
voters out of order, a reject inside a finality cert, a signature over another
position, a voter nothing can resolve, a quorum larger than the votes carried —
and on the weighted side, an unresolved set, a set below the minimum Byzantine
committee, signable stake of zero, the seats without the stake and the stake
without the seats. The same table exists in Rust and in C++; the three are meant
to be read side by side, because three implementations refusing one certificate
for three reasons is a fork waiting for the cert they disagree about.
The fifteenth name, ErrQCVotePosition, is returned by nothing. The var block's
comment says each maps 1:1 onto a clause; that one maps onto no clause, because a
vote that signed another position fails as a bad signature — the message is
rebuilt from the CERT's position and never from the vote's. It is left alone here
and reported rather than covered, since no input reaches it.
The proof-of-possession rejects follow the frozen corpus and go past it: the
class is asserted, not merely that something failed. Encoding before possession
is what keeps a pairing off a point that was never a point, and a test that only
checked for non-nil would pass with the order reversed. The corpus has no
49-byte-key case; this side carries one, because the clause is `!=` and a
verifier guarding only the short side would check a proof against a PREFIX of the
published key.
Elsewhere: the parameter doors (every clause, and zero still meaning unset),
the corpus's own stake seam (three projections over the signers, the fourth over
the roll), the two arithmetic doors that must not wrap, the warp filter/sum/
aggregate read as the step a forged message would attack, the vote frame's
refusals including a four-gigabyte signature that is not there, and the durable
guard: what survives a restart, what a floor that could not be written must not
report, and every shape of file that is present and does not decode.
engine/chain 87.2 → 89.7 · validator 84.7 → 98.3 · pop 85.3 → 91.2
config 99.0 → 100 · conformance 93.8 → 97.4
Of the named core, cert/finality/quorum/quasar/descent/guard/verified_cert are
whole; what remains there is four statements no caller can reach, written down in
LLM.md so the next reader does not spend an afternoon each on them. The rest of
the gap is engine.go and the VM/network integration surface, which wants
multi-node harnesses rather than unit tests.
Tests only — no consensus logic moved.
Co-authored-by: Hanzo Dev <[email protected]>
The Quasar rung read only stake. Two thirds of the stake is one signature
wherever two thirds of the stake is one validator, so a whale holding a
supermajority could mint an export-grade certificate alone — and export-grade
finality is what a bridge, a settlement and a cross-chain message admit on. A
certificate one key can produce is not a claim that a Byzantine supermajority
agreed, however much weight stands behind the key.
VerifyWeighted now demands config.TwoThirdsCount(n) = floor(2n/3)+1 DISTINCT
signers of a Quasar certificate as well as the stake. Two independent
predicates, neither sufficient alone, the same shape Nova has one rung up.
TwoThirdsCount is the count that value has always been, given its own name and
one home. It is derived from TwoThirdsStakeFloor over unit weights rather than
restated, so the count and the stake predicate cannot drift. It replaces three
spellings of the same closed form that had grown apart in reach if not yet in
value: config.EqualStakeSupermajorityThreshold (the alpha sizer), the private
chain.quasarQuorum (the attestor's assembly trigger), and the clause that was
missing from the verifier entirely.
Stake is read first and the count second. Both must hold, so the order names
only which clause a refusal reports: on an equal-stake set the two are one bar
in two units and bind together, and the count clause binds ALONE only where the
weights are lopsided — so reaching it means the stake was there and the signers
were not. Every verdict the R3 corpus froze is unchanged.
Liveness. Nothing that exports today stops exporting. QuasarAttestor, the only
producer of export certificates, already assembled at exactly floor(2n/3)+1
attestations, and FeasibleParams already sized alpha — the floor
HandleIncomingCert applies to a gossiped Quasar cert — to the same number.
Producer, gossip door and verifier now read one function, so the verifier's new
clause refuses nothing the engine can build and exactly what it never could.
quasar_count_floor_test.go holds all three to that.
The tests that moved were the ones written on three seats, where floor(2n/3)+1
is three and no two-signer certificate can isolate the stake half any more.
They ask the same question on four.
Rust: the same clause in Cert::verify_quasar_supermajority, the same name.
1.22.4 -> 1.22.5 — this changes what the crate accepts.
Also, at the already-admitted door: the zero-weight refusal is justified by
agreement with Register, not by the phantom-signer argument alone. A seat whose
key does not decode can never sign and is refused anyway, because the weight is
read before the key is — and deferring it would make admission depend on which
keys a node's crypto build can parse.
Co-authored-by: Hanzo Dev <[email protected]>
Register refuses a validator admitted with no stake, because a keyed seat
carrying zero weight is a phantom signer: it raises the count of distinct
signers a floor is read against and contributes nothing to the stake the same
certificate is weighed by. FlattenValidatorSet admitted it, so the clause the
two doors are supposed to share meant one thing at registration and another on
a set the chain had already admitted.
The corpus said so before the code did. AdmissionCase already records that "the
weight clauses are common to both" doors, and the transcribed rule in
verdict_test.go already demanded a zeroWeight refusal for any case carrying a
weightless seat, at either door — there was simply no case exercising this one.
There is now, produced by the live function, and it fails against the old door
on the golden AND on the transcribed rule independently.
Refused, not skipped, and that is not the trade this door makes for an
undecodable key. Skipping a bad key costs a signer the set was going to lose
anyway; admitting a weightless keyed seat is a signer the set gains for free.
A KEYLESS seat with no stake is untouched: it is skipped before the weight
clause, never becomes a signer, and neither signs nor weighs — the leniency this
door keeps, and the one Rust's insert_unkeyed keeps for the same reason.
Placed where Register places it and where Rust's ValidatorSet::insert places it:
after the key is known to be present, before it is decoded. A shaped key is
therefore enough to reach the clause on either side, which is what lets the Rust
runner answer this row instead of skipping it as it must skip the other two at
this door.
Co-authored-by: Hanzo Dev <[email protected]>
Red review of the oracle found the bytes unbreakable and the set-plane rule
around them incomplete. This closes it.
HIGH — one node, one key. Register enforced one-key-one-node but not the
converse: a slice could name one node twice under two keys, each with a genuine
proof, so possession did not catch it and one operator held several signer
indices and several shares of the weight under one identity. FlattenValidatorSet's
map input made that unrepresentable; a slice does not. A byNode set beside byKey
refuses it (ErrDuplicateNode), in the same pass, so the set is a set on both axes
— the rule weighted_merkle already states and registration.go had half of.
HIGH — one canonical order, on every build. PublicKeyBytes, the sort key, was the
UNCOMPRESSED encoding, which the crypto library returns as 96 bytes under blst
and 48 under CIRCL — so the same set sorted two ways, and two nodes on different
builds indexed the signer bits differently. It is the COMPRESSED encoding now, in
both Register and the local FlattenValidatorSet, so the order is the same on every
build. Under the canonical (purego) image this still matches upstream; under cgo
it is now deterministic where upstream is not. The crypto root cause is upstream
and live on the warp path — escalated separately, not fixed here.
Also: key uniqueness now keys on the compressed bytes decoded from the point, not
the caller's input buffer read three times (a caller could mutate it between the
verify and the map insert); a zero-weight validator is refused (a signer that
raises the count but not the weight, the phantom BuildWeightedValidatorSet already
refuses); cert-store eviction drops the file the evicted slot names, not whatever
the map points at, and unmaps a decision only if the map still names the removed
file; and a canonicalCommitter test block exercises the cert-store re-key in the
regime where the inner decision differs from the outer envelope — the case every
prior test degraded past.
Node width is 20 bytes, frozen in vectors/pop.json. Nothing here runs on mainnet:
node pins a consensus version without these files, and Register has no non-test
callers. A validator set that shares a key across node ids is now refused — the
intended behaviour, and the seam with the P-chain step still to come.
Co-authored-by: Hanzo Dev <[email protected]>
The forged certificate that reached crates.io was killed at verification, but a
red review showed the hole had only moved to registration: a key was admitted on
key_validate alone, which proves a point is on the curve and nothing about who
holds its secret, and duplicate keys were merged into one validator carrying many
node ids — so a floor written to require many distinct signers was cleared by one
holder registering one key under several ids.
This is the Go oracle for the standard ruled in luxfi/conformance: it defines the
bytes Rust and C++ reproduce.
validator/pop is the primitive. A registrant signs its own identity followed by
its own key — node(20, ids.NodeID) ‖ pubkey(48, compressed G1), 68 bytes, no
separator — under the proof-of-possession domain BLS_POP_..._POP_, distinct from
the vote's ..._NUL_ so a vote is never a proof and a proof never a vote. The node
is bound because the pubkey-only IETF proof travels with the key: bound to
nothing, it re-registers under any identity. 20 bytes because a Lux validator's
identity IS its NodeID; the 32-byte consensus Id names blocks, not nodes, and has
no canonical inverse, so widening would invent a second name for one node.
validator/registration is the admission rule, written once: encoding, then
possession, then uniqueness, then weight — a pairing on non-points is undefined,
and weight counted before uniqueness is weight counted twice. A set is admitted
whole or refused whole, because a set that silently drops a signer no longer
describes its own weight. FlattenValidatorSet's merge of duplicate keys is retired
here: it was the only thing that made many-nodes-one-key representable, and after
the uniqueness rule that state does not exist to normalize. Order and
PublicKeyBytes still match the upstream flatten exactly, so a proven set and a
flattened-distinct set are the same set.
engine/chain keys the cert store on the DECISION — the signed canonical identity
— not the transport envelope id, which no signature covers: two envelopes of one
decision were two store entries for one finality fact, and a verified cert could
be filed under a block it never attested. Keyed by decision, a cert is found by
anything that finalized that decision, and Put is idempotent per decision.
The pubkey-only P-chain proof (luxfi/node AddValidatorTx) is NOT touched here:
moving it node-bound is a network upgrade, every validator re-proves, and it is
activation-gated as the deliberate last step. Anywhere a validator set that
shares a key across node ids is admitted today, this now refuses it — that is the
intended behaviour, and the coordination point with the P-chain step.
Co-authored-by: Hanzo Dev <[email protected]>
- Remove unused packages: engine/dag/{getter,bootstrap,vertex/vertexmock},
engine/pq/{bootstrap,block}, core/{tracker,verify}
- Add sync.RWMutex to protocol/wave for thread-safe map access
- Increase engine/dag test coverage from 11.7% to 98.3%
- Add comprehensive tests for DAG consensus, vertex, and engine
- All 27 packages pass tests, 16 at 100% coverage
Add CoronaPubKey field to GetValidatorOutput and WarpValidator
structs to support hybrid BLS+Corona quantum-safe signatures.
Part of the quantum-safe Warp messaging upgrade.
BREAKING CHANGE: Package paths have been reorganized for clarity
## Major Changes
### Package Structure
- Collapsed engine/core → engine (removed unnecessary nesting)
- Moved core/types → types (top-level types package)
- Added root facade for single-import experience
- Removed redundant factory.go
### SDK Improvements
All SDKs now provide single-import interfaces:
- Go: import "github.com/luxfi/consensus"
- Python: import lux_consensus as consensus
- Rust: use lux_consensus::*
### Documentation
- Added comprehensive introduction and architecture docs
- Created AI/ML integration guide for researchers
- Added research section with papers and experiments
- Improved sidebar organization and discoverability
### Benefits
- Shallow, obvious import paths
- One responsibility per package
- DRY, composable, orthogonal design
- Idiomatic patterns for each language
- Consistent API across all SDKs
This refactor provides a clean, intuitive developer experience where
one import is enough and things work one way only.
Rationale: Bag is a fundamental data type, not a utility
Changes:
- Moved utils/bag/ → types/bag/
- Updated imports in error_propagation_test.go
- Updated imports in network_test.go
- Updated package documentation
Why types/ over utils/:
- Bag is a multiset data structure (fundamental type)
- SetThreshold/Threshold methods are type-level features
- More semantic: types contain data structures, utils contain helpers
Canonical import: github.com/luxfi/consensus/types/bag
All tests passing:
- ✅ Chain engine: 28 tests (0.438s)
- ✅ Integration: all tests (59s)
Next: Node should kill utils/bag and import consensus/types/bag
Add GetWarpValidatorSets() and GetWarpValidatorSet() methods to the
validators.State interface for accessing BLS-enabled validator sets
required for Warp message signing.
Changes:
- Add WarpValidator and WarpSet types to represent Warp-capable validators
- Add GetWarpValidatorSets(ctx, heights, netIDs) for batch lookups
- Add GetWarpValidatorSet(ctx, height, netID) for single lookups
- Regenerate mocks with new interface methods
- Add comprehensive test coverage for Warp accessor methods
- Update mockState in validators_test.go to implement new methods
All tests passing: 5/5 new tests, all existing tests still pass.
- doc.go: Convert spaces to tabs for proper Go formatting
- validator/validatorstest/test.go: Add validators import alias
- engine/chain/chaintest/test_blocks.go: Add consensustest import alias
- engine/chain/block/blocktest/test.go: Add consensustest import alias
These formatting fixes resolve all lint errors from golangci-lint.
- Removed wavefpc references from Makefile (moved to protocol/wave)
- Fixed duplicate Engine interface by renaming to SimpleEngine in message.go
- Consolidated validator and validators directories into single validators package
- Removed empty util directory
- Fixed package declarations to match directory names
- Updated all imports from validator to validators
This completes the consensus repository reorganization with:
- All protocols in /protocol/
- All engines in /engine/
- No duplicate directories or packages
- Consistent package naming
- Replace all snowball/snowflake references with photon/pulse/wave terminology
- Update test function names to use new nomenclature (TestPhotonBinary, TestWaveMultiChoice, etc.)
- Fix TestParameters Beta value from 1 to 2 to prevent immediate finalization
- Fix wave preference update logic to properly reset non-voted choices
- Make photon Add() method idempotent to allow re-adding same choice
- Rename test files: binary_snowball_test.go → photon_binary_test.go, etc.
- Update struct field names: snowflakePreference → wavePreference/pulsePreference
- Change Nnary to MultiChoice throughout the codebase
- Update comments to remove avalanche-specific terminology
Most tests now pass with the new terminology. Some test failures remain in
the main consensus package that need further investigation, but core protocol
implementations (photon, pulse, wave, prism, quasar) are working correctly.