Commit Graph
985 Commits
Author SHA1 Message Date
zeekayandHanzo Dev a425cf1585 lux-consensus 1.23.1: a proof over any message
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 09:54:26 -07:00
zeekayandHanzo Dev 33e6b708c3 cargo fmt
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 09:53:52 -07:00
zeekayandHanzo Dev c98bbf4e9a One proof implementation, over whichever message the caller has
A node-bound proof is one message among several: a committee entitles a
validator ON A CHAIN and signs the chain into its proof, so it has a
message of its own. It could only reach the same encoding and
possession clauses by restating them, and a second statement of what a
canonical key is is a second answer waiting to differ.

The general pair is the implementation now and the node-bound pair is
it with one message, so a registration and a committee are checked by
the same rules or by none. Admission is the same door for the same
reason.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 09:53:52 -07:00
zeekayandHanzo Dev 937578257d chain: the export rung's committee floor, on the road a certificate arrives by
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]>
v1.36.92 v1.36.91
2026-09-02 15:13:37 -07:00
zeekayandHanzo Dev 18fe0f8f1c validator, cert: name the checked add the way that is not deprecated
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]>
v1.36.90 v1.36.89
2026-09-02 03:46:08 -07:00
zeekayandHanzo Dev 292d7b8cd6 cert: drop the refusal no clause raises
The var block above these errors says each one maps 1:1 onto a predicate
clause. ErrQCVotePosition did not: nothing raised it and nothing read it,
in any language, in any repo.

It could not be raised. A SignedVote is {NodeID, Accept, Signature} and
carries no position, so there is no per-vote position that could disagree
with the cert's. A vote signed at the wrong position fails as
ErrQCSigInvalid, because the position is in the signed bytes — which is
the clause that actually defends the property this error was named for.

An exported error that no clause raises is a promise the package does not
keep: a caller may errors.Is against it forever and never match. Removing
it makes the block's own comment true.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 03:45:35 -07:00
zeekayandHanzo Dev 7d990c2592 quasar: the engine issues certificates its own accept rule accepts
It did not. QuasarConsensus::create_certificate stamped ceil(alpha * k), the
CONFIGURED accept quorum, while every floor that certificate is then held to is
derived from the SET. Those are two quantities: one is about a committee, the
other about who can sign. They part at every n != k, and the testnet preset parts
at its own k — five validators, alpha 0.6, a stamped 3 against a derived 4 — so
the public issue path was dead on that preset at every set size, and its refusal
came back as NoQuorum over a set holding a unanimous one.

So the threshold is derived here and now, exactly as Tally::cert derives it:
signer_floor(tier, signer_count) at the moment of issue. The liveness pre-gate
reads the same derived number rather than the configured one, so it can never stop
short of a quorum the predicate below would have admitted — the door-in-front-of-a-
gate mistake, which this one was also making at every n where the config asked more
than the set does.

The suite stayed at 170 green through all of it because it only ever asked the
DEFAULT preset at n=20, which is the one point where ceil(0.69 * 20) and
two_thirds_count(20) are both 14. The new rows straddle that point, on both presets,
and every one of them fails on the old stamp.

THE REFUSAL IS CARRIED WHOLE. verify_weighted's answer was flattened to NoQuorum,
so "the voters hold 3 of 10_003 stake" and "two seats cannot carry a supermajority
at all" and "the votes are not in yet" were one sentence. ConsensusError::Cert
carries the CertError, and the two registration doors stop stringifying theirs
through CryptoError — one way to carry one thing.

THE CONFIGURED NUMBER STAYS, under its own name. QuasarConsensus::alpha is what
has_quorum asks: has the membership grown to the committee this node was told to
expect. It is a readiness question and it touches no certificate. It was called
`threshold`, which is why it ended up stamped on one.

ONE READ PER ISSUE. Tally::cert derived its threshold from signer_count and then
handed verify_weighted the source, which reads signer_count again to check that
same threshold. A source that need not answer twice alike makes those two numbers,
and the second refuses what the first built. The count is taken once and answers
every clause of the issue. Only the issue: the ARRIVAL road keeps its independent
reads, and the rung's count clause reading the set again is what refuses a
certificate whose set moved under it — which is why that clause is not dead.

CertError and ConsensusError are #[non_exhaustive]. A refusal is a fact about the
protocol and the protocol acquires facts; ThresholdNotDerived is one it just
acquired. This cut breaks an exhaustive match either way, so it is the cut that
should make it the last time. 1.23.0: new API, non-breaking variants after.

Co-authored-by: Hanzo Dev <[email protected]>
v1.36.88
2026-09-02 02:56:40 -07:00
zeekayandHanzo Dev 731dc22f23 cert: a floor is read once, where it is authoritative
Red found no way past the predicate — every refusal was fail-closed. What it
found instead was the opposite failure, three times over: a number about a quorum
read somewhere that is not the authority for it.

THE DOORS IN FRONT OF THE GATE. HandleIncomingCert and VerifyCatchupCertificate
each carried a cheap sub-quorum filter over the sample committee — NovaQuorum(K)
for Nova, Alpha() for Quasar — in front of verifyCert. That is a THIRD spelling of
a quantity with one definition, and it is a different quantity: the gate derives
the accept rung's floor from the SIGNING SET, where NovaSignerFloor saturates at
three, while the door read the committee's majority, which grows with K. At six
seats the gate accepts a certificate declaring three and the door demanded four;
at nine it demanded five; at eleven, six. And where the set is SMALLER than the
committee the door asked the set for more than the set's own floor: three signers
derive two, and a door reading a committee of four asked for three.

So the exact bytes a node's own assembler produces never reached the predicate
that would have admitted them. A door in front of a gate may only refuse what the
gate refuses, and these could not, so they are gone. Nothing is lost by not asking
twice: the gate demands EQUALITY with the derived floor, which is strictly
stronger than the "at least" a door applied. Nor is any work saved by refusing
early — the certificates a floor filter drops are the ones carrying FEW votes,
which are the cheap ones, and Verify stops at the first signature that fails.

THE CALLER'S ALPHA. BuildVerifiedQuorumCert took the floor as an ARGUMENT, and on
the count-only road nothing re-derived it: one vote and an alpha of one minted an
authority token on a chain of five, while a certificate carrying those exact bytes
was refused on arrival. The docstring said an alpha that disagrees with the set
yields no token, and that was true only of the weighted road. So there is no
argument. The floor is SignerFloor(tier, SignerCount(epoch)), read at the door,
and there is nothing left for a caller to disagree with.

With that, a nil stake source is refused at BOTH rungs rather than at the export
rung alone: a floor is a property of the set, and with no set there is no floor and
nothing to mint. exportNeedsStake goes with it — one rule where there were two, and
the rule Rust and C++ already have by their types, each taking a set by reference
so the absent case is unrepresentable rather than checked. The equal-stake road
keeps minting through the engine, whose committee is its own, and keeps ADMITTING
on the arrival road, where signatures have already arrived to be weighed.

THE WIDTH. SignerFloor and Quorum returned uint32 while counting a set held in an
int, so the floor wrapped at about six billion signers and SignerFloor(Quasar, ~6.4e9)
answered 1 — a floor a lone signer meets. They return int now and are narrowed only
where a certificate is written, which is one place; and because the derived clause
compares at full width, a mint that narrowed is caught by it rather than believed.

THE LAST SECOND SPELLING. attestation.go's export trigger said config.TwoThirdsCount(n)
where it means SignerFloor(Quasar, n). Same number, and that is the point: a trigger
spelled apart from the rule it must never outrun is a trigger that can drift from it.

TestTheDoorAdmitsWhatTheGateAdmits walks the four sizes where the two numbers part,
on both arrival roads, and fails on the old filter at every one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 02:56:02 -07:00
zeekayandHanzo Dev 287b89c64f cert: derive the floor on the road a certificate arrives by, too
The rule landed at the door that MINTS a token from raw votes. Auditing the three
sites that promote a certificate to that token found the same hole one function
away, on the road a certificate ARRIVES by: with no stake source, verifyCert fell
to the count-only Verify, whose last clause counts distinct valid accepts against
the number the CERTIFICATE declares. So a gossiped Quasar certificate declaring a
quorum of one, carrying one signature, was promoted and finalized.

The two doors answer differently, and the difference is the threat rather than an
oversight. Minting takes the floor from the CALLER's alpha, so with no set there
is nothing to check the caller against and the export rung is refused outright.
Admitting one that arrived checks N real signatures from N distinct in-set
validators; what was missing was only that its declared quorum was its own. So the
floor is derived there too, from the node's live committee — which is that road's
authoritative view of its set. Refusing the rung outright instead would retire the
equal-stake deployment, which is a larger decision than this clause is.

THE PRICE, stated because it is real: with no stake model the committee is a
CONFIGURED number, so that road now needs the fleet to agree on it. Before, any
declaration was admitted and a node with a different K interoperated by accident.
That is the cost of not letting a certificate name its own quorum, and it is why
the minting door refuses the rung rather than weighing it — a configured committee
is strong enough to CHECK a declaration and not strong enough to ORIGINATE one.

And the count-only road reads a DIFFERENT derived number, which is the trap this
change nearly walked into. SignerFloor is the distinct-signer guard the weighted
road carries beside a stake clause; Quorum is the count that IS the predicate
where no stake clause stands beside it. They agree at Quasar and diverge at Nova
past five signers, because NovaSignerFloor saturates at three — correct as a guard
on a road whose majority is carried in stake, wrong as a majority on a road that
has only the count. assembleCertLocked already declares NovaQuorum on that arm, so
reading SignerFloor there would make a node refuse its OWN certificates at six
signers and up. Every committee the fixtures use is four or five, exactly where
the two agree, so nothing in the suite would have said so:
TestTheTwoRoadsDeriveTheNumberTheyDeclare walks 1..64 instead.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 02:23:17 -07:00
zeekayandHanzo Dev 8d94ad1e03 cert: derive a certificate's quorum from the set, never from the certificate
A certificate carries a Threshold, and until now that field was its own
authority on the count-only road: Verify's last clause counts distinct valid
accepts against the number the CERTIFICATE names, so one declaring 1 clears it
on a single signature. C++ has always demanded the derived floor exactly, so the
same certificate was accepted by two implementations and refused by the third.

VerifyWeighted now requires Threshold == SignerFloor(tier, n) — the floor this
set gives this rung. Equality and not a lower bound: an over-claim names a bar
this set does not set, and tolerating it would let a certificate redefine the
rung upward exactly as tolerating an under-claim lets it redefine the rung down.

SignerFloor is the one definition of a rung's count floor, read by the rung
predicates, by the assemblers and by the new clause, so the number enforced and
the number declared cannot drift.

Two corollaries the rule forces:

  No set, no export. BuildVerifiedQuorumCert refuses any tier that authorizes
  export when no stake source is supplied. That road counts votes against the
  caller's own alpha, so an export token could be minted over one signer with
  alpha of one — and AcceptWithCert trusts the token, which is the point of the
  token. The accept rung keeps the road: it authorizes local execution the chain
  can still reorg away.

  The assembler reads the same n the verifier does. assembleCertLocked derived
  Nova's declared floor from effectiveCommittee, which sizes the SAMPLE and is
  floored at the minimum Byzantine committee. At three signers the sampler said
  three and the set said two, so a node would have built a certificate its own
  peers refuse. The export path already read SignerCount.

The corpus grows five rows — three that declare a quorum of their own, and two
lopsided sets where the stake half binds alone, which an equal set cannot state
because both halves bind at one edge there. Two frozen verdicts change the clause
they are NAMED by, from the stake floor to the count, and neither changes from
refused: over an equal set a certificate short of its stake is also short of the
quorum it now has to declare.

The rungs' own count clauses are unreachable as a result — Verify proves count
reaches the declaration and the new clause proves the declaration is the floor —
and are kept as the place the floor is defined and as the guard that survives any
change to the declaration rule. Written down in LLM.md beside the other walls
behind walls.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 02:23:07 -07:00
zeekayandHanzo Dev 578de7ef2b pop: freeze the over-long key, so the width is refused from both sides
The corpus froze a 47-byte key and both a 95- and a 97-byte proof, but no
49-byte key — so the one width a port could get wrong was the one the standard
did not carry. The clause is a width equality, not a lower bound: a decoder that
guarded only the short side reads the first 48 bytes of a longer buffer and
checks a proof against a PREFIX of what the registrant published. That is a
second spelling of an already-seated key, and two spellings of one key is two
seats and two votes on one signature.

The case is generated by the oracle and self-checked against pop.Verify, like
every other row, and lands as an ENCODING refusal before any pairing.

Go already held it as a unit assertion and Rust held its own; Rust's is dropped
here, because a case the frozen corpus carries for all three does not also want
a private restatement that has to be kept in step.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 02:22:56 -07:00
zeekayandHanzo Dev 90b3a5db5e merge: adopt vote.rs + zap.rs into the canonical crate (lux-rs fork retirement); Tally derives its quorum from the set
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 01:58:33 -07:00
zeekayandHanzo Dev 1ed42ad0a2 vote: derive the tally's quorum from the set, and drop a replay before the pairing
Two things a tally was getting wrong, both in `Tally`.

DERIVE, DON'T CARRY. A tally took a threshold at `new` and handed it to the
certificate at `cert`, while the floors that certificate is held to were read
from the live set. Two numbers about one quantity, fixed at two different
moments, and the gap between the moments is a set that can change size. A tally
that opened when ten could sign and issues when seven can is holding a claim
about a set that no longer exists: the floor it pinned counts validators who
have gone, and it strands a tally that holds a real quorum of the seven. The
declaration was never load-bearing either — it sat under the real floors and
could only ask for less than the set requires.

So there is no declaration. `Tally::new` takes a position, a rung and an epoch;
`Tally::cert` reads `signer_floor(tier, signer_count)` at the moment it issues,
and that is what the certificate carries. A caller cannot ask for a lower bar
and cannot pin a higher one. The floor is always the live one, and it is the
number every party checking the certificate computes for itself — one quantity
with one source, which is what makes the two sides agree by construction rather
than by luck.

`signer_floor` is the one spelling of that quantity, beside `nova_signer_floor`
and `two_thirds_count` which it selects between. A second spelling is how a
certificate acquires a quorum of its own choosing.

DEDUPLICATE BEFORE THE CURVE. `add` verified the signature and then looked for
the signer in the map, so a frame from a signer already held paid a full BLS
pairing before being dropped. A mesh replays and echoes as a matter of course,
and 577µs against 19ns is thirty thousand copies of a peer's cheapest frame per
copy of this node's CPU. The lookup goes first. Nothing is lost: the first
signature from a signer stands, so a second frame from a held signer changes
nothing whatever it carries — including when what it carries is garbage, which
now costs a lookup rather than a verification.

One behaviour moves with it. A duplicate signer offering a bad signature answers
`Ok(false)` where it used to answer `Err(Signature)`, which is the truer of the
two: that signer is held, and the frame was not counted for the reason a repeat
is never counted.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 00:22:32 -07:00
zeekayandHanzo Dev 3a25fb68c3 vote: close VoteError while closing it is still possible
`#[non_exhaustive]` is free before a type is published and unavailable after:
adding it later breaks every downstream `match`, so it is never added later.
`VoteError` is new in this cut and 1.22.7 is where it leaves the crate.

The vote plane will grow refusals — a slot rule, a rate rule, whatever the mesh
turns out to need — and each one should reach a caller's wildcard arm rather
than its compiler. `CertError` is left alone: it is already published, and the
concurrent cut that adds a variant to it is the one that owes that decision.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 00:21:45 -07:00
zeekayandHanzo Dev b7f7d907fb vote: name the MPC transport's home, and say the two lanes do not interoperate
Two claims in this file were wrong about the world outside it.

`MsgMPCReady = 62` is `github.com/luxfi/mpc` (`pkg/transport/wire.go`), a
repository of its own — not a package inside `luxfi/node`. A reader following
the citation to check the collision would not have found it. The conclusion it
supports is unchanged and now carries the reason it holds: that ring listens on
its own port behind mandatory PQ TLS 1.3 and closes any connection whose first
frame is not a `MsgMPCReady` carrying JSON, which a vote payload is not.

And the id was presented as the only thing between this lane and the C++ one,
which reads as "fix the constant and they interoperate". They do not. C++ frames
`block_id(32) ‖ pubkey(48) ‖ sig(96)` and names its signer by a 48-byte key;
this lane frames `signed_message(226) ‖ node(20) ‖ sig(96)` and names it by the
20-byte NodeID a proof of possession binds. C++ signs the same canonical message
and then transmits only the block id, so its receiver rebuilds the preimage —
the second derivation the payload rule exists to prevent. On the two counts with
a standard behind them, 0x11 is `MsgResponse` in the Go registry and Go names a
validator by `ids.NodeID`; this side holds both. The reconciliation is owed on
the C++ side, and until it is paid neither can read the other's frames.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 00:21:14 -07:00
zeekayandHanzo Dev 5e49fa5b6f cert: a certificate is a record, not a capability
`QuorumCert` has public fields and `verify` counts against the `threshold`
written on the certificate, so `verify` alone clears a 1-of-7 certificate
that says `threshold: 1`. The doc explained that and then explained the
remedy, but never stated the invariant the two hang off — and a reader who
skims to "every signature verified" has everything they need to mistake it
for acceptance.

So state it first: holding a certificate authorises nothing, because anyone
can build one saying anything. What authorises is `verify_weighted`, every
acceptor runs it, and the count-only predicate is the structural pre-filter
inside it. Nothing about the code changes; the sentence a reader needs first
is now first.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 00:20:26 -07:00
zeekayandHanzo Dev b20b564173 zap: the bounds check prevents a panic, not an over-read
The comment on `Reader::bytes` said an unchecked add would hand back a slice
on a payload it was meant to refuse. It would not. A wrapped end lands BELOW
the cursor — the length is under 2^32 and so is the wrap — and `get` answers
`None` to a range whose start is past its end. Every cursor a payload can hold
and every length that wraps it: zero slices.

What the unchecked add does is overflow, and an overflow is a panic wherever
overflow checks are on. So the exposure was a peer announcing a length and
halting the process, not reading past a buffer. `checked_add` is still the
whole fix; it was being credited with the wrong one.

The test claimed the property was held by the bounds check "and not by the
target". On a 64-bit target — every target this suite runs on — that is the
one thing it cannot say: the sum has nowhere to overflow to, and removing the
`checked_add` leaves the test green. It says so now, and names the day it
would start carrying the property.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 00:19:59 -07:00
zeekay 000166c5d1 zap: bound the cursor with the peer's number, not the pointer's width
`Reader::bytes` added a peer's 32-bit length to our position and indexed with
the sum. Where `usize` is 64 bits that cannot wrap and the bounds check does
the work. Where it is 32 bits the sum wraps to a SMALL index, which is in
bounds — so the very payload the check exists to refuse would come back as a
slice.

`checked_add` is the whole fix. The test announces lengths near `u32::MAX`
over a five-byte payload and requires a refusal, so the property is held by
the bounds check and not by the target.
2026-09-01 23:35:38 -07:00
zeekay 667bbb4023 vote: carry the vote plane here, and hold it to the certificate's floors
`lux-rs/consensus` is a filtered extraction of this crate that grew two
modules this one never had — a vote on the wire, and the ZAP frame it
travels under. Rust had no reader for that frame at all, so the Rust lane
could not carry a consensus vote between validators; the extraction could,
and that is the only reason it still exists. It is also a second crate
answering the name `lux-consensus`, which is one implementation too many.

So the two modules come here, where the rule they need already lives.

They are not copied. The extraction predates proof of possession and the
export rung's floors, and its `Tally` issued a certificate the moment a
caller-declared count was reached — a threshold anyone could declare and
nobody had to earn. Rewritten against this crate: `NodeId` and the proof
path from `pop`, `ValidatorSet` for who may be counted at all, and
`QuorumCert::verify_weighted` for whether the votes amount to anything.

`Tally::cert` is the only door out of a tally and it runs the whole
predicate — the stake supermajority, the distinct-signer count, and the
minimum Byzantine committee, every one recomputed from the live set. A
tally can no more declare its own quorum than a certificate can. That is
`a_tally_cannot_declare_its_own_quorum`: one valid signature, a declared
threshold of one, a set of seven, and no certificate.

One tally reads one epoch. The height is fixed when it opens and both the
signature check and the stake read use it, so a tally cannot verify against
one set and weigh against another.

`VoteError` spells only what the vote plane owns — the wire, the position,
the accept bit, the signature — and carries every certificate clause as the
`CertError` `cert` already names, so a refusal has one vocabulary.

The frame's constants are pinned against the two lanes that must read it:
`luxfi/api/zap` writes a 4-byte big-endian length, a type byte, and the
payload, with `WriteBytes` prefixing a field the same way; `luxcpp/zap`
matches. Go names no type mask and open-codes the strip, so
`strip_flags_is_gos_own_expression` proves the named one is that expression
over every byte.

62 is free in the Go registry, which spells 60 and 61 of the Quasar block.
It is NOT free everywhere — `mpc/pkg/transport` extends the same frame with
its own 60..69 — and the constant says so rather than claiming a clean id
space this end cannot give.

Public API addition, so 1.22.7.
2026-09-01 23:29:07 -07:00
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