mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
a425cf15855966bf81ad7037e1bac16048c33801
985
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a425cf1585 |
lux-consensus 1.23.1: a proof over any message
Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
33e6b708c3 |
cargo fmt
Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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]>
|
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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. |
||
|
|
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]> |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |