Commit Graph
966 Commits
Author SHA1 Message Date
zeekayandHanzo Dev d047dc7311 notifier: answer an unset logger where the loop starts, not where it logs
forwardNotification logs on its first statement and runs on the goroutine Start
spawns, so a NotificationForwarder holding no logger dies there — and a panic on
that goroutine takes the process with it, since nothing above it can recover.

The logger can go unset two ways: NewNotificationForwarder stores whatever it is
handed, and Log is exported, so a struct literal can simply omit it. Start is the
one place the loop begins and the only route to run(), so answering it there
covers both ways at once, which defaulting in the constructor alone would not.

This is the same answer bootstrap.New, summary.New and the engine's NewWithConfig
already give; the forwarder was the remaining exported constructor without it.

Co-authored-by: Hanzo Dev <[email protected]>
v1.36.87
2026-09-01 22:40:08 -07:00
zeekayandHanzo Dev d408ae3e1d runtime: answer an unset logger once, so no handler can be crashed by its absence
NewRuntime stored the caller's Logger as given, so a Runtime built without one
held a nil interface. Twenty-four sites in engine/chain read that logger with a
bare IsZero(); fourteen others ask `!= nil &&` first. Two of the twenty-four are
the first line of HandleIncomingVote and HandleIncomingCert, which is to say a
remote input reached a nil interface method call before anything validated it.

The engine already settles this for itself — NewWithConfig falls back to
log.Noop() when no logger is set — so the runtime constructor now does the same
thing in the same way. Runtime.config is unexported and written once, at
construction, so past NewRuntime a logger is always present and all twenty-four
sites are safe by construction rather than by twenty-four remembered guards.

Noop discards what it is given and reports IsZero, so an unset logger stays
exactly as silent as it was; nothing else changes.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 22:37:31 -07:00
zeekayandHanzo Dev 43c74b012d go: name every refusal, then check that the named one is the one given
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]>
2026-09-01 22:19:35 -07:00
zeekayandHanzo Dev 19a120e632 rust: ask what a refusal says, not only that there was one
The crate measured 74.95% of regions, and the shape of the gap was the point:
lib.rs — the engine, the rounds, the sampler, the identity types — sat at 67%
with 52 of its 123 functions never called by a test at all. A door nothing opens
is a door nobody has checked is a door.

cert.rs already broke one valid certificate at a time and asked whether it still
passed. The new tests ask the other half: when the gate refuses, does the refusal
name the clause that actually failed and carry the numbers to act on? A gate that
refuses everything for one reason is as useless as one that accepts everything,
and the copy-pasted match arm is invisible to a test that only checks for Err.

What the rest found, stated as properties rather than counts: an engine with no
validators counted nobody and let a block through; asking the sampler for a
threshold advanced the FPC phase, so two nodes that asked a different number of
times drew different committees; a validator that shouted its ballot contributed
one vote to the evidence and not many. Each of those is a fork, and each was
reachable through the public API of a crate that read as tested.

99.19% of regions, 99.42% of functions, 167 tests. The remaining six lines are
arms the type system or an earlier clause already excludes — a tier the verifier
refused before the weighted switch sees it, a state lookup its only caller just
created, an Undecided arm of a match reached only when a decision was made.

Two things were left uncovered rather than padded: whether two adjacent block ids
differ (a fact about the host clock, so the test would pass or fail by machine)
and version() == CARGO_PKG_VERSION (tautological). The semver shape is asserted
instead, which catches the plausible mistake.

Tests only — src/ is untouched, and the crate's API has not moved.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 22:01:03 -07:00
zeekayandHanzo Dev 6f22451eab merge: R5 — quorum floors over signer stake + an absolute export committee floor
The quorum stake/count floors read SIGNER (keyed) stake, not carried — a keyless
member is a known non-participant, so counting it in the denominator strands
export once keyless weight >=1/3 (an adversary-reachable L1 liveness attack via
deactivation). Byzantine basis becomes 1/3-of-signer; quorum intersection
preserved. HIGH-1: the export rung refuses SignerCount < minBFTCommittee(4) so a
shrunk denominator cannot shrink past a Byzantine committee (f=0 below 4 carries
no guarantee). Corpus +2 rows isolating stake vs count; one deliberate flip
(quasar_keyless_third n=3 -> refuse). C++ ctor now key-validates. Red GO.
DEPLOY GATE: a live chain with <=3 keyed signers stops certifying Quasar on upgrade.

Co-authored-by: Hanzo Dev <[email protected]>
v1.36.86
2026-09-01 21:21:17 -07:00
zeekayandHanzo Dev ac10e53f84 cert: give the export rung a floor on the SET, so a shrinking denominator cannot shrink past a committee
Reading both floors over the signers let 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 signature over a one-signer set clears the count floor and the stake floor
together and mints export finality. Byzantine tolerance is f = ⌊(n−1)/3⌋, which is
ZERO for n of one, two and three: such a certificate absorbs no fault at all, and
one compromised key among its signers forges it 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, and reused rather than restated. It is a floor on the SET, stated
beside the n<1 refusal because neither quorum floor implies it: both are read over
n and both shrink with it. Nova has no such clause and must not grow one — it
authorizes local execution the chain can still reorg away, so a small or
partitioned chain has to be able to make progress. A three-validator chain now
PRODUCES and never CERTIFIES; operators need n ≥ 4 to export with any fault
tolerance. Rust mirrors it as CertError::MinCommittee, which the corpus vocabulary
folds into Go's one belowThreshold class.

The transcription guard was reading the wrong denominator. verdict_test computed
n as len(c.Set), the membership ROLL, so a floor moved onto the roll would agree
with the guard and be re-blessed by -update — the second line of defence agreeing
with the regression it exists to catch. It now counts the seats that hold a key,
on the Quasar branch and the Nova one, and transcribes the committee floor as a
search for the least n whose fault budget reaches one rather than as the constant.
Proven by construction: with the corpus source reporting the roll as its signer
count and the golden re-blessed, the old guard passes and the new one fails.

The corpus could not have caught it either. quasar_keyless_third had three signers
on a roll of four, and ⌊2·3/3⌋+1 and ⌊2·4/3⌋+1 are both three, so its count half
was vacuous. Two rows now separate the denominators one at a time:
quasar_keyless_stake (six signers, a seventh member holding a third of the roll —
the count floor is five either way, so only stake decides) and
quasar_keyless_count (four signers, two members holding one unit each — the stake
clears under either reading, so only the count decides). TestKeylessRowsBind-
BothDenominators fails if either is lost or flattened.

quasar_keyless_third itself FLIPS to a refusal, and it is the row that states the
new clause: the recorded stake floor is 200 over the three hundred that can sign
and not 333 over the five hundred the chain carries, so the denominator IS the
signers — and the certificate is refused anyway, on neither quorum floor. Reading
the floors over the signers stops a spectator stranding a chain; it is not a way
for a chain with three signers to export. Every other frozen verdict is
byte-identical, and the admission section and the signed vectors are untouched.

StakeSource gains CarriedStake, and no floor may read it. SignerStake alone
decides a certificate correctly and cannot show the set shrinking underneath one:
a chain whose signable stake has fallen to a sliver of what it carries still
certifies at ⅔ of the sliver, and ResponsiveStakePct — voted over signer — stays
at one the whole way down. FinalityStatus.SignerStakePct is the number that falls,
and the one to alarm on. Rust has carried(), C++ has total_stake(); Go had lost
the projection when the denominator moved.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 20:52:28 -07:00
zeekayandHanzo Dev f4913a3fd7 cert: read both floors over the signers, so stake that cannot vote cannot strand export
A validator set may carry a member with no signing key. It holds stake and no
verifier will ever accept a signature from it — it is a spectator, not a party to
the vote. Both quorum floors were nonetheless read over the whole membership:
StakeSource.TotalStake summed its weight and ValidatorCount counted its seat.

That is a category error with an arithmetic consequence. Once keyless stake
reaches a third of what the chain carries, floor(2/3 * carried) is above the
whole of the signable stake, so the export rung cannot be reached even with every
signer in the set agreeing — permanently, with no misbehaviour anywhere. Past a
half, Nova goes the same way. The count floor strands the same way in seats: two
thirds of the roll can exceed the number of signatures the set is able to produce.

So the seam says what it means. TotalStake becomes SignerStake and ValidatorCount
becomes SignerCount, both read over the validators that can actually sign, and
Weight answers 0 for a keyless member as it already did for a stranger. The
rename is deliberate rather than a doc change: an implementer of the interface
has to confront the question, instead of silently keeping a denominator that
strands its own chain.

This is the two-thirds rule read over the right set, not a weakening of it.
Quorum intersection is untouched — two disjoint quorums each past 2/3 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. A key absent at the
epoch is a known non-participant; BFT's third is a budget for the unknown ones.

Rust follows: ValidatorSet is both the verifier and the source, and its
insert_unkeyed door made this reachable through a public API. It now keeps
`signable` beside `total` — total still bounds admission, because a set whose
weights cannot be represented is refused whether or not the seat that overflows
it can sign, and signable is what a quorum is measured against. carried() names
the first for what it is.

C++ needed no change and is the reason to believe the shape: a Validator there IS
a public key and the engine's set is keyed by one, so a spectator has never had a
slot to occupy and total_stake_ was always the signer stake.

Corpus: quasar_keyless_third, the case the R5 TODO could not state. Three seats
of a hundred hold keys, a fourth holds two hundred and none; all three signers
sign and the export rung admits it on 300 of 300, where the roll denominator
would refuse it against floor(2*500/3)=333 forever. Every existing verdict is
byte-identical — the seat gains an omitempty keyless flag, so a set of ordinary
seats encodes exactly as before.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 19:07:15 -07:00
zeekayandHanzo Dev d77a988fed merge: R4 — the export rung's distinct-signer floor (Go + Rust)
Quasar now carries a stake floor AND a distinct-signer floor, both derived
from the validator set live at the cert's epoch, and neither half is
sufficient alone. config.EqualStakeSupermajorityThreshold becomes
config.TwoThirdsCount — one definition of the count, derived from
TwoThirdsStakeFloor over unit weights so the count and the stake predicate
cannot drift.

Two thirds of the stake is one signature wherever two thirds of the stake is
one validator, and a certificate one key can mint is not a claim that a
Byzantine supermajority agreed.

Rust crate source moves to 1.22.5. Publishing is a separate, deliberate step.

Co-authored-by: Hanzo Dev <[email protected]>
v1.36.85
2026-09-01 18:25:19 -07:00
zeekayandHanzo Dev 9f2d755bb0 cert: the door is sized from K, the verifier from n — say which
TestQuasarCountFloor_SizerMatchesVerifier and LLM.md both claimed the
producer, the gossip door and the verifier ask for one count. What the
test holds is narrower and is the thing worth holding: the door's alpha
is TwoThirdsCount(p.K), so the door is sized by the function the verifier
enforces and neither is a second definition of the count.

The two are one number whenever the params were sized to the set that is
running, which is what FeasibleParams does. They part when the set has
moved since, and the new test states which way that cuts: TwoThirdsCount
does not decrease, so a set that GREW leaves the door the looser bar and
the predicate still decides — this clause cannot halt an export the door
admitted. A set that SHRANK leaves the door the stricter bar, which is
the runtime's gossip-admission bound and reads the same before and after
the clause.

Documentation and one test. No floor logic is touched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 18:22:29 -07:00
zeekayandHanzo Dev 82ab92a70f cert: give the export rung a distinct-signer floor, so two thirds means two thirds of the parties
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]>
2026-09-01 17:39:39 -07:00
zeekayandHanzo Dev 2c79ab76a9 merge: Go tally checked-add + Flatten zero-weight refusal (hardening)
votedStake() routes both weighted-tally loops through math.Add64 -> ErrWeightOverflow
(parity with Rust's checked voted_stake and C++'s construction guard; closes a
false-ACCEPT on a wrapping StakeSource). FlattenValidatorSet now hard-refuses a
keyed zero-weight seat (ErrZeroWeight), matching Register and Rust's insert;
keyless-zero stays tolerated. Corpus freezes the Flatten-door zero-weight case.
Red GO. No production caller of the changed Flatten function today.

Co-authored-by: Hanzo Dev <[email protected]>
v1.36.84
2026-09-01 16:57:10 -07:00
zeekayandHanzo Dev 3a06f4888d validator: refuse a keyed seat with no stake at the second door
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]>
2026-09-01 16:32:21 -07:00
zeekayandHanzo Dev c220da75b1 cert: sum the voted stake with a checked add
Both rungs decided by comparing a summed stake against a floor read off the
total, and both summed with a bare +. Go's + is modular, so a StakeSource
reporting weights past 2^64 did not fail the tally — it returned a different
number and the comparison proceeded. The wrapped value can land above the
floor: three voters at 2^63 wrap to 2^63, which clears floor(total/2) for a
total of 2^64-1 by one, and the certificate was accepted on arithmetic rather
than on votes. At the Quasar rung, which carries no signer floor, the tally was
the only thing between such a cert and export-grade finality.

Rust has held this clause all along (Cert::voted_stake, checked_add ->
WeightOverflow). This is the Go side of the same rule, written once as
votedStake rather than twice at the two call sites, and returning the
ErrWeightOverflow both admission doors already return.

Not remotely exploitable: Verify's strictly-increasing-signer clause refuses a
duplicated voter before the tally, and Register and FlattenValidatorSet both
refuse a set whose weights overflow, so no admitted set can reach it. Reaching
it is evidence about the source, not about the votes.

The tests state the wrap outright — the value the old loop produced and the
floor it cleared — before asserting the refusal, so they fail against the
unchecked loop rather than passing against anything that refuses everything.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 16:31:49 -07:00
zeekayandHanzo Dev 21a99d9189 merge: R3 weighted-verdict conformance corpus (Go oracle + Rust runner)
Freeze the finality DECISION (accept/reject on quorum+stake), not only wire
encoding, so a runner missing the weighted half can no longer report PASS. 8
verdicts produced by the real path (VerifyWeighted/Register/FlattenValidatorSet)
and self-checked, both Nova (20/21) and Quasar (27/28) edges on one 41-seat set.
All three languages agree. Red GO. R4/R5 deferred as TODO (not frozen).

Co-authored-by: Hanzo Dev <[email protected]>
v1.36.83
2026-09-01 16:09:32 -07:00
zeekay 38367a87f1 every Hanzo dependency is the version npm serves
Ranges drift once nothing moves them: a caret that has not been rewritten
since it was written pins whatever was current that day, and an exact pin
cannot even take a patch. Both are updated here.

One line per package, so a surface reads the same components, the same
tokens and the same face as every other one.
2026-09-01 15:54:34 -07:00
zeekayandHanzo Dev 2fab570b4b conformance: freeze what the predicate DECIDES, not only what it encodes
The corpus stated what a validator signs and what a certificate looks like
on the wire. That is necessary and it is not sufficient: an implementation
can reproduce every byte and still finalize on the wrong number of signers,
because encoding and deciding are different questions. A build carrying no
weighted predicate at all passed a corpus that only ever asked it to encode.

So the corpus now asks the other question. Each case states a validator set,
the distinct signers a certificate carries and the rung it attests, and
records what VerifyWeighted decided about it — read from the live predicate,
never restated, the same way the proof-of-possession vector records what
pop.Verify returns.

Both edges of a forty-one seat set are pinned, seven seats apart, so an
implementation that confuses the majority floor for the supermajority floor
cannot pass by accident. The lone holder of a stake majority is pinned
refusing on the COUNT: stake cannot buy a pass past the signer floor, which
is what stops a single holder self-igniting.

Signatures are not the subject. Every vote is resolved as correctly signed,
so a case can only turn on the weighted half. Braiding the two would mean a
weighted rule could not be stated without a key ceremony, and a runner that
lacked one would report PASS by skipping the case — the failure this closes.

The golden alone would let a moved threshold be re-blessed with -update, so
the boundaries are also stated outright and recomputed in arbitrary
precision: 2*voted > total, and 3*voted > 2*total. Neither can share an
off-by-one with the floors it checks.

The export rung's own signer floor (R4) and the keyless denominator (R5) are
left as open decisions rather than frozen expectations, marked where the next
cut extends this section.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 13:18:51 -07:00
zeekay 7ca0f79686 Pin past the versions the proxy can no longer serve
The bulk retag moved published tags. go.sum and sum.golang.org hold the
original bytes, proxy.golang.org answers not-found, and the direct fetch
disagrees, so builds stopped at a checksum before reaching a compiler.
Pin forward to versions the proxy still serves.
2026-09-01 13:10:20 -07:00
zeekayandHanzo Dev 10f2997953 merge: close the α=0 accept-on-zero-votes P0 (engine parameter validation)
ProcessVote accepted a block on a single REJECT vote at α=0 (acceptVotes=0,
flagged accepted∧rejected). Parameters now reject α<1 and enforce the BFT
overlap floor unconditionally in ValidQuorum (called by Valid and by
Transitive.Start, the non-bypassable seam), with a structural floor at
NewChainConsensus and the BFT floor at Reclamp. params5 corrected K=5/α=3→α=4
(production already ran α=4 via FeasibleParams; α=3 lived only in a test helper).

Red re-review: GO, P0 closed, mechanism correction confirmed.

Co-authored-by: Hanzo Dev <[email protected]>
v1.36.82
2026-09-01 12:29:12 -07:00
zeekay b7ceb5fec8 Pin past the versions the proxy can no longer serve
The bulk retag moved published tags. go.sum and sum.golang.org hold the
original bytes, proxy.golang.org answers not-found, and the direct fetch
disagrees, so builds stopped at a checksum before reaching a compiler.
Pin forward to versions the proxy still serves.
2026-09-01 12:27:18 -07:00
zeekayandHanzo Dev bba3234534 consensus: a quorum of zero is not a quorum
The chain engine decides on `acceptVotes() >= alpha`, so alpha=0 makes that
predicate hold over an EMPTY accept set. Any arriving vote — including one
AGAINST the block — then marks it accepted with zero accept votes, and Poll's
`rejectVotes() >= alpha` fires on nothing too, rejecting every fresh block and
dropping it from the tips. Two partitioned nodes could settle different blocks
at the same height with no quorum and no cert.

alpha=0 was reachable because Config.Validate had no callers — New,
NewWithConfig, NewWithParams and NewRuntime all reached NewChainConsensus
unchecked — and because Parameters.Valid gated its alpha checks behind
`AlphaPreference != 0`. The one value that destroys safety was the one value
that skipped validation.

Three independent layers close it, each judged by ONE definition of a safe
quorum:

  - config.Parameters.ValidQuorum, split out of Valid, judges K and the two
    integer alphas: 1 <= alpha <= K (unconditional now), AlphaConfidence >=
    AlphaPreference (never enforced before, so preference=15/confidence=1
    passed), and the Byzantine overlap floor, also unconditional. Valid calls
    it, so the two cannot drift. The split lets the engine demand SAFETY
    without also demanding the descriptive Alpha ratio and block timings,
    which have no bearing on whether a quorum can fork.

  - Transitive.Start refuses to run unsafe parameters. The check belongs here,
    not in the constructors: options are applied AFTER config, so WithParams
    can replace params a constructor already validated. Start is the one point
    every engine passes through with its final parameters.

  - NewChainConsensus, the single place the running predicate is armed, pins
    (k, alpha) into k >= 1, 1 <= alpha <= k. A path that skips both checks
    still cannot arm a predicate satisfiable by nothing.

Reclamp gains the safety floor as well. It installs a quorum on a LIVE engine
from a DERIVED validator count, and used to accept any well-formed alpha —
including k=4/alpha=2, where two disjoint quorums overlap in nothing. The
supermajority the committee sizer derives clears the floor at every k, so no
legitimate re-clamp is refused.

Fixtures that encoded unsafe quorums are corrected rather than exempted.
params5 was K=5/alpha=3: two 3-of-5 quorums overlap in a SINGLE node, so one
faulty validator (f=1 at K=5) can certify both sides of a fork. It is now
K=5/alpha=4 — the Byzantine floor, and what FeasibleParams(Local, 5) derives
for the live nets — which makes params5Prod an exact duplicate, so it goes.
Two config cases declared K and Beta with no integer alpha at all and expected
Valid to pass; that is a zero-vote quorum, not an unspecified one. Catch-up
certs carrying four signatures now declare the 4-of-5 threshold they are, and
the forged-signature case declares a real threshold so it still fails on the
forgery rather than on a stale bound.

The single-validator path is untouched: K=1/alpha=1 satisfies the floor, needs
no vote verifier, and bftCommittee still leaves a genuine K=1 preset unclamped.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 12:01:23 -07:00
zeekayandHanzo Dev c22f13f63e cert: the weight clause Go runs last, the set admitted whole, and one workspace
THE TOTAL. `Register` in `validator/registration.go` runs seven clauses, and
this port had six. The seventh is `math.Add64` on the running weight: a set
whose weights sum past what a `uint64` holds is refused entire, before any of
it is admitted. Without it `total_stake` folded with `saturating_add` and
`voted_stake` summed with `saturating_add`, so a set of four validators at
2^63 reported `u64::MAX` staked AND `u64::MAX` voted — and
`u64::MAX > floor(2·u64::MAX/3)`. A certificate carrying HALF the stake
cleared the two-thirds export floor, which is the rung a bridge reads.

Clamping is what makes the two sides agree on a number neither can represent,
so neither clamps any more. The set carries its total as a fact established at
admission with `checked_add` — one source of truth, decided at the one place
that still has a registration to refuse — and `voted_stake` is checked too:
the voters are a subset of the members, so against a set this crate admitted
the overflow is unreachable, and reaching it is evidence about the stake
source rather than about the votes. `weighted_quasar` takes the same
treatment: a vector with no representable total leaves by the same door as a
zero total, since a plain sum WRAPS to a small total and sizes the count gate
below the stake quorum it exists to anticipate.

THE SET. `insert` admits one validator and reports on that one, which is a
property no set can be built out of: a caller that inserts in a loop and logs
what failed is left holding a set missing a member, with `n` short by one and
the total short by that member's stake — the two numbers every threshold is
read against, wrong, with nothing to say so. `ValidatorSet::register` is the
port of Go's whole-set door: sorted by node id so which duplicate a set is
refused on cannot depend on the order a caller built its input, and
all-or-nothing because the set under construction is a local. `insert` stays
for a membership change to a set already live, and now says so.

THE BINDING. `pkg/typescript` path-depends on `pkg/rust` and could not
compile: narrowing the node id from 32 bytes to 20 left two `NodeID::from([u8;
32])` calls with no impl. It is a 20-byte API now, and the 20 bytes come from
20-byte input — `arr[..20]` would reintroduce exactly the collision the
narrowing exists to prevent, so a hex string of the wrong length is refused,
never trimmed. The width is `size_of::<cert::NodeId>()`, taken from the core
rather than restated, so it cannot drift again. `addValidator` also stopped
discarding the core's answer, which reported a validator as added when the
set had refused it.

And the two crates are one workspace, because that is what they already were.
Built separately they can disagree and did — nothing built both, so nothing
noticed. `cargo test` at the root now builds both, which turns this class of
break from a review finding into a compile error. It also surfaced two tests
in the binding that had been failing unnoticed: they voted with unregistered
ids, from before only members were counted.

No signed byte moves. `canonical_vote_message` is untouched, the node id
enters no signed message, and the Go corpus and the frozen `pop` vectors pass
unchanged.

Verified: 97 tests green (92 rust, 5 napi), clippy clean at the workspace
root, the binding's 29 JS tests green against a linux build of it. Red's PoC
— four validators of 2^63, two voting, read at QUASAR — returned `Ok(())`
before and now dies at registration with `WeightOverflow`.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 11:53:54 -07:00
zeekayandHanzo Dev ab41ba6b1c rust: the eligibility rule at registration, on the identity the proof binds
`ValidatorSet` admitted a key on a proof of possession and refused a key
already held by another node, and stopped there. Three things the Go rule
turns on were missing, and this closes them by naming the same clauses in
the same order as `validator/registration.go`:

  NO KEY       a registration with no key wanted the unkeyed door
  ZERO WEIGHT  a keyed signer with no stake is a phantom signer — it raises
               the count a floor is read against without raising the weight
  ENCODING     the key is a canonical compressed BLS12-381 G1 point
  POSSESSION   a node-bound proof binds THIS key to THIS node
  UNIQUENESS   the key is registered to no node, AND the node to no key

The node axis is the new half of uniqueness, and possession cannot stand in
for it: one operator registering two keys it genuinely holds under one id
offers two genuine proofs, and takes two signer slots and two shares of the
weight. So a node is admitted exactly once, on both doors. Re-keying is a
`remove` and a fresh admission — the one spelling that cannot change a live
member's key or weight behind a caller that thought it was adding one.

And registration now calls `pop::verify` rather than restating it. There was
a second proof-of-possession implementation here, with its own copy of the
domain tag and the preimage; two homes for one standard is how the two
drift, so there is one, and it is the one the frozen Go vectors pin.

THE IDENTITY. A voter was a 32-byte block `Id`. Go names a validator by
`ids.NodeID`, 20 bytes, and the proof signs `node(20) ‖ key(48)` — so the
proof the network makes was not the proof this crate checked, and the two
were never the same standard however well each held on its own. The voter
is now `pop::NodeId`, one value with one width at all three places it is
read: registration, the proof preimage, and the strictly-ascending vote
order. `types::NodeID` is a 20-byte type of its own rather than an alias of
the block id, with no lossy constructor — truncating 32 bytes into 20 maps
distinct nodes onto one identity, and one identity is one signer slot.

No signed byte moves: the node id enters no signed message, so Go's frozen
vote message and Go's signatures verify unchanged.

Verified: 83 tests green, `pop_conformance` and `cert_conformance`
byte-identical; clippy clean. Eight mutants applied and killed, one per
clause — possession dropped, each uniqueness axis dropped, zero weight
admitted, no-key admitted, the weight clause reordered behind possession,
the preimage padded to 32 bytes, and the unkeyed door restating a member.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 11:08:02 -07:00
zeekayandHanzo Dev 6e27c03399 cert: address the re-review — restore the deleted property, kill the surviving mutants, apply the floor on issue
Red's second pass confirmed CRIT-1/HIGH-2/HIGH-3 closed but found the update had
weakened its own suite and left four mutants alive, plus a real issue-path bug.

  - Restore the per-key property, on the curve not via a lookup miss. The old
    "the rogue cannot sign under its own key" had become an assertion that a
    non-member is not found — no signature check ran. It is back as a direct
    curve assertion (BLST_VERIFY_FAIL), which is the only layer left if a peer
    without proof-of-possession hands a verifier the cancelling key.
  - Kill the surviving mutants with tests that bite (each verified against the
    mutant): dropping the node from the proof message, collapsing POP_DST onto
    the vote DST, weakening key_validate to a bare uncompress, and dropping the
    degrade branch of signed_identity. The degrade assertion is pinned to a
    literal so production and test cannot move together.
  - MED-1: the issue path finalized on the vote COUNT and never applied the
    stake floor, so is_finalized could report a certificate verify_certificate
    rejects. create_certificate now runs verify_weighted before filing, and a
    test proves three dust voters that meet the count and miss the two-thirds
    floor are refused and finalize nothing.
  - LOW/DRY: the degrade rule now lives once — canonical_vote_message folds the
    canonical through signed_identity, so the bytes signed and the bytes
    finalized cannot drift.
  - Docs: four sites that still described the pre-possession world are corrected
    (the no-aggregate rationale, the finalized-by-transport-id note, the
    "no registration supplies a proof" note, and the rogue_key module header),
    and the conformance comment now says plainly that it pins Go's cgo keygen,
    not the non-cgo CIRCL path.

Still deferred to the coordinated rollout, unchanged here so as not to diverge
from the Go oracle: the Quasar signer floor (MED-3), a proof that binds weight
or an epoch (MED-2), and the Go/C++ one-key-one-node question that
FlattenValidatorSet raises (HIGH-1). Not touched: mainnet Go.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 09:42:28 -07:00
zeekayandHanzo Dev df97a33ec4 cert: prove possession and pin one key to one validator (eligibility)
1.22.2 killed the forged certificate but secured only verification, not
registration — and that is where the CTO's bar, "unique signers + PoP," lives. A
red review confirmed two registration attacks with running PoCs:

  - one secret key registered under many node ids clears nova_signer_floor, which
    was counting ids and not signers — a stake-majority holder self-finalizes
    Nova alone, the exact fork class the floor exists to prevent;
  - an honest validator's PUBLISHED key can be registered by a third party and
    its genuine ACCEPT replayed, because key_validate proves the point is on the
    curve and nothing about who holds the secret.

ValidatorSet::insert now takes a proof of possession and enforces both halves:

  - a public key belongs to at most one node (an `owner` index), so a floor on
    distinct voters is a floor on distinct signers;
  - the registrant signs its own (node ‖ key) under a POP-specific DST
    (BLS_POP_..._POP_, distinct from the vote DST), verified against the key at
    registration. A key the registrant does not hold — an honest validator's, or
    a g1·x − Σpk cancelling key — cannot be registered in its name, because the
    proof cannot be made. remove and insert_unkeyed free the key so rotation is
    remove-then-insert and a retired key does not stay live.

Two more from the same review:

  - HIGH: finality was keyed on the transport block_id, which is unsigned, so a
    verified certificate could be relabelled to a block it never attested.
    Position::signed_identity returns the value the votes actually committed to
    (the canonical id, or block_id only in the degrade where none is set), and
    the engine keys finality on that. Two positions that sign to the same bytes
    now resolve to one entry, not two.
  - MED: verify()'s doc claimed it was "the whole rule on an equal-stake chain."
    It is not — its threshold is a field of the certificate, so alone it clears a
    1-of-n cert that declares threshold 1. The doc now says so, and names
    verify_weighted as the accept rule.

The rogue-key suite is reframed to the stronger truth: the rogue is refused at
REGISTRATION now, one step before the per-signature backstop. cert_conformance
regenerates each Go key from its seed to form the proof, which also asserts Go's
SecretKeyFromSeed and this crate's key_gen derive one key from one seed.

This is a BREAKING registration API (insert and add_validator_with_key take a
proof), so it is 1.22.3 and NOT published — the published 1.22.2 stays the
non-PoP code, no version shares two codebases. Rolling PoP to the Go oracle and
the C++ port is a coordinated, fork-class change and the CTO's call; this branch
does not touch mainnet Go. The proof-of-possession scheme is stated exactly so
Go and C++ can implement it byte-identically: message = node(32) ‖ pubkey(48),
ciphersuite BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_, min_pk (pk G1, sig G2).

Still open before validator-eligible: the Go/C++ PoP rollout, a wire codec with
full-vector conformance, and the Quasar signer-floor decision (unchanged here,
to stay faithful to the Go oracle).

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 09:42:18 -07:00
zeekayandHanzo Dev 45b41488ba Publish under the org, not a person
Bylines, paper authorship, correspondence addresses and the founder blurb under
each post named an individual. They name the organisation that published the
work instead: the brand on posts and pages, the research byline on papers, and
research@ in place of a personal address. Both are what these repositories
already used elsewhere — nothing new was coined.

Co-authors are untouched. Where a paper named someone else alongside, they stay
named; only the one byline changed. Bibliography entries, running heads,
PDF metadata and affiliations move with it, so a citation still resolves to
whoever the cited paper names.

Sample and fixture data uses the placeholder name these tests already use
elsewhere, so no assertion changed meaning.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 08:54:29 -07:00
zeekayandHanzo Dev 8be65b8dd9 rust: the node-bound proof of possession, conforming to the Go oracle
pkg/rust gains pop.rs — the 20-byte-NodeID-bound proof of possession, the same
bytes the Go validator/pop froze in the conformance corpus. tests/pop_conformance
reads that frozen vector and holds this crate to it: pop::verify returns the Go
verifier's accept/reject, and on a reject its error class, for every case,
including the encoding-leg rejects (identity key and proof, non-subgroup point)
that a lax decoder would wrongly pass.

This is the primitive; wiring it into ValidatorSet registration — one key to one
node, the 20-byte identity threaded through the vote plane — is the eligibility
step that follows. The forgery fix already on main (per-signature verification,
no aggregate) stands; this adds the possession proof the registration side was
missing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 08:14:10 -07:00
zeekay b660d19f51 the face is Zen, and it ships from @hanzo/font
One family. Font stacks name Zen and fall back to the native faces, tokens
name the role rather than a face, and the packages that used to supply a
face now point at @hanzo/font.

Self-hosted @font-face blocks are removed rather than renamed: rewriting the
family in one would leave a src pointing at a file this repo no longer ships.
@hanzo/font carries the binaries and declares the faces, so the local copies
go with the declaration that used them.

Comments naming a face we no longer use are deleted, not reworded.
2026-09-01 08:12:22 -07:00
zeekayandHanzo Dev c9b9b94e99 validator: close the node axis, make the canonical order build-invariant
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]>
2026-09-01 08:10:35 -07:00
zeekayandHanzo Dev af808b25ad validator: node-bound proof of possession, one key per node
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]>
2026-09-01 08:10:24 -07:00
zeekayandHanzo Dev 4c7db00125 One release, one artifact
The concurrency group was keyed on the repository, so each release run cancelled
the one before it and a batch of tags cut in one sitting left a single artifact
behind. Tags are not alternatives to each other; each names something somebody
will try to fetch. Keyed on the ref, each tag builds on its own.

Same change as luxfi/node, which carries the same workflow and lost seven
releases to it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-30 15:49:22 -07:00
zeekayandHanzo Dev f1810d9112 Merge main: take the extracted cert design, keep the re-cut corpus
Two lineages refactored the same certificate path at once. This branch fixed the
BLS length inversion in place — the aggregator declared a 48-byte G1 signature
and sliced every 96-byte G2 vote down to it, so the aggregate was the error
path's zero bytes and forty validators voting with no signatures produced a
certificate that verified. Main went further and deleted the aggregate path
entirely, moving the per-key check into cert.rs, where a certificate is verified
against one public key per signature and keys are never summed.

Deleting the class beats fixing the instance, and main's version arrives with the
test that proves it: rogue_key.rs builds the Boneh-Drijvers-Neven forgery, shows
the four keys really do sum to one the attacker holds, and then shows it buys
nothing. So the Rust crate is main's.

Resolving that file by date would have taken this branch's lib.rs and left
cert.rs orphaned: main declares the module, this branch does not, and the new
test would not have resolved lux_consensus::cert at all.

The corpus stays this branch's. It was re-cut for the seed encoding, and the Go
definitions that generate it are here; main's is smaller because it de-duplicated
an older cut. Both languages read the re-cut one and agree.

Go: conformance and engine/chain green. Rust: 8 suites, 70 tests, 0 failures.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 22:57:18 -07:00
zeekayandHanzo Dev 408ec64b33 bench: ask Go the aggregate question C++ was being asked
The aggregate row compared Go summing DECOMPRESSED signatures against C++
summing COMPRESSED bytes, and reported the 1.44x as a language difference. It
is the argument: bls.AggregateSignatures takes points, bls::aggregate_sigs
takes bytes and pays an uncompress and a group check for each one.

Asked the same question — 41 compressed signatures in, one out — Go answers
2.100 ms and C++ 2.096 ms. Within two parts in a thousand.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 20:02:17 -07:00
zeekayandHanzo Dev c6f4d45b36 bench: time the same work in every language, and time a round
The three legs were handing their aggregators and verifiers different work
under one name, so three rows of the comparison were measuring the inputs
rather than the implementations.

  aggregate n=41 spanned sixtyfold — Rust summed decompressed points with the
  group check off, Go summed decompressed points with it ON, and C++ summed
  COMPRESSED bytes and paid to decompress every one. With all three summing
  points already decompressed the row closes to Go 37.8 us, Rust 35.4 us,
  C++ 33.7 us: the sixtyfold gap was the caller's argument, not the language.

  verify one signature — Go decompresses the signature and group-checks it
  TWICE, once in SignatureFromBytes and again inside Verify. That is priced
  here rather than inferred: the check is 32.1 us of a 794 us verification, on
  every vote, in the shipped path.

Each operation is now timed twice: as shipped, which is what a Go node pays,
and matched, which is the same blst calls in the same order the other two legs
make. Adds the empty-body row, so the nanosecond figures are readable against
the harness floor (0.2 ns), and the certificate wire in both directions, which
Rust already timed and Go did not.

A ROUND is timed for the first time in any leg, split by who pays: sign is one
validator's own cost and does not grow with n; collect is the assembler's sort
and encode, O(n) with no curve arithmetic; admit is a follower's decode and
predicate, the O(n) pairings that are finality's critical path.

Rust gains the fast-aggregate-verify counterpart it had no row for, and a
measurement of the key validation Registry::verify repeats on every vote
although insert already paid it.

go vet clean; conformance and engine/chain green; Rust 33 tests green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 19:11:08 -07:00
zeekayandHanzo Dev 5639cf92e9 consensus: re-cut the golden corpus for the seed encoding, and say so in the standard
TestCorpusMatchesGolden caught the change, which is what it is for: 472 values
moved, all of them under /fpc, nothing added, removed or restructured. The vote
message, the certificate, the ladder, the count thresholds and the committee are
byte-identical, so the encoding change reached exactly the plane it was meant to
and no other.

Two of the moved values are the reason this is a protocol change and not a
refactor: /fpc/thresholds[1]/alpha went from 3 to 4. A round at that seed and
phase now accepts on one more vote. Freezing the encoding before anything runs
it is the whole point of doing this now.

standard.mdx carried the old preimage, and a standard that is quietly wrong is
worse than none. It now shows the domain and the lengths, and says what they
buy: without them the boundary between the chain id and the parent slides, and a
chain that names itself chain_id‖H derives what chain_id derives at parent H —
which trades away the input nobody can know before the previous epoch finalizes,
the input the whole construction exists to bind.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 18:12:18 -07:00
zeekayandHanzo Dev fb181d586b export the reject leg the corpus generator already calls
go build ./... fails on this branch and has since 8968c19c:

    conformance/corpus.go:248:15: undefined: chain.CanonicalRejectMessage

The generator needs both decisions because the accept byte is bound into
the signed message, and proving that binding is most of what the corpus
is for — a port that folded the decision out would pass every
accept-only vector. But only the accept leg was exported;
canonicalVoteMessageFor is package-private, and package conformance is
not that package.

So export the reject leg beside it. Two named builders rather than one
taking a bool: the decision is the thing being distinguished, and Go
reads better when it is in the name.

Correct, not merely compiling: the committed corpus.json already holds
the two reject vectors (zero_reject, spec_reject, message tails 00
against the accept vectors' 01), and TestCorpusMatchesGolden compares
the regenerated corpus to it byte for byte. It passes. Invert the bool
and it fails twice over — 'the corpus moved: a finality rule changed'
and 'decision byte is reject=0x1 accept=0x1, want 0x00 and 0x01'.

The wire-vector test pinned the unexported builder, so it could not see
a wrapper handed the wrong bool, which is the only way a three-line
wrapper goes wrong. It pins both exported names now.

Co-authored-by: Hanzo Dev <[email protected]>
pkg/rust/v1.22.2
2026-08-29 17:52:36 -07:00
zeekayandHanzo Dev c03aaa1b9c pin the domain tag, not just the vectors that use it
The conformance vectors prove Go and Rust agree, but the file header
says how to regenerate them. Change DST, rerun that recipe, and every
test in the file goes green again: Go and Rust agreeing with each other
under a tag the running network never signs under. A Rust node built
that way rejects every real vote on the wire and its test suite says
nothing.

Proven load-bearing rather than assumed: flipping DST to the _POP_
suffix fails this assertion and four corpus tests with 'Go's signature
did not verify here'. (Force the rebuild -- cargo's fingerprint can miss
a same-second edit and report a stale pass.)

The suffix pinned is _NUL_. luxfi/crypto/bls also declares
CiphersuiteSignature as BLS_SIG_...__POP_, which is neither the
signature tag nor the proof-of-possession tag. It has no users, but it
is the wrong constant sitting beside the right one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 17:48:37 -07:00
zeekayandHanzo Dev ffbe0e15b5 the crate version no longer collides with what is published
Cargo.toml said 1.22.1, and 1.22.1 is on crates.io — published
2026-05-13, unyanked, 235 downloads. That published build is the one
whose verify_certificate ends

    !cert.aggregated_sig.is_empty() || !cert.quantum_sigs.is_empty()

so it accepts a certificate carrying no signature at all. Leaving this
tree at the same number says the fix and the forgery are the same
release. They are not. 1.22.2.

The removal of the aggregate path changes the public API, so whoever
publishes may prefer 2.0.0; the number here is the smallest one that
stops the collision.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 17:47:25 -07:00
zeekayandHanzo Dev 451f2b3906 carry main's branding onto the certificate fix
main moved one commit (the corporate name) while the certificate work was
in flight. It touches only Go test files, none of pkg/rust, so the merge
is mechanical. Merging main IN makes this branch a descendant of main:
whoever lands it lands one branch, not a chain of three.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 17:44:36 -07:00
zeekayandHanzo Dev b0f6a338e7 consensus: give the epoch seed a domain and write its fields at their lengths
The seed preimage was be64(epoch) || chain_id || prev_block_hash, written end
to end. Nothing recorded where one field stopped and the next began, so the
boundary slid: DeriveEpochSeed(1, "chain-A", "") and DeriveEpochSeed(1,
"chain-", "A") are one seed. Machine-checked, both are
c89a52... under the old encoding.

That is not only untidy. The seed decides alpha, the count of votes a round
accepts on, and the input that makes it unpredictable is the parent hash --
the one value nobody holds until the previous epoch finalizes. Under a
concatenated preimage a chain that names itself lux-mainnet||H binds no parent
at all and still derives the seed lux-mainnet derives at parent H. The chain id
is a name the chain picks for itself; the parent is not. Trading one for the
other gives away the whole reason the parent is an input.

So every variable-length field is now written at its length, and the digest is
taken under a label of its own:

    domain || be64(epoch) || be64(len(chain_id)) || chain_id
           || be64(len(prev_block_hash)) || prev_block_hash

with domain = "lux.consensus.fpc.seed". Lengths make the preimage read back
apart into exactly the triple that produced it, which is injectivity stated as
something a machine checks rather than something a comment claims:
TestOneTripleReachesOnePreimage parses each preimage back and compares. The
domain keeps this digest from colliding with any other the protocol takes over
similar bytes.

EpochSeedPreimage is exported because the corpus records the bytes, not only
the digest. Two implementations can agree on every digest in a corpus and still
disagree on the first case that is not in it; they cannot, if they agree on
what they hashed. The oracle asks the package for the preimage rather than
rebuilding it, so a vector is never a second copy of the encoding it records.

pkg/rust carries the same change, and the same reason: it is the crate the
Rust conformance harness holds against these vectors.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 17:44:06 -07:00
zeekay 1776e41421 consensus: derive the FPC seed instead of drawing it
engine/driver.go filled the FPC seed from crypto/rand, so every node ran a
different threshold schedule. The threshold is the count of votes a round must
see before it moves, which means two honest validators sampling the same
committee were demanding different majorities of it: measured over 200
instances at k=20, the phase-1 threshold came out 11, 12, 13, 14, 15 and 16.

The seed is now DeriveEpochSeed(epoch, chain, parent), which every validator
computes alike and none can compute ahead of time — the parent hash is only
known once the previous epoch has finalized. The epoch arrives through
WithEpoch; a driver told no epoch derives the genesis epoch, which is a real
value rather than a draw. Sampling randomness is untouched: which peers a node
draws is deliberately its own business, what majority it then demands is not.

Wave and Driver gain Threshold(phase) so the number can be asked for. It is
the one number two implementations must agree on to agree at all, and a test
that cannot read it can only assert agreement rather than measure it.
2026-08-29 14:25:23 -07:00
zeekay d3b4086ff4 rogue_key: rustfmt the new file 2026-08-29 14:20:55 -07:00
zeekay 96d44a9436 quasar: delete the signature type nothing used
QuasarSignature had no constructor, no consumer anywhere in the workspace,
and a comment saying a BLS signature is 48 bytes. It is 96 — 48 is the
public key — and that exact inversion is what produced the certificate
that verified over no signatures at all. A dead type is not worth a
correction, so it goes.

Go's protocol/quasar.QuasarSignature is a different and live thing (BLS,
Corona and ML-DSA legs); this was not a port of it.
2026-08-29 14:19:15 -07:00
zeekay ddbd6216eb cert: delete the aggregate path, and keep the signatures
A verifier that sums the signers' keys and checks one signature against
the sum is sound only when every registrant proved possession of its
secret. Registration proves nothing of the kind — key_validate checks
encoding, subgroup and non-identity, all properties a chosen key has — so
pk_x = g1*x - sum(pk_others) collapsed the sum to a key the attacker
alone could sign under, and one signature stood for four signers.

The ruling allows deleting the path or requiring proof of possession.
Deleted, for three reasons that are not preference:

  - Go has no aggregate to conform to. engine/chain/cert.go: "there is no
    aggregate field, because nothing is aggregated." An aggregate accept
    rule is an accept this network cannot express, so it can only differ
    from the standard, never agree with it.
  - The crate carried two accept rules, and the aggregate one skipped
    every clause the other checks: version, type, tier, and the stake
    floor. The weaker one was the one on the engine.
  - It bought 654us against 13.6ms at n=21, and the sound way to buy that
    back is batch verification of the same per-vote signatures — 1.26ms,
    no summed keys, no proof of possession, no wire change, because the
    certificate already carries every signature. Not done here: it is a
    performance change and benchmarks come after conformance.

So QuasarConsensus now issues and verifies a cert::QuorumCert, which is
the Go rule: every signature checked against exactly one signer's key
over the message rebuilt from the certificate's own position, then the
tier's stake floor recomputed from the live set. A rogue key registers
and buys nothing — it cannot make anyone appear to have signed, and its
owner cannot even cast its own vote, holding no secret for what it
published.

The unbound header goes with it. Certificate.block_id and .height were
covered by no signature, so an honestly signed certificate re-labelled to
any block; a certificate now makes exactly one claim, its position, and
every part of it is signed. The map of issued certificates is keyed off
position.block_id rather than a field beside it.

rogue_key.rs keeps the forgery: it still builds the rogue key, still
shows one signature verifying against the summed key, and shows both
refusals. cert.rs and cert_conformance.rs test the per-vote rule the
aggregate tests used to cover.
2026-08-29 14:19:02 -07:00
zeekay 98d6105ad3 cert: reproduce the rogue-key forgery against the aggregate path
verify_aggregate sums the signers' public keys and checks one signature
against the sum. That is sound only when every registered key belongs to
someone who proved they hold the matching secret. insert() never asks:
key_validate checks encoding, subgroup and non-identity, and a chosen key
satisfies all three.

So an attacker registers pk_x = g1*x - (pk_a + pk_b + pk_c). The four keys
sum to g1*x, the attacker signs once with x, and a certificate naming four
signers verifies while three of them never saw the block.

Three failing tests, one control pair. The forgery is shown at the set
(verify_aggregate) and again through the public predicate
(verify_certificate), and the controls establish that the rogue key is a
well-formed subgroup element and that the three honest validators are
ordinary members whose real signatures verify.

The third failure is separate and simpler: Certificate.block_id and
.height are covered by no signature at all — the message is rebuilt from
.position — so an honestly signed certificate re-labels to any block.
2026-08-29 14:09:40 -07:00
zeekayandHanzo Dev 5884d7830e docs: write the finality standard down for readers
consensus.lux.network described an engine that is not in this repository.
It documented `engine/quasar` and a `qzmq/` transport, neither of which
exists; a `CertBundle` of two opaque blobs; a `quasar.NewQuasar(cfg, nodeID)`
constructor with no referent; and a quick start calling `consensus.NewID`,
which is not a function. Two performance tables gave mainnet finality as
9.63s on one page and under a second on the other. A reader who followed any
of it would not have compiled.

Meanwhile the thing that actually decides finality had no page at all. The
ladder is five distinct authorization rungs and the whole point of the type
is that they are never collapsed — a block below Quasar may drive local
execution and may never be exported — and none of that was written down
where anyone would look for it.

standard.mdx is the rule: the ladder and what each rung authorizes, the
226-byte signed message field by field, why the outer proposervm ids are
absent from it, the certificate wire form, both stake floors with their
values at the top of the u64 range, the count thresholds, and the θ
derivation whose last bit is load-bearing because α is a ceiling. Every
number is read out of conformance/corpus.json or the definition the corpus
is projected from, so a rule change moves the page's source rather than
leaving it quietly wrong.

conformance.mdx is the record: which harness holds which implementation, what
each one currently reports, and the two divergences that motivated the corpus
— the C++ 70-byte v1 message against Go's 226-byte v2, and the Rust mixer
that accepted on eleven votes where Go requires fifteen. It reports the open
one honestly: lux-rs/conformance is 11 of 12, failing the ZAP handshake frame
on a version byte. A conformance page that only lists green is a brochure.

The quick start now compiles and prints what it claims. quasar.mdx describes
the two witnesses for one relation, the legs and the posture table as
protocol/quasar defines them.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 01:22:09 -07:00
zeekayandHanzo Dev b41c532fa4 rust: derive θ with SHA-256, and verify the signatures
Two implementations of one protocol disagreed about when a round accepts
and about what a certificate proves, and nothing in the repository said
so because each agreed with itself.

θ. The selector's doc comment said "PRF (SHA256)" and the code called a
SipHash-like mixer. Go's protocol/wave/fpc computes θ with crypto/sha256,
and α = ⌈θ·k⌉ is the vote count a round accepts on, so the two nodes
applied different accept counts to identical votes: at phase 0 with k=20
the mixer returns 11 where Go returns 15. Measured both ways — the mixer
is spliced back in under tests/conformance.rs and the check fails on θ
before it reaches α. The seed was worse than the PRF: three hardcoded
ASCII constants, public forever, so every future threshold was readable
in advance. derive_epoch_seed is Go's DeriveEpochSeed, which binds the
previous epoch's block hash and is therefore unknown while the current
epoch is still open.

The phase now advances once per counted round, past the k-vote gate,
which is where Go advances it. It was advancing once per vote, k times
too fast, so two nodes one round apart read θ from different phases.

Signatures. verify_certificate ended in

    !cert.aggregated_sig.is_empty() || !cert.quantum_sigs.is_empty()

and the aggregator it asked about returned vec![0u8; 48] whenever it had
nothing to aggregate. Zero bytes are not empty. It had nothing to
aggregate on every input, because it sliced each signature to 48 bytes
and handed it to a G2 decoder: luxfi/crypto/bls signs under
BLS_SIG_BLS12381G2, so a public key is 48 bytes and a signature is 96,
and this crate had the two the other way round. So forty validators
voting with no signature at all produced a certificate that verified,
and there was no input for which it did not.

finality::Cert is engine/chain.QuorumCert — the position, the rung, the
threshold, and one signature per named signer. It encodes to the bytes
the network gossips, decodes strictly, and verifies Go's seven clauses:
version, role, tier, non-zero threshold, non-empty votes, strictly
increasing signers, accept-only, and every signature checked against a
registered key over the message the cert's OWN position derives. A
signature does not travel between positions. verify_stake adds the rung's
stake predicate, re-derived from the live set rather than read from the
cert, so a Nova quorum relabeled Quasar fails; an unresolved set fails
closed.

The engine no longer issues certificates. Its block carries an id, a
parent, a height and a payload, and a certificate binds a chain id, a
round and three roots — nothing it can assemble is a statement the
network signs, so what stood there manufactured finality out of a
position it did not have and votes it had not checked.

The corpus carries the rule now: 8 epoch seeds and 192 thresholds read
from the live Go selector, θ as exact IEEE-754 bits because α is a
ceiling and the last bit decides. Certificates are compared over their
whole wire form; the previous check read four header bytes.

Also removed: build.rs linked a C library no Rust symbol references, and
libc was unused. BENCHMARK_RESULTS.md reported a benchmark that has not
compiled in a long time — it imported five names this crate does not
export — so the benchmark is rewritten against the real API and the
retracted report is gone.

33 tests, no warnings.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-28 23:52:34 -07:00
zeekay fd6fb47216 consensus: clear the remaining lints, one default apiece
Mechanical. QuasarEngine's inherent default() shadowed the standard
trait method, so it is a Default impl like the other three; the rest is
cargo clippy --fix (or_insert_with to or_default, map_or, sort_by_key,
redundant borrows). clippy --all-targets is now silent.
2026-08-28 23:52:23 -07:00
zeekay 168ac7d518 focus: state the beta rule once, and stop advancing the phase in a getter
Wave and Focus each carried their own copy of the confidence fold —
deepen on agreement, restart on opposition, reset when there is no
quorum, decide at beta. Two copies of the rule that decides when a block
is final.

They differ only in how a round is read: Wave counts votes against a
threshold, Focus compares a ratio to alpha. So that is what they keep. A
round now produces a verdict — for, against, or neither — and
focus::accumulate folds verdicts into confidence in one place. Sampling,
tally and confidence come apart, which is what they always were.

get_threshold was a getter that incremented self.phase, so asking what
the threshold was changed it, and the FPC schedule advanced once per
vote because check_consensus asks on every vote. It is now a pure
threshold() and an explicit advance_phase(). The behaviour is unchanged
— a phase is still a vote — but the reason is now visible and named:
this engine has no round boundary, and FPC draws per round.

tests/confidence.rs pins the reset in particular. A counter that only
climbs would let a block accumulate agreement across rounds that
disagreed in between, so beta would no longer mean consecutive; a
thousand alternating rounds must never decide, and now demonstrably
do not.
2026-08-28 23:51:08 -07:00
zeekay 9b217a7037 consensus: delete what was saying the same thing twice
Nothing here changes what the crate decides.

The three network presets each restated all twenty configuration fields,
so mainnet and the default could drift apart silently. They are now
stated as what they change: mainnet is the default with a committee of
21, a seed, and a higher security level, which is what it always was.

Four FPC seeds sat in four places, and the default was not the seed Go
defaults to — so a default Rust node and a default Go node drew
different theta schedules. There is one named constant per network now,
and the default is Go's.

Luminance::default re-hardcoded the same five numbers the default
configuration already held; it reads the configuration. FpcSelector
likewise. Both are Default impls rather than inherent methods that
shadow the trait, and QuasarConfig's Default no longer calls a
same-named inherent method — that only avoided being infinite recursion
because inherent methods win, which is not a thing to rely on.

Deleted: the Config and Chain aliases kept for backward compatibility;
33KB of tests parked as .old and excluded from the package; a build
script whose own comment said no Rust code references the C symbols it
linked, and the cc build-dependency it needed; an unread field on
PhotonSampler. The crate builds with no warnings.

The benchmark had not compiled since the crate stopped being an FFI
wrapper — it imported five types that no longer exist. Rewritten against
the API that does, and it now measures the part that costs: verifying
signatures, not inserting into a HashMap. BENCHMARK_RESULTS.md, whose
own header retracted its numbers as a u8 overflow bug against that
removed API, is deleted; the benchmark is the source of truth. LLM.md
carries the finding worth keeping — the aggregate check is flat in
committee size where the per-vote check is linear, and they answer
different questions.
2026-08-28 23:49:01 -07:00
zeekay 511265b10c quasar: count only members, and certify only what is signed
Three holes in the probabilistic engine's finality side, all of the same
shape: something that looked like evidence was accepted as evidence.

record_vote consulted no validator set. Nine unregistered node ids could
carry a block to Accepted against an engine holding no validators at
all, because the sample was whoever sent a message rather than the
committee. Membership is now required, and an empty set counts nobody.

create_certificate counted ballots and aggregated whatever bytes it
found. Its aggregation could not have worked — it read 48 bytes of a
signature that is 96 — so it fell back to a zero aggregate, which the
structural check then accepted. It now verifies each signature under the
voter's registered key over the position's canonical message, and issues
nothing when there is nothing to issue. Unsigned ballots yield NoQuorum
rather than an empty certificate.

verify_certificate checks that aggregate against the aggregate of the
signers' keys, over the message rebuilt from the certificate's own
position — so a certificate carries the position it attests, because
without it a holder can only check the shape.

quantum_sigs was filled by cloning the BLS signature under a second
name, so a certificate appeared to carry post-quantum evidence it did
not have. It is empty until there is a post-quantum signer.

The validator set is now one structure, cert::ValidatorSet, holding
membership, stake and keys together. A member with no key still counts
toward the set size and total stake — which raises the bar for everyone
else — and can never contribute a verified signature.

test_consensus_flow voted with zero validators registered and asserted
success, so it encoded the first hole. It now registers the committee it
votes as, and a new test pins the refusal.
2026-08-28 23:42:05 -07:00