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]>
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]>
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]>
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]>
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]>
`#[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]>
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]>
`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]>
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]>
`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.
`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.
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]>
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]>
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]>
The Quasar rung read only stake. Two thirds of the stake is one signature
wherever two thirds of the stake is one validator, so a whale holding a
supermajority could mint an export-grade certificate alone — and export-grade
finality is what a bridge, a settlement and a cross-chain message admit on. A
certificate one key can produce is not a claim that a Byzantine supermajority
agreed, however much weight stands behind the key.
VerifyWeighted now demands config.TwoThirdsCount(n) = floor(2n/3)+1 DISTINCT
signers of a Quasar certificate as well as the stake. Two independent
predicates, neither sufficient alone, the same shape Nova has one rung up.
TwoThirdsCount is the count that value has always been, given its own name and
one home. It is derived from TwoThirdsStakeFloor over unit weights rather than
restated, so the count and the stake predicate cannot drift. It replaces three
spellings of the same closed form that had grown apart in reach if not yet in
value: config.EqualStakeSupermajorityThreshold (the alpha sizer), the private
chain.quasarQuorum (the attestor's assembly trigger), and the clause that was
missing from the verifier entirely.
Stake is read first and the count second. Both must hold, so the order names
only which clause a refusal reports: on an equal-stake set the two are one bar
in two units and bind together, and the count clause binds ALONE only where the
weights are lopsided — so reaching it means the stake was there and the signers
were not. Every verdict the R3 corpus froze is unchanged.
Liveness. Nothing that exports today stops exporting. QuasarAttestor, the only
producer of export certificates, already assembled at exactly floor(2n/3)+1
attestations, and FeasibleParams already sized alpha — the floor
HandleIncomingCert applies to a gossiped Quasar cert — to the same number.
Producer, gossip door and verifier now read one function, so the verifier's new
clause refuses nothing the engine can build and exactly what it never could.
quasar_count_floor_test.go holds all three to that.
The tests that moved were the ones written on three seats, where floor(2n/3)+1
is three and no two-signer certificate can isolate the stake half any more.
They ask the same question on four.
Rust: the same clause in Cert::verify_quasar_supermajority, the same name.
1.22.4 -> 1.22.5 — this changes what the crate accepts.
Also, at the already-admitted door: the zero-weight refusal is justified by
agreement with Register, not by the phantom-signer argument alone. A seat whose
key does not decode can never sign and is refused anyway, because the weight is
read before the key is — and deferring it would make admission depend on which
keys a node's crypto build can parse.
Co-authored-by: Hanzo Dev <[email protected]>
Register refuses a validator admitted with no stake, because a keyed seat
carrying zero weight is a phantom signer: it raises the count of distinct
signers a floor is read against and contributes nothing to the stake the same
certificate is weighed by. FlattenValidatorSet admitted it, so the clause the
two doors are supposed to share meant one thing at registration and another on
a set the chain had already admitted.
The corpus said so before the code did. AdmissionCase already records that "the
weight clauses are common to both" doors, and the transcribed rule in
verdict_test.go already demanded a zeroWeight refusal for any case carrying a
weightless seat, at either door — there was simply no case exercising this one.
There is now, produced by the live function, and it fails against the old door
on the golden AND on the transcribed rule independently.
Refused, not skipped, and that is not the trade this door makes for an
undecodable key. Skipping a bad key costs a signer the set was going to lose
anyway; admitting a weightless keyed seat is a signer the set gains for free.
A KEYLESS seat with no stake is untouched: it is skipped before the weight
clause, never becomes a signer, and neither signs nor weighs — the leniency this
door keeps, and the one Rust's insert_unkeyed keeps for the same reason.
Placed where Register places it and where Rust's ValidatorSet::insert places it:
after the key is known to be present, before it is decoded. A shaped key is
therefore enough to reach the clause on either side, which is what lets the Rust
runner answer this row instead of skipping it as it must skip the other two at
this door.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
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]>
`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]>
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]>
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]>
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]>
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]>
Cargo.toml said 1.22.1, and 1.22.1 is on crates.io — published
2026-05-13, unyanked, 235 downloads. That published build is the one
whose verify_certificate ends
!cert.aggregated_sig.is_empty() || !cert.quantum_sigs.is_empty()
so it accepts a certificate carrying no signature at all. Leaving this
tree at the same number says the fix and the forgery are the same
release. They are not. 1.22.2.
The removal of the aggregate path changes the public API, so whoever
publishes may prefer 2.0.0; the number here is the smallest one that
stops the collision.
Co-authored-by: Hanzo Dev <[email protected]>
QuasarSignature had no constructor, no consumer anywhere in the workspace,
and a comment saying a BLS signature is 48 bytes. It is 96 — 48 is the
public key — and that exact inversion is what produced the certificate
that verified over no signatures at all. A dead type is not worth a
correction, so it goes.
Go's protocol/quasar.QuasarSignature is a different and live thing (BLS,
Corona and ML-DSA legs); this was not a port of it.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Remove impossible/mislabeled per-second figures from consensus docs: the
"3.9B blocks/sec" / "6.6B votes/sec" u8-overflow artifacts (real C-FFI ≈21K
votes/sec per the CI README), the 20.26M/11.5M "TPS" that are ZAP wire-message
microbenches (relabeled, not consensus finality), stub-SDK "sub-second finality"
claims, and downgraded "production-ready" labels on SDKs the CI audit documents
as stubs. Genuinely-measured go-test numbers left intact. Fixed stale
LUX_CONSENSUS_PQ_MODE → CONSENSUS_PQ_MODE. Docs only, no Go logic touched.
Co-authored-by: Hanzo Dev <[email protected]>
Reverts violations of the durable rule that current-state docs belong
in LLM.md and history belongs in git log. Removed files were session
handoffs, agent-style "complete success" / "1000%" reports, dated
audit dumps, and stub NOTES.
Removes Corona references from docs, Rust SDK type names, Python
SDK signature scheme tags, and the audit ledger. Corona is the
canonical name for the R-LWE threshold primitive in the Quasar
consensus stack alongside Pulsar (Module-LWE) and Magnetar (hash).
Rust changes: rename corona_sig → corona_sig, corona_pubkey →
corona_pubkey, collect_corona_signatures → collect_corona_signatures.
Python changes: rename SIG_CORONA → SIG_CORONA.
The Go-side Corona purge (commits 045c944d, f6753f3b, d1c8a2cc) was
not propagated to pkg/rust prose. README, Cargo.toml description, and
the top-level //! docstring in src/lib.rs still said "BLS12-381 +
Corona" or "BLS + Corona signatures" while the Go consensus stack
renamed to Pulsar (Module-LWE) + Corona (Ring-LWE) per LP-4200.
Update README + Cargo.toml description + lib.rs //! header to use
"Pulsar (Module-LWE threshold, FIPS 204 byte-equal)" — matching the
canonical naming in consensus/CLAUDE.md.
Cargo.lock 1.22.0 → 1.22.1 was already in the working tree from the
pending version bump; commits alongside this prose alignment so the
next crates.io publish carries both.
NOT renamed: public API field names (corona_sig, corona_pubkey,
collect_corona_signatures, etc.) — breaking surface renames need a
deliberate decision and a coordinated minor/major bump. Prose
alignment is the safe patch.
`cargo check --no-default-features` PASS.
Quasar is the post-quantum finality layer (hybrid BLS12-381 + Corona = P3Q)
on top of the Wave/FPC/Photon/Focus probabilistic consensus stack. BFT is
not the property Quasar provides; PQ-secure probabilistic finality is.
Bumped to 1.22.1.
Metadata polish for the public registry: correct `license = "MIT"`
(wrong) to `license-file = "LICENSE"` with the Lux Ecosystem License 1.2
vendored in-crate, sharpen the description (Quasar BFT, Wave/FPC/Photon/
Focus), add keywords/categories/homepage/readme/authors, and exclude
the historical `tests/*.old` snapshots from the published tarball.
build.rs: the Rust source has zero `extern "C"` symbols and never
actually consumed the sibling `libluxconsensus` C library, but the
unconditional `cargo:rustc-link-lib=luxconsensus` would break every
downstream consumer building from crates.io. Guard the link directives
on the static/dynamic library actually existing on disk so in-tree
development is unchanged and crates.io builds succeed cleanly. Skip
the link step entirely under `DOCS_RS`.
Published: https://crates.io/crates/lux-consensus/1.22.0
- Mark doctest as no_run to avoid Result type conflicts
- Fix ID construction to use full 32-byte arrays
- Update to use underscore for ignored results
- All Rust tests now passing (doctests + unit tests)
This fixes the final CI failure.
- 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
- 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