mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
main
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
33e6b708c3 |
cargo fmt
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
c03aaa1b9c |
pin the domain tag, not just the vectors that use it
The conformance vectors prove Go and Rust agree, but the file header says how to regenerate them. Change DST, rerun that recipe, and every test in the file goes green again: Go and Rust agreeing with each other under a tag the running network never signs under. A Rust node built that way rejects every real vote on the wire and its test suite says nothing. Proven load-bearing rather than assumed: flipping DST to the _POP_ suffix fails this assertion and four corpus tests with 'Go's signature did not verify here'. (Force the rebuild -- cargo's fingerprint can miss a same-second edit and report a stale pass.) The suffix pinned is _NUL_. luxfi/crypto/bls also declares CiphersuiteSignature as BLS_SIG_...__POP_, which is neither the signature tag nor the proof-of-possession tag. It has no users, but it is the wrong constant sitting beside the right one. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
d3b4086ff4 | rogue_key: rustfmt the new file | ||
|
|
ddbd6216eb |
cert: delete the aggregate path, and keep the signatures
A verifier that sums the signers' keys and checks one signature against
the sum is sound only when every registrant proved possession of its
secret. Registration proves nothing of the kind — key_validate checks
encoding, subgroup and non-identity, all properties a chosen key has — so
pk_x = g1*x - sum(pk_others) collapsed the sum to a key the attacker
alone could sign under, and one signature stood for four signers.
The ruling allows deleting the path or requiring proof of possession.
Deleted, for three reasons that are not preference:
- Go has no aggregate to conform to. engine/chain/cert.go: "there is no
aggregate field, because nothing is aggregated." An aggregate accept
rule is an accept this network cannot express, so it can only differ
from the standard, never agree with it.
- The crate carried two accept rules, and the aggregate one skipped
every clause the other checks: version, type, tier, and the stake
floor. The weaker one was the one on the engine.
- It bought 654us against 13.6ms at n=21, and the sound way to buy that
back is batch verification of the same per-vote signatures — 1.26ms,
no summed keys, no proof of possession, no wire change, because the
certificate already carries every signature. Not done here: it is a
performance change and benchmarks come after conformance.
So QuasarConsensus now issues and verifies a cert::QuorumCert, which is
the Go rule: every signature checked against exactly one signer's key
over the message rebuilt from the certificate's own position, then the
tier's stake floor recomputed from the live set. A rogue key registers
and buys nothing — it cannot make anyone appear to have signed, and its
owner cannot even cast its own vote, holding no secret for what it
published.
The unbound header goes with it. Certificate.block_id and .height were
covered by no signature, so an honestly signed certificate re-labelled to
any block; a certificate now makes exactly one claim, its position, and
every part of it is signed. The map of issued certificates is keyed off
position.block_id rather than a field beside it.
rogue_key.rs keeps the forgery: it still builds the rogue key, still
shows one signature verifying against the summed key, and shows both
refusals. cert.rs and cert_conformance.rs test the per-vote rule the
aggregate tests used to cover.
|
||
|
|
98d6105ad3 |
cert: reproduce the rogue-key forgery against the aggregate path
verify_aggregate sums the signers' public keys and checks one signature against the sum. That is sound only when every registered key belongs to someone who proved they hold the matching secret. insert() never asks: key_validate checks encoding, subgroup and non-identity, and a chosen key satisfies all three. So an attacker registers pk_x = g1*x - (pk_a + pk_b + pk_c). The four keys sum to g1*x, the attacker signs once with x, and a certificate naming four signers verifies while three of them never saw the block. Three failing tests, one control pair. The forgery is shown at the set (verify_aggregate) and again through the public predicate (verify_certificate), and the controls establish that the rogue key is a well-formed subgroup element and that the three honest validators are ordinary members whose real signatures verify. The third failure is separate and simpler: Certificate.block_id and .height are covered by no signature at all — the message is rebuilt from .position — so an honestly signed certificate re-labels to any block. |
||
|
|
fd6fb47216 |
consensus: clear the remaining lints, one default apiece
Mechanical. QuasarEngine's inherent default() shadowed the standard trait method, so it is a Default impl like the other three; the rest is cargo clippy --fix (or_insert_with to or_default, map_or, sort_by_key, redundant borrows). clippy --all-targets is now silent. |
||
|
|
168ac7d518 |
focus: state the beta rule once, and stop advancing the phase in a getter
Wave and Focus each carried their own copy of the confidence fold — deepen on agreement, restart on opposition, reset when there is no quorum, decide at beta. Two copies of the rule that decides when a block is final. They differ only in how a round is read: Wave counts votes against a threshold, Focus compares a ratio to alpha. So that is what they keep. A round now produces a verdict — for, against, or neither — and focus::accumulate folds verdicts into confidence in one place. Sampling, tally and confidence come apart, which is what they always were. get_threshold was a getter that incremented self.phase, so asking what the threshold was changed it, and the FPC schedule advanced once per vote because check_consensus asks on every vote. It is now a pure threshold() and an explicit advance_phase(). The behaviour is unchanged — a phase is still a vote — but the reason is now visible and named: this engine has no round boundary, and FPC draws per round. tests/confidence.rs pins the reset in particular. A counter that only climbs would let a block accumulate agreement across rounds that disagreed in between, so beta would no longer mean consecutive; a thousand alternating rounds must never decide, and now demonstrably do not. |
||
|
|
9b217a7037 |
consensus: delete what was saying the same thing twice
Nothing here changes what the crate decides. The three network presets each restated all twenty configuration fields, so mainnet and the default could drift apart silently. They are now stated as what they change: mainnet is the default with a committee of 21, a seed, and a higher security level, which is what it always was. Four FPC seeds sat in four places, and the default was not the seed Go defaults to — so a default Rust node and a default Go node drew different theta schedules. There is one named constant per network now, and the default is Go's. Luminance::default re-hardcoded the same five numbers the default configuration already held; it reads the configuration. FpcSelector likewise. Both are Default impls rather than inherent methods that shadow the trait, and QuasarConfig's Default no longer calls a same-named inherent method — that only avoided being infinite recursion because inherent methods win, which is not a thing to rely on. Deleted: the Config and Chain aliases kept for backward compatibility; 33KB of tests parked as .old and excluded from the package; a build script whose own comment said no Rust code references the C symbols it linked, and the cc build-dependency it needed; an unread field on PhotonSampler. The crate builds with no warnings. The benchmark had not compiled since the crate stopped being an FFI wrapper — it imported five types that no longer exist. Rewritten against the API that does, and it now measures the part that costs: verifying signatures, not inserting into a HashMap. BENCHMARK_RESULTS.md, whose own header retracted its numbers as a u8 overflow bug against that removed API, is deleted; the benchmark is the source of truth. LLM.md carries the finding worth keeping — the aggregate check is flat in committee size where the per-vote check is linear, and they answer different questions. |
||
|
|
511265b10c |
quasar: count only members, and certify only what is signed
Three holes in the probabilistic engine's finality side, all of the same shape: something that looked like evidence was accepted as evidence. record_vote consulted no validator set. Nine unregistered node ids could carry a block to Accepted against an engine holding no validators at all, because the sample was whoever sent a message rather than the committee. Membership is now required, and an empty set counts nobody. create_certificate counted ballots and aggregated whatever bytes it found. Its aggregation could not have worked — it read 48 bytes of a signature that is 96 — so it fell back to a zero aggregate, which the structural check then accepted. It now verifies each signature under the voter's registered key over the position's canonical message, and issues nothing when there is nothing to issue. Unsigned ballots yield NoQuorum rather than an empty certificate. verify_certificate checks that aggregate against the aggregate of the signers' keys, over the message rebuilt from the certificate's own position — so a certificate carries the position it attests, because without it a holder can only check the shape. quantum_sigs was filled by cloning the BLS signature under a second name, so a certificate appeared to carry post-quantum evidence it did not have. It is empty until there is a post-quantum signer. The validator set is now one structure, cert::ValidatorSet, holding membership, stake and keys together. A member with no key still counts toward the set size and total stake — which raises the bar for everyone else — and can never contribute a verified signature. test_consensus_flow voted with zero validators registered and asserted success, so it encoded the first hole. It now registers the committee it votes as, and a new test pins the refusal. |
||
|
|
40330e2427 |
cert: check Go's signatures, not only our own
tests/cert.rs signs and then accepts its own signatures, which would hold just as well if the domain tag, the group assignment or the point encoding were all wrong in the same direction twice. These vectors come from the implementation the network runs — luxfi/ crypto/bls signing chain.CanonicalVoteMessage — so a Go public key is registered here, a Go signature verifies under it, and a certificate every one of whose signatures Go made is accepted by this predicate. That is a Rust node and a Go node accepting each other's votes rather than each other's shape. The header of the file records the program that regenerates them. |
||
|
|
ab64bc35d2 |
cert: verify the signatures a certificate is made of
The crate could aggregate BLS signatures but never checked one. The structural check ended `!cert.aggregated_sig.is_empty()`, and an aggregate of nothing is a run of zero bytes, which is not empty — so a certificate carrying no signature at all verified, and so did one carrying 0xAB repeated. It could not have checked them as written: min_pk puts the public key in G1 at 48 bytes and the signature in G2 at 96, and the code had the two the other way round, so every real signature failed to parse and fell back to the zero aggregate that then passed. This is the Go predicate, clause for clause, from engine/chain/cert.go: distinct voters in ascending order, every vote an ACCEPT, every signature verified over the canonical message derived from the certificate's own position, and the count met — then the tier's stake floor, recomputed from the live set so a certificate cannot declare its own threshold. Nova carries a signer floor beside the stake majority, because one holder of a stake majority must not ignite alone. Go opens Verify with a nil check on the verifier, since a certificate nobody can check must never pass. Here the verifier is a &dyn, so that clause is discharged by the compiler and cannot regress. tests/cert.rs takes a valid certificate and breaks one thing at a time, including the four forgeries the old check accepted, and flips every bit of all 226 message bytes to show what the signature is bound to. |
||
|
|
7c48b5e644 |
fpc: compute the threshold with SHA-256, the hash the network uses
theta decided the size of the majority a block needs, and it was derived from a hand-rolled SipHash-style mixer under a comment claiming SHA-256. Against the Go selector on the same seed, no phase agreed: phase 0 read 0.51596246540797 here and 0.70781556272112667 on the network, and only 2 of 20 sampled alphas matched. Nothing is attacking in that story. Two nodes simply demand different majorities of the same committee, and split. blst already links a SHA-256, so the crate gains one hash and loses sixty lines of mixing rather than gaining a dependency. tests/fpc_conformance.rs pins theta bit for bit against values captured from the Go implementation, and pins sha256 itself to the FIPS 180-4 vectors so the primitive cannot later be swapped for something that merely agrees on today's phases. |
||
|
|
7a1591ee63 |
feat: update consensus v1.22.1 - context improvements and TypeScript SDK
- Add context extension methods for uptime and node info - Add NoOpCalculator for uptime in test contexts - Update locked calculator with proper interface compliance - Remove experimental quasar event horizon duplicates - Add TypeScript/NAPI SDK bindings for consensus - Update Rust bindings and tests |
||
|
|
b433ac3c53 |
fix: resolve all build and test issues
- Fixed cmd tools to use new consensus API (NewChain instead of NewChainEngine) - Removed deprecated Rust tests that use old API - Fixed golangci-lint configuration - Applied gofmt formatting to all Go files - All Go tests passing - All Python tests passing - Basic Rust tests passing |
||
|
|
4bbeffe36e |
fix: update all tests and commands to use new consensus API
- Fixed cmd/checker and cmd/consensus to use new Engine interface - Fixed integration tests to use GetConfig instead of Config() - Updated Python SDK tests for new Chain API - Added basic Rust tests that pass - Fixed import paths from luxfi/node to local packages - Moved test files to proper directories |
||
|
|
14bbfe3847 |
test: fix integration test finalization logic
Fixed SimpleConsensus.Vote() to set finalized=true even when block is already accepted. Previously, only the first node to vote would finalize, causing tests to fail with only 1/N nodes finalized. Changes: - Modified Vote() to separate block acceptance from finalization - Now sets finalized=true for any valid block, regardless of status - Added network-wide finalization check with 50% threshold - Implemented parallel polling (10% of nodes per round) for faster convergence Results: - TestRandomizedConsistency: PASS (1 round convergence) - TestPropertyBasedConsensus: ALL PASS - TestConsensusAtScale: PASS (up to 1000 nodes) - TestChaosConsensus: PASS Outstanding: TestNetworkScalability and TestDAGConsensus use different simulation framework and require separate investigation. |
||
|
|
2f4d6b5d9e | Update copyright | ||
|
|
fee6b3564f | Update and re-organize |