mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
main
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
731dc22f23 |
cert: a floor is read once, where it is authoritative
Red found no way past the predicate — every refusal was fail-closed. What it found instead was the opposite failure, three times over: a number about a quorum read somewhere that is not the authority for it. THE DOORS IN FRONT OF THE GATE. HandleIncomingCert and VerifyCatchupCertificate each carried a cheap sub-quorum filter over the sample committee — NovaQuorum(K) for Nova, Alpha() for Quasar — in front of verifyCert. That is a THIRD spelling of a quantity with one definition, and it is a different quantity: the gate derives the accept rung's floor from the SIGNING SET, where NovaSignerFloor saturates at three, while the door read the committee's majority, which grows with K. At six seats the gate accepts a certificate declaring three and the door demanded four; at nine it demanded five; at eleven, six. And where the set is SMALLER than the committee the door asked the set for more than the set's own floor: three signers derive two, and a door reading a committee of four asked for three. So the exact bytes a node's own assembler produces never reached the predicate that would have admitted them. A door in front of a gate may only refuse what the gate refuses, and these could not, so they are gone. Nothing is lost by not asking twice: the gate demands EQUALITY with the derived floor, which is strictly stronger than the "at least" a door applied. Nor is any work saved by refusing early — the certificates a floor filter drops are the ones carrying FEW votes, which are the cheap ones, and Verify stops at the first signature that fails. THE CALLER'S ALPHA. BuildVerifiedQuorumCert took the floor as an ARGUMENT, and on the count-only road nothing re-derived it: one vote and an alpha of one minted an authority token on a chain of five, while a certificate carrying those exact bytes was refused on arrival. The docstring said an alpha that disagrees with the set yields no token, and that was true only of the weighted road. So there is no argument. The floor is SignerFloor(tier, SignerCount(epoch)), read at the door, and there is nothing left for a caller to disagree with. With that, a nil stake source is refused at BOTH rungs rather than at the export rung alone: a floor is a property of the set, and with no set there is no floor and nothing to mint. exportNeedsStake goes with it — one rule where there were two, and the rule Rust and C++ already have by their types, each taking a set by reference so the absent case is unrepresentable rather than checked. The equal-stake road keeps minting through the engine, whose committee is its own, and keeps ADMITTING on the arrival road, where signatures have already arrived to be weighed. THE WIDTH. SignerFloor and Quorum returned uint32 while counting a set held in an int, so the floor wrapped at about six billion signers and SignerFloor(Quasar, ~6.4e9) answered 1 — a floor a lone signer meets. They return int now and are narrowed only where a certificate is written, which is one place; and because the derived clause compares at full width, a mint that narrowed is caught by it rather than believed. THE LAST SECOND SPELLING. attestation.go's export trigger said config.TwoThirdsCount(n) where it means SignerFloor(Quasar, n). Same number, and that is the point: a trigger spelled apart from the rule it must never outrun is a trigger that can drift from it. TestTheDoorAdmitsWhatTheGateAdmits walks the four sizes where the two numbers part, on both arrival roads, and fails on the old filter at every one. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
287b89c64f |
cert: derive the floor on the road a certificate arrives by, too
The rule landed at the door that MINTS a token from raw votes. Auditing the three sites that promote a certificate to that token found the same hole one function away, on the road a certificate ARRIVES by: with no stake source, verifyCert fell to the count-only Verify, whose last clause counts distinct valid accepts against the number the CERTIFICATE declares. So a gossiped Quasar certificate declaring a quorum of one, carrying one signature, was promoted and finalized. The two doors answer differently, and the difference is the threat rather than an oversight. Minting takes the floor from the CALLER's alpha, so with no set there is nothing to check the caller against and the export rung is refused outright. Admitting one that arrived checks N real signatures from N distinct in-set validators; what was missing was only that its declared quorum was its own. So the floor is derived there too, from the node's live committee — which is that road's authoritative view of its set. Refusing the rung outright instead would retire the equal-stake deployment, which is a larger decision than this clause is. THE PRICE, stated because it is real: with no stake model the committee is a CONFIGURED number, so that road now needs the fleet to agree on it. Before, any declaration was admitted and a node with a different K interoperated by accident. That is the cost of not letting a certificate name its own quorum, and it is why the minting door refuses the rung rather than weighing it — a configured committee is strong enough to CHECK a declaration and not strong enough to ORIGINATE one. And the count-only road reads a DIFFERENT derived number, which is the trap this change nearly walked into. SignerFloor is the distinct-signer guard the weighted road carries beside a stake clause; Quorum is the count that IS the predicate where no stake clause stands beside it. They agree at Quasar and diverge at Nova past five signers, because NovaSignerFloor saturates at three — correct as a guard on a road whose majority is carried in stake, wrong as a majority on a road that has only the count. assembleCertLocked already declares NovaQuorum on that arm, so reading SignerFloor there would make a node refuse its OWN certificates at six signers and up. Every committee the fixtures use is four or five, exactly where the two agree, so nothing in the suite would have said so: TestTheTwoRoadsDeriveTheNumberTheyDeclare walks 1..64 instead. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
8d94ad1e03 |
cert: derive a certificate's quorum from the set, never from the certificate
A certificate carries a Threshold, and until now that field was its own authority on the count-only road: Verify's last clause counts distinct valid accepts against the number the CERTIFICATE names, so one declaring 1 clears it on a single signature. C++ has always demanded the derived floor exactly, so the same certificate was accepted by two implementations and refused by the third. VerifyWeighted now requires Threshold == SignerFloor(tier, n) — the floor this set gives this rung. Equality and not a lower bound: an over-claim names a bar this set does not set, and tolerating it would let a certificate redefine the rung upward exactly as tolerating an under-claim lets it redefine the rung down. SignerFloor is the one definition of a rung's count floor, read by the rung predicates, by the assemblers and by the new clause, so the number enforced and the number declared cannot drift. Two corollaries the rule forces: No set, no export. BuildVerifiedQuorumCert refuses any tier that authorizes export when no stake source is supplied. That road counts votes against the caller's own alpha, so an export token could be minted over one signer with alpha of one — and AcceptWithCert trusts the token, which is the point of the token. The accept rung keeps the road: it authorizes local execution the chain can still reorg away. The assembler reads the same n the verifier does. assembleCertLocked derived Nova's declared floor from effectiveCommittee, which sizes the SAMPLE and is floored at the minimum Byzantine committee. At three signers the sampler said three and the set said two, so a node would have built a certificate its own peers refuse. The export path already read SignerCount. The corpus grows five rows — three that declare a quorum of their own, and two lopsided sets where the stake half binds alone, which an equal set cannot state because both halves bind at one edge there. Two frozen verdicts change the clause they are NAMED by, from the stake floor to the count, and neither changes from refused: over an equal set a certificate short of its stake is also short of the quorum it now has to declare. The rungs' own count clauses are unreachable as a result — Verify proves count reaches the declaration and the new clause proves the declaration is the floor — and are kept as the place the floor is defined and as the guard that survives any change to the declaration rule. Written down in LLM.md beside the other walls behind walls. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
43c74b012d |
go: name every refusal, then check that the named one is the one given
QuorumCert.Verify returns a different typed error for each clause of the finality
predicate, precisely so one refusal can be told from another. Fourteen are
declared. Four were ever named by a test — the two the R5 floors turn on, plus
ordering and a bad signature — and the other ten had nothing asserted on them at
all: nil cert, nil verifier, version, role, unknown tier, zero threshold, no
votes, a non-accept vote, the nova stake floor. Every one of those clauses would
have passed with its body replaced by `return errors.New("no")`, which is the
shape of test that measures nothing.
So every clause now has a row: a certificate proven valid by the control above
it, one field changed, and the error that clause owes. Wrong version, wrong role,
a tier that is neither rung, a threshold of zero, no votes, a duplicate voter,
voters out of order, a reject inside a finality cert, a signature over another
position, a voter nothing can resolve, a quorum larger than the votes carried —
and on the weighted side, an unresolved set, a set below the minimum Byzantine
committee, signable stake of zero, the seats without the stake and the stake
without the seats. The same table exists in Rust and in C++; the three are meant
to be read side by side, because three implementations refusing one certificate
for three reasons is a fork waiting for the cert they disagree about.
The fifteenth name, ErrQCVotePosition, is returned by nothing. The var block's
comment says each maps 1:1 onto a clause; that one maps onto no clause, because a
vote that signed another position fails as a bad signature — the message is
rebuilt from the CERT's position and never from the vote's. It is left alone here
and reported rather than covered, since no input reaches it.
The proof-of-possession rejects follow the frozen corpus and go past it: the
class is asserted, not merely that something failed. Encoding before possession
is what keeps a pairing off a point that was never a point, and a test that only
checked for non-nil would pass with the order reversed. The corpus has no
49-byte-key case; this side carries one, because the clause is `!=` and a
verifier guarding only the short side would check a proof against a PREFIX of the
published key.
Elsewhere: the parameter doors (every clause, and zero still meaning unset),
the corpus's own stake seam (three projections over the signers, the fourth over
the roll), the two arithmetic doors that must not wrap, the warp filter/sum/
aggregate read as the step a forged message would attack, the vote frame's
refusals including a four-gigabyte signature that is not there, and the durable
guard: what survives a restart, what a floor that could not be written must not
report, and every shape of file that is present and does not decode.
engine/chain 87.2 → 89.7 · validator 84.7 → 98.3 · pop 85.3 → 91.2
config 99.0 → 100 · conformance 93.8 → 97.4
Of the named core, cert/finality/quorum/quasar/descent/guard/verified_cert are
whole; what remains there is four statements no caller can reach, written down in
LLM.md so the next reader does not spend an afternoon each on them. The rest of
the gap is engine.go and the VM/network integration surface, which wants
multi-node harnesses rather than unit tests.
Tests only — no consensus logic moved.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
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]> |
||
|
|
5639cf92e9 |
consensus: re-cut the golden corpus for the seed encoding, and say so in the standard
TestCorpusMatchesGolden caught the change, which is what it is for: 472 values moved, all of them under /fpc, nothing added, removed or restructured. The vote message, the certificate, the ladder, the count thresholds and the committee are byte-identical, so the encoding change reached exactly the plane it was meant to and no other. Two of the moved values are the reason this is a protocol change and not a refactor: /fpc/thresholds[1]/alpha went from 3 to 4. A round at that seed and phase now accepts on one more vote. Freezing the encoding before anything runs it is the whole point of doing this now. standard.mdx carried the old preimage, and a standard that is quietly wrong is worse than none. It now shows the domain and the lengths, and says what they buy: without them the boundary between the chain id and the parent slides, and a chain that names itself chain_id‖H derives what chain_id derives at parent H — which trades away the input nobody can know before the previous epoch finalizes, the input the whole construction exists to bind. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
b41c532fa4 |
rust: derive θ with SHA-256, and verify the signatures
Two implementations of one protocol disagreed about when a round accepts
and about what a certificate proves, and nothing in the repository said
so because each agreed with itself.
θ. The selector's doc comment said "PRF (SHA256)" and the code called a
SipHash-like mixer. Go's protocol/wave/fpc computes θ with crypto/sha256,
and α = ⌈θ·k⌉ is the vote count a round accepts on, so the two nodes
applied different accept counts to identical votes: at phase 0 with k=20
the mixer returns 11 where Go returns 15. Measured both ways — the mixer
is spliced back in under tests/conformance.rs and the check fails on θ
before it reaches α. The seed was worse than the PRF: three hardcoded
ASCII constants, public forever, so every future threshold was readable
in advance. derive_epoch_seed is Go's DeriveEpochSeed, which binds the
previous epoch's block hash and is therefore unknown while the current
epoch is still open.
The phase now advances once per counted round, past the k-vote gate,
which is where Go advances it. It was advancing once per vote, k times
too fast, so two nodes one round apart read θ from different phases.
Signatures. verify_certificate ended in
!cert.aggregated_sig.is_empty() || !cert.quantum_sigs.is_empty()
and the aggregator it asked about returned vec![0u8; 48] whenever it had
nothing to aggregate. Zero bytes are not empty. It had nothing to
aggregate on every input, because it sliced each signature to 48 bytes
and handed it to a G2 decoder: luxfi/crypto/bls signs under
BLS_SIG_BLS12381G2, so a public key is 48 bytes and a signature is 96,
and this crate had the two the other way round. So forty validators
voting with no signature at all produced a certificate that verified,
and there was no input for which it did not.
finality::Cert is engine/chain.QuorumCert — the position, the rung, the
threshold, and one signature per named signer. It encodes to the bytes
the network gossips, decodes strictly, and verifies Go's seven clauses:
version, role, tier, non-zero threshold, non-empty votes, strictly
increasing signers, accept-only, and every signature checked against a
registered key over the message the cert's OWN position derives. A
signature does not travel between positions. verify_stake adds the rung's
stake predicate, re-derived from the live set rather than read from the
cert, so a Nova quorum relabeled Quasar fails; an unresolved set fails
closed.
The engine no longer issues certificates. Its block carries an id, a
parent, a height and a payload, and a certificate binds a chain id, a
round and three roots — nothing it can assemble is a statement the
network signs, so what stood there manufactured finality out of a
position it did not have and votes it had not checked.
The corpus carries the rule now: 8 epoch seeds and 192 thresholds read
from the live Go selector, θ as exact IEEE-754 bits because α is a
ceiling and the last bit decides. Certificates are compared over their
whole wire form; the previous check read four header bytes.
Also removed: build.rs linked a C library no Rust symbol references, and
libc was unused. BENCHMARK_RESULTS.md reported a benchmark that has not
compiled in a long time — it imported five names this crate does not
export — so the benchmark is rewritten against the real API and the
retracted report is gone.
33 tests, no warnings.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
a91b81b959 |
consensus: write the finality standard down as data
The C++ implementation signs 70 bytes under "LUX/chain/vote/v1\0"; this one signs 226 under v2. Neither side could ever verify the other, and nothing in the repository said so. The rule was only ever expressed as Go, so the only way to check another language against it was to read it and retype it — which is how the two drifted. conformance/corpus.json is the standard as data: the signed message, the certificate wire form, both stake floors across the full u64 range, the ladder's authorizations and the committee parameters, all read from the live definitions rather than restated beside them. An implementation in another language is conformant when it reproduces that file. A golden file alone would be weak — it can be re-blessed with -update and the change vanishes. So the load-bearing facts are also stated outright, transcribed from the spec and sharing no code with the encoder: the message is 226 bytes, the tag is the one the network uses, the two-thirds floor recomputed in arbitrary precision, and every signed axis moved one at a time to prove it is bound. Re-blessing the golden does not get past those. CanonicalRejectMessage is exported alongside it. The engine has always signed reject votes, but only the accept half could be reached from outside the package, so only half the standard could be written down. Co-authored-by: Hanzo Dev <[email protected]> |