388 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 937578257d chain: the export rung's committee floor, on the road a certificate arrives by
verifyCert's count-only branch verified a certificate and checked its declared
quorum against the number the committee derives, and read nothing else against
that committee. The derived clause cannot see a small one, because it shrinks
with it: TwoThirdsCount(2) is 2, so two signatures over a two-member committee
ARE that committee's quorum and the certificate passes as the one this node
would have built itself. What passed was export finality — irreversible, the
rung bridges read — over a committee whose Byzantine budget f=⌊(n−1)/3⌋ is zero,
where every signer is load-bearing and one compromised key is not a fault
absorbed by a margin but a forged certificate.

It did not stop at the node that admitted it, and that is the part that made it
worth a clause. TryAccept hands the accepted cert's bytes to GossipCert, and
applyBranchFinalization files them as the proof served to stragglers on catch-up.
So one admission turned a stake-less node into a second source for a certificate
carrying no fault budget, and every node it convinced became a third. The floor
stops that at each hop, which is why it belongs at the arrival gate and not only
at the accept.

The clause is the one verifyQuasarSupermajority already holds on the WEIGHTED
road, read here against the committee because that is this road's authoritative
view of its set — the same sentinel and the same TwoThirdsCount number, so the
two Go roads refuse the same certificate by the same name.

Both roads are Go's, and that is the whole comparison. No other implementation
has a stake-less accept road for this clause to exist on: Rust's Cert::verify is
the structural predicate its weighted road runs first, never an accept rule on
its own, and C++'s QuorumCertEngine::verify_cert fails closed on an empty stake
model before it reads a vote. Go alone accepts with no stake source, so Go alone
needed this, and it is Go's own weighted road it is brought up to.

The predicate is AuthorizesExport, not an equality on Quasar. The weighted road
is fail-closed by construction — its tier switch ends in ErrQCUnknownTier — and
an equality here would be allow-by-omission: Horizon sits above Quasar on the
ladder, so the day it becomes attestable a Horizon cert would cross this road
with no floor at all. Behaviour is identical today, because Verify refuses any
tier that is not Nova or Quasar before this line; the rung's own predicate makes
tomorrow safe by construction rather than by review.

Nova keeps no such floor and must not grow one: it authorizes only local
execution the chain can still reorg away, and a small chain has to be able to
make local progress. The Nova case pins that.

Not reachable on a stake-wired chain, which takes the weighted road, and not
mintable — quasarAttestor, the one Quasar producer, is wired only when a stake
source is present, so a stake-less chain has no export cert of its own. It had
to ARRIVE. Fail-closed once closed, at no cost to the deployment that has always
run Nova-only.

TestExportCommitteeFloorHoldsOnTheCountOnlyRoad is the regression, on the road
TestExportNeedsAByzantineCommittee could not reach: a stake-less engine whose
committee is exactly n, and a certificate unanimous over it declaring the quorum
it derives, so nothing but the committee can refuse it. It admits at n of one,
two and three without this clause.

Also widens the reject table's G2 identity to its real width. At 95 bytes it
died at the proof-width gate and never reached the decode it exists to test — a
green duplicate of the short-proof row above it. At 96 it is refused where the
frozen corpus records the refusal.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 15:13:37 -07:00
zeekayandHanzo Dev 18fe0f8f1c validator, cert: name the checked add the way that is not deprecated
luxfi/math v1.5.1 marks Add64 deprecated in favour of Add[uint64]. The two
are the same function by construction — safe.go declares the deprecated
name as `Add64 = Add[uint64]`, a var holding that exact instantiation — so
this moves no arithmetic: same overflow predicate, same (sum, error), same
refusal at the same boundary. It also drops a call through a package-level
func value, which nothing should be able to reassign.

Instantiated explicitly rather than left to inference. These three are the
overflow-refusal sites for stake: the two weight tallies Register and
FlattenValidatorSet run, and the cert tally votedStake runs. If a weight
field ever narrowed, `Add` would infer the narrower T and quietly start
refusing at a lower ceiling while the caller still believed it was guarding
a uint64; `Add[uint64]` fails at compile time instead. A tally that refuses
at the wrong ceiling is the fault these calls exist to catch.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 03:46:08 -07:00
zeekayandHanzo Dev 292d7b8cd6 cert: drop the refusal no clause raises
The var block above these errors says each one maps 1:1 onto a predicate
clause. ErrQCVotePosition did not: nothing raised it and nothing read it,
in any language, in any repo.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two corollaries the rule forces:

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

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

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

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

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-02 02:23:07 -07:00
zeekayandHanzo Dev d408ae3e1d runtime: answer an unset logger once, so no handler can be crashed by its absence
NewRuntime stored the caller's Logger as given, so a Runtime built without one
held a nil interface. Twenty-four sites in engine/chain read that logger with a
bare IsZero(); fourteen others ask `!= nil &&` first. Two of the twenty-four are
the first line of HandleIncomingVote and HandleIncomingCert, which is to say a
remote input reached a nil interface method call before anything validated it.

The engine already settles this for itself — NewWithConfig falls back to
log.Noop() when no logger is set — so the runtime constructor now does the same
thing in the same way. Runtime.config is unexported and written once, at
construction, so past NewRuntime a logger is always present and all twenty-four
sites are safe by construction rather than by twenty-four remembered guards.

Noop discards what it is given and reports IsZero, so an unset logger stays
exactly as silent as it was; nothing else changes.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 22:37:31 -07:00
zeekayandHanzo Dev 43c74b012d go: name every refusal, then check that the named one is the one given
QuorumCert.Verify returns a different typed error for each clause of the finality
predicate, precisely so one refusal can be told from another. Fourteen are
declared. Four were ever named by a test — the two the R5 floors turn on, plus
ordering and a bad signature — and the other ten had nothing asserted on them at
all: nil cert, nil verifier, version, role, unknown tier, zero threshold, no
votes, a non-accept vote, the nova stake floor. Every one of those clauses would
have passed with its body replaced by `return errors.New("no")`, which is the
shape of test that measures nothing.

So every clause now has a row: a certificate proven valid by the control above
it, one field changed, and the error that clause owes. Wrong version, wrong role,
a tier that is neither rung, a threshold of zero, no votes, a duplicate voter,
voters out of order, a reject inside a finality cert, a signature over another
position, a voter nothing can resolve, a quorum larger than the votes carried —
and on the weighted side, an unresolved set, a set below the minimum Byzantine
committee, signable stake of zero, the seats without the stake and the stake
without the seats. The same table exists in Rust and in C++; the three are meant
to be read side by side, because three implementations refusing one certificate
for three reasons is a fork waiting for the cert they disagree about.

The fifteenth name, ErrQCVotePosition, is returned by nothing. The var block's
comment says each maps 1:1 onto a clause; that one maps onto no clause, because a
vote that signed another position fails as a bad signature — the message is
rebuilt from the CERT's position and never from the vote's. It is left alone here
and reported rather than covered, since no input reaches it.

The proof-of-possession rejects follow the frozen corpus and go past it: the
class is asserted, not merely that something failed. Encoding before possession
is what keeps a pairing off a point that was never a point, and a test that only
checked for non-nil would pass with the order reversed. The corpus has no
49-byte-key case; this side carries one, because the clause is `!=` and a
verifier guarding only the short side would check a proof against a PREFIX of the
published key.

Elsewhere: the parameter doors (every clause, and zero still meaning unset),
the corpus's own stake seam (three projections over the signers, the fourth over
the roll), the two arithmetic doors that must not wrap, the warp filter/sum/
aggregate read as the step a forged message would attack, the vote frame's
refusals including a four-gigabyte signature that is not there, and the durable
guard: what survives a restart, what a floor that could not be written must not
report, and every shape of file that is present and does not decode.

  engine/chain 87.2 → 89.7 · validator 84.7 → 98.3 · pop 85.3 → 91.2
  config 99.0 → 100 · conformance 93.8 → 97.4

Of the named core, cert/finality/quorum/quasar/descent/guard/verified_cert are
whole; what remains there is four statements no caller can reach, written down in
LLM.md so the next reader does not spend an afternoon each on them. The rest of
the gap is engine.go and the VM/network integration surface, which wants
multi-node harnesses rather than unit tests.

Tests only — no consensus logic moved.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 22:19:35 -07:00
zeekayandHanzo Dev ac10e53f84 cert: give the export rung a floor on the SET, so a shrinking denominator cannot shrink past a committee
Reading both floors over the signers let a set shrink to its signers, and a rung
whose floors are read over n is satisfiable at every n. TwoThirdsCount(1) is 1, so
one signature over a one-signer set clears the count floor and the stake floor
together and mints export finality. Byzantine tolerance is f = ⌊(n−1)/3⌋, which is
ZERO for n of one, two and three: such a certificate absorbs no fault at all, and
one compromised key among its signers forges it outright.

So the export rung refuses a signing set below minBFTCommittee — the same constant
bftCommittee floors committee SELECTION at, read at the certificate instead of at
the sampler, and reused rather than restated. It is a floor on the SET, stated
beside the n<1 refusal because neither quorum floor implies it: both are read over
n and both shrink with it. Nova has no such clause and must not grow one — it
authorizes local execution the chain can still reorg away, so a small or
partitioned chain has to be able to make progress. A three-validator chain now
PRODUCES and never CERTIFIES; operators need n ≥ 4 to export with any fault
tolerance. Rust mirrors it as CertError::MinCommittee, which the corpus vocabulary
folds into Go's one belowThreshold class.

The transcription guard was reading the wrong denominator. verdict_test computed
n as len(c.Set), the membership ROLL, so a floor moved onto the roll would agree
with the guard and be re-blessed by -update — the second line of defence agreeing
with the regression it exists to catch. It now counts the seats that hold a key,
on the Quasar branch and the Nova one, and transcribes the committee floor as a
search for the least n whose fault budget reaches one rather than as the constant.
Proven by construction: with the corpus source reporting the roll as its signer
count and the golden re-blessed, the old guard passes and the new one fails.

The corpus could not have caught it either. quasar_keyless_third had three signers
on a roll of four, and ⌊2·3/3⌋+1 and ⌊2·4/3⌋+1 are both three, so its count half
was vacuous. Two rows now separate the denominators one at a time:
quasar_keyless_stake (six signers, a seventh member holding a third of the roll —
the count floor is five either way, so only stake decides) and
quasar_keyless_count (four signers, two members holding one unit each — the stake
clears under either reading, so only the count decides). TestKeylessRowsBind-
BothDenominators fails if either is lost or flattened.

quasar_keyless_third itself FLIPS to a refusal, and it is the row that states the
new clause: the recorded stake floor is 200 over the three hundred that can sign
and not 333 over the five hundred the chain carries, so the denominator IS the
signers — and the certificate is refused anyway, on neither quorum floor. Reading
the floors over the signers stops a spectator stranding a chain; it is not a way
for a chain with three signers to export. Every other frozen verdict is
byte-identical, and the admission section and the signed vectors are untouched.

StakeSource gains CarriedStake, and no floor may read it. SignerStake alone
decides a certificate correctly and cannot show the set shrinking underneath one:
a chain whose signable stake has fallen to a sliver of what it carries still
certifies at ⅔ of the sliver, and ResponsiveStakePct — voted over signer — stays
at one the whole way down. FinalityStatus.SignerStakePct is the number that falls,
and the one to alarm on. Rust has carried(), C++ has total_stake(); Go had lost
the projection when the denominator moved.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 20:52:28 -07:00
zeekayandHanzo Dev f4913a3fd7 cert: read both floors over the signers, so stake that cannot vote cannot strand export
A validator set may carry a member with no signing key. It holds stake and no
verifier will ever accept a signature from it — it is a spectator, not a party to
the vote. Both quorum floors were nonetheless read over the whole membership:
StakeSource.TotalStake summed its weight and ValidatorCount counted its seat.

That is a category error with an arithmetic consequence. Once keyless stake
reaches a third of what the chain carries, floor(2/3 * carried) is above the
whole of the signable stake, so the export rung cannot be reached even with every
signer in the set agreeing — permanently, with no misbehaviour anywhere. Past a
half, Nova goes the same way. The count floor strands the same way in seats: two
thirds of the roll can exceed the number of signatures the set is able to produce.

So the seam says what it means. TotalStake becomes SignerStake and ValidatorCount
becomes SignerCount, both read over the validators that can actually sign, and
Weight answers 0 for a keyless member as it already did for a stranger. The
rename is deliberate rather than a doc change: an implementer of the interface
has to confront the question, instead of silently keeping a denominator that
strands its own chain.

This is the two-thirds rule read over the right set, not a weakening of it.
Quorum intersection is untouched — two disjoint quorums each past 2/3 of the
signer stake would sum past the whole of it — and the fault budget simply stops
being spent on parties known in advance to cast no vote. A key absent at the
epoch is a known non-participant; BFT's third is a budget for the unknown ones.

Rust follows: ValidatorSet is both the verifier and the source, and its
insert_unkeyed door made this reachable through a public API. It now keeps
`signable` beside `total` — total still bounds admission, because a set whose
weights cannot be represented is refused whether or not the seat that overflows
it can sign, and signable is what a quorum is measured against. carried() names
the first for what it is.

C++ needed no change and is the reason to believe the shape: a Validator there IS
a public key and the engine's set is keyed by one, so a spectator has never had a
slot to occupy and total_stake_ was always the signer stake.

Corpus: quasar_keyless_third, the case the R5 TODO could not state. Three seats
of a hundred hold keys, a fourth holds two hundred and none; all three signers
sign and the export rung admits it on 300 of 300, where the roll denominator
would refuse it against floor(2*500/3)=333 forever. Every existing verdict is
byte-identical — the seat gains an omitempty keyless flag, so a set of ordinary
seats encodes exactly as before.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 19:07:15 -07:00
zeekayandHanzo Dev 9f2d755bb0 cert: the door is sized from K, the verifier from n — say which
TestQuasarCountFloor_SizerMatchesVerifier and LLM.md both claimed the
producer, the gossip door and the verifier ask for one count. What the
test holds is narrower and is the thing worth holding: the door's alpha
is TwoThirdsCount(p.K), so the door is sized by the function the verifier
enforces and neither is a second definition of the count.

The two are one number whenever the params were sized to the set that is
running, which is what FeasibleParams does. They part when the set has
moved since, and the new test states which way that cuts: TwoThirdsCount
does not decrease, so a set that GREW leaves the door the looser bar and
the predicate still decides — this clause cannot halt an export the door
admitted. A set that SHRANK leaves the door the stricter bar, which is
the runtime's gossip-admission bound and reads the same before and after
the clause.

Documentation and one test. No floor logic is touched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 18:22:29 -07:00
zeekayandHanzo Dev 82ab92a70f cert: give the export rung a distinct-signer floor, so two thirds means two thirds of the parties
The Quasar rung read only stake. Two thirds of the stake is one signature
wherever two thirds of the stake is one validator, so a whale holding a
supermajority could mint an export-grade certificate alone — and export-grade
finality is what a bridge, a settlement and a cross-chain message admit on. A
certificate one key can produce is not a claim that a Byzantine supermajority
agreed, however much weight stands behind the key.

VerifyWeighted now demands config.TwoThirdsCount(n) = floor(2n/3)+1 DISTINCT
signers of a Quasar certificate as well as the stake. Two independent
predicates, neither sufficient alone, the same shape Nova has one rung up.

TwoThirdsCount is the count that value has always been, given its own name and
one home. It is derived from TwoThirdsStakeFloor over unit weights rather than
restated, so the count and the stake predicate cannot drift. It replaces three
spellings of the same closed form that had grown apart in reach if not yet in
value: config.EqualStakeSupermajorityThreshold (the alpha sizer), the private
chain.quasarQuorum (the attestor's assembly trigger), and the clause that was
missing from the verifier entirely.

Stake is read first and the count second. Both must hold, so the order names
only which clause a refusal reports: on an equal-stake set the two are one bar
in two units and bind together, and the count clause binds ALONE only where the
weights are lopsided — so reaching it means the stake was there and the signers
were not. Every verdict the R3 corpus froze is unchanged.

Liveness. Nothing that exports today stops exporting. QuasarAttestor, the only
producer of export certificates, already assembled at exactly floor(2n/3)+1
attestations, and FeasibleParams already sized alpha — the floor
HandleIncomingCert applies to a gossiped Quasar cert — to the same number.
Producer, gossip door and verifier now read one function, so the verifier's new
clause refuses nothing the engine can build and exactly what it never could.
quasar_count_floor_test.go holds all three to that.

The tests that moved were the ones written on three seats, where floor(2n/3)+1
is three and no two-signer certificate can isolate the stake half any more.
They ask the same question on four.

Rust: the same clause in Cert::verify_quasar_supermajority, the same name.
1.22.4 -> 1.22.5 — this changes what the crate accepts.

Also, at the already-admitted door: the zero-weight refusal is justified by
agreement with Register, not by the phantom-signer argument alone. A seat whose
key does not decode can never sign and is refused anyway, because the weight is
read before the key is — and deferring it would make admission depend on which
keys a node's crypto build can parse.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 17:39:39 -07:00
zeekayandHanzo Dev c220da75b1 cert: sum the voted stake with a checked add
Both rungs decided by comparing a summed stake against a floor read off the
total, and both summed with a bare +. Go's + is modular, so a StakeSource
reporting weights past 2^64 did not fail the tally — it returned a different
number and the comparison proceeded. The wrapped value can land above the
floor: three voters at 2^63 wrap to 2^63, which clears floor(total/2) for a
total of 2^64-1 by one, and the certificate was accepted on arithmetic rather
than on votes. At the Quasar rung, which carries no signer floor, the tally was
the only thing between such a cert and export-grade finality.

Rust has held this clause all along (Cert::voted_stake, checked_add ->
WeightOverflow). This is the Go side of the same rule, written once as
votedStake rather than twice at the two call sites, and returning the
ErrWeightOverflow both admission doors already return.

Not remotely exploitable: Verify's strictly-increasing-signer clause refuses a
duplicated voter before the tally, and Register and FlattenValidatorSet both
refuse a set whose weights overflow, so no admitted set can reach it. Reaching
it is evidence about the source, not about the votes.

The tests state the wrap outright — the value the old loop produced and the
floor it cleared — before asserting the refusal, so they fail against the
unchecked loop rather than passing against anything that refuses everything.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 16:31:49 -07:00
zeekayandHanzo Dev bba3234534 consensus: a quorum of zero is not a quorum
The chain engine decides on `acceptVotes() >= alpha`, so alpha=0 makes that
predicate hold over an EMPTY accept set. Any arriving vote — including one
AGAINST the block — then marks it accepted with zero accept votes, and Poll's
`rejectVotes() >= alpha` fires on nothing too, rejecting every fresh block and
dropping it from the tips. Two partitioned nodes could settle different blocks
at the same height with no quorum and no cert.

alpha=0 was reachable because Config.Validate had no callers — New,
NewWithConfig, NewWithParams and NewRuntime all reached NewChainConsensus
unchecked — and because Parameters.Valid gated its alpha checks behind
`AlphaPreference != 0`. The one value that destroys safety was the one value
that skipped validation.

Three independent layers close it, each judged by ONE definition of a safe
quorum:

  - config.Parameters.ValidQuorum, split out of Valid, judges K and the two
    integer alphas: 1 <= alpha <= K (unconditional now), AlphaConfidence >=
    AlphaPreference (never enforced before, so preference=15/confidence=1
    passed), and the Byzantine overlap floor, also unconditional. Valid calls
    it, so the two cannot drift. The split lets the engine demand SAFETY
    without also demanding the descriptive Alpha ratio and block timings,
    which have no bearing on whether a quorum can fork.

  - Transitive.Start refuses to run unsafe parameters. The check belongs here,
    not in the constructors: options are applied AFTER config, so WithParams
    can replace params a constructor already validated. Start is the one point
    every engine passes through with its final parameters.

  - NewChainConsensus, the single place the running predicate is armed, pins
    (k, alpha) into k >= 1, 1 <= alpha <= k. A path that skips both checks
    still cannot arm a predicate satisfiable by nothing.

Reclamp gains the safety floor as well. It installs a quorum on a LIVE engine
from a DERIVED validator count, and used to accept any well-formed alpha —
including k=4/alpha=2, where two disjoint quorums overlap in nothing. The
supermajority the committee sizer derives clears the floor at every k, so no
legitimate re-clamp is refused.

Fixtures that encoded unsafe quorums are corrected rather than exempted.
params5 was K=5/alpha=3: two 3-of-5 quorums overlap in a SINGLE node, so one
faulty validator (f=1 at K=5) can certify both sides of a fork. It is now
K=5/alpha=4 — the Byzantine floor, and what FeasibleParams(Local, 5) derives
for the live nets — which makes params5Prod an exact duplicate, so it goes.
Two config cases declared K and Beta with no integer alpha at all and expected
Valid to pass; that is a zero-vote quorum, not an unspecified one. Catch-up
certs carrying four signatures now declare the 4-of-5 threshold they are, and
the forged-signature case declares a real threshold so it still fails on the
forgery rather than on a stale bound.

The single-validator path is untouched: K=1/alpha=1 satisfies the floor, needs
no vote verifier, and bftCommittee still leaves a genuine K=1 preset unclamped.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 12:01:23 -07:00
zeekayandHanzo Dev c9b9b94e99 validator: close the node axis, make the canonical order build-invariant
Red review of the oracle found the bytes unbreakable and the set-plane rule
around them incomplete. This closes it.

HIGH — one node, one key. Register enforced one-key-one-node but not the
converse: a slice could name one node twice under two keys, each with a genuine
proof, so possession did not catch it and one operator held several signer
indices and several shares of the weight under one identity. FlattenValidatorSet's
map input made that unrepresentable; a slice does not. A byNode set beside byKey
refuses it (ErrDuplicateNode), in the same pass, so the set is a set on both axes
— the rule weighted_merkle already states and registration.go had half of.

HIGH — one canonical order, on every build. PublicKeyBytes, the sort key, was the
UNCOMPRESSED encoding, which the crypto library returns as 96 bytes under blst
and 48 under CIRCL — so the same set sorted two ways, and two nodes on different
builds indexed the signer bits differently. It is the COMPRESSED encoding now, in
both Register and the local FlattenValidatorSet, so the order is the same on every
build. Under the canonical (purego) image this still matches upstream; under cgo
it is now deterministic where upstream is not. The crypto root cause is upstream
and live on the warp path — escalated separately, not fixed here.

Also: key uniqueness now keys on the compressed bytes decoded from the point, not
the caller's input buffer read three times (a caller could mutate it between the
verify and the map insert); a zero-weight validator is refused (a signer that
raises the count but not the weight, the phantom BuildWeightedValidatorSet already
refuses); cert-store eviction drops the file the evicted slot names, not whatever
the map points at, and unmaps a decision only if the map still names the removed
file; and a canonicalCommitter test block exercises the cert-store re-key in the
regime where the inner decision differs from the outer envelope — the case every
prior test degraded past.

Node width is 20 bytes, frozen in vectors/pop.json. Nothing here runs on mainnet:
node pins a consensus version without these files, and Register has no non-test
callers. A validator set that shares a key across node ids is now refused — the
intended behaviour, and the seam with the P-chain step still to come.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 08:10:35 -07:00
zeekayandHanzo Dev af808b25ad validator: node-bound proof of possession, one key per node
The forged certificate that reached crates.io was killed at verification, but a
red review showed the hole had only moved to registration: a key was admitted on
key_validate alone, which proves a point is on the curve and nothing about who
holds its secret, and duplicate keys were merged into one validator carrying many
node ids — so a floor written to require many distinct signers was cleared by one
holder registering one key under several ids.

This is the Go oracle for the standard ruled in luxfi/conformance: it defines the
bytes Rust and C++ reproduce.

validator/pop is the primitive. A registrant signs its own identity followed by
its own key — node(20, ids.NodeID) ‖ pubkey(48, compressed G1), 68 bytes, no
separator — under the proof-of-possession domain BLS_POP_..._POP_, distinct from
the vote's ..._NUL_ so a vote is never a proof and a proof never a vote. The node
is bound because the pubkey-only IETF proof travels with the key: bound to
nothing, it re-registers under any identity. 20 bytes because a Lux validator's
identity IS its NodeID; the 32-byte consensus Id names blocks, not nodes, and has
no canonical inverse, so widening would invent a second name for one node.

validator/registration is the admission rule, written once: encoding, then
possession, then uniqueness, then weight — a pairing on non-points is undefined,
and weight counted before uniqueness is weight counted twice. A set is admitted
whole or refused whole, because a set that silently drops a signer no longer
describes its own weight. FlattenValidatorSet's merge of duplicate keys is retired
here: it was the only thing that made many-nodes-one-key representable, and after
the uniqueness rule that state does not exist to normalize. Order and
PublicKeyBytes still match the upstream flatten exactly, so a proven set and a
flattened-distinct set are the same set.

engine/chain keys the cert store on the DECISION — the signed canonical identity
— not the transport envelope id, which no signature covers: two envelopes of one
decision were two store entries for one finality fact, and a verified cert could
be filed under a block it never attested. Keyed by decision, a cert is found by
anything that finalized that decision, and Put is idempotent per decision.

The pubkey-only P-chain proof (luxfi/node AddValidatorTx) is NOT touched here:
moving it node-bound is a network upgrade, every validator re-proves, and it is
activation-gated as the deliberate last step. Anywhere a validator set that
shares a key across node ids is admitted today, this now refuses it — that is the
intended behaviour, and the coordination point with the P-chain step.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 08:10:24 -07:00
zeekayandHanzo Dev f1810d9112 Merge main: take the extracted cert design, keep the re-cut corpus
Two lineages refactored the same certificate path at once. This branch fixed the
BLS length inversion in place — the aggregator declared a 48-byte G1 signature
and sliced every 96-byte G2 vote down to it, so the aggregate was the error
path's zero bytes and forty validators voting with no signatures produced a
certificate that verified. Main went further and deleted the aggregate path
entirely, moving the per-key check into cert.rs, where a certificate is verified
against one public key per signature and keys are never summed.

Deleting the class beats fixing the instance, and main's version arrives with the
test that proves it: rogue_key.rs builds the Boneh-Drijvers-Neven forgery, shows
the four keys really do sum to one the attacker holds, and then shows it buys
nothing. So the Rust crate is main's.

Resolving that file by date would have taken this branch's lib.rs and left
cert.rs orphaned: main declares the module, this branch does not, and the new
test would not have resolved lux_consensus::cert at all.

The corpus stays this branch's. It was re-cut for the seed encoding, and the Go
definitions that generate it are here; main's is smaller because it de-duplicated
an older cut. Both languages read the re-cut one and agree.

Go: conformance and engine/chain green. Rust: 8 suites, 70 tests, 0 failures.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 22:57:18 -07:00
zeekayandHanzo Dev 408ec64b33 bench: ask Go the aggregate question C++ was being asked
The aggregate row compared Go summing DECOMPRESSED signatures against C++
summing COMPRESSED bytes, and reported the 1.44x as a language difference. It
is the argument: bls.AggregateSignatures takes points, bls::aggregate_sigs
takes bytes and pays an uncompress and a group check for each one.

Asked the same question — 41 compressed signatures in, one out — Go answers
2.100 ms and C++ 2.096 ms. Within two parts in a thousand.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 20:02:17 -07:00
zeekayandHanzo Dev c6f4d45b36 bench: time the same work in every language, and time a round
The three legs were handing their aggregators and verifiers different work
under one name, so three rows of the comparison were measuring the inputs
rather than the implementations.

  aggregate n=41 spanned sixtyfold — Rust summed decompressed points with the
  group check off, Go summed decompressed points with it ON, and C++ summed
  COMPRESSED bytes and paid to decompress every one. With all three summing
  points already decompressed the row closes to Go 37.8 us, Rust 35.4 us,
  C++ 33.7 us: the sixtyfold gap was the caller's argument, not the language.

  verify one signature — Go decompresses the signature and group-checks it
  TWICE, once in SignatureFromBytes and again inside Verify. That is priced
  here rather than inferred: the check is 32.1 us of a 794 us verification, on
  every vote, in the shipped path.

Each operation is now timed twice: as shipped, which is what a Go node pays,
and matched, which is the same blst calls in the same order the other two legs
make. Adds the empty-body row, so the nanosecond figures are readable against
the harness floor (0.2 ns), and the certificate wire in both directions, which
Rust already timed and Go did not.

A ROUND is timed for the first time in any leg, split by who pays: sign is one
validator's own cost and does not grow with n; collect is the assembler's sort
and encode, O(n) with no curve arithmetic; admit is a follower's decode and
predicate, the O(n) pairings that are finality's critical path.

Rust gains the fast-aggregate-verify counterpart it had no row for, and a
measurement of the key validation Registry::verify repeats on every vote
although insert already paid it.

go vet clean; conformance and engine/chain green; Rust 33 tests green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 19:11:08 -07:00
zeekayandHanzo Dev fb181d586b export the reject leg the corpus generator already calls
go build ./... fails on this branch and has since 8968c19c:

    conformance/corpus.go:248:15: undefined: chain.CanonicalRejectMessage

The generator needs both decisions because the accept byte is bound into
the signed message, and proving that binding is most of what the corpus
is for — a port that folded the decision out would pass every
accept-only vector. But only the accept leg was exported;
canonicalVoteMessageFor is package-private, and package conformance is
not that package.

So export the reject leg beside it. Two named builders rather than one
taking a bool: the decision is the thing being distinguished, and Go
reads better when it is in the name.

Correct, not merely compiling: the committed corpus.json already holds
the two reject vectors (zero_reject, spec_reject, message tails 00
against the accept vectors' 01), and TestCorpusMatchesGolden compares
the regenerated corpus to it byte for byte. It passes. Invert the bool
and it fails twice over — 'the corpus moved: a finality rule changed'
and 'decision byte is reject=0x1 accept=0x1, want 0x00 and 0x01'.

The wire-vector test pinned the unexported builder, so it could not see
a wrapper handed the wrong bool, which is the only way a three-line
wrapper goes wrong. It pins both exported names now.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 17:52:36 -07:00
zeekay 1776e41421 consensus: derive the FPC seed instead of drawing it
engine/driver.go filled the FPC seed from crypto/rand, so every node ran a
different threshold schedule. The threshold is the count of votes a round must
see before it moves, which means two honest validators sampling the same
committee were demanding different majorities of it: measured over 200
instances at k=20, the phase-1 threshold came out 11, 12, 13, 14, 15 and 16.

The seed is now DeriveEpochSeed(epoch, chain, parent), which every validator
computes alike and none can compute ahead of time — the parent hash is only
known once the previous epoch has finalized. The epoch arrives through
WithEpoch; a driver told no epoch derives the genesis epoch, which is a real
value rather than a draw. Sampling randomness is untouched: which peers a node
draws is deliberately its own business, what majority it then demands is not.

Wave and Driver gain Threshold(phase) so the number can be asked for. It is
the one number two implementations must agree on to agree at all, and a test
that cannot read it can only assert agreement rather than measure it.
2026-08-29 14:25:23 -07:00
zeekayandHanzo Dev 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]>
2026-08-28 23:26:41 -07:00
zeekayandHanzo Dev e808906131 the corporate name is Lux Industries Inc
Lux Partners Limited is not the entity behind this work. Every place the
old name stood — copyright lines, site footers, brand config, legal and
compliance text — now names the company that actually holds it.

lux.fund is the exception and is not renamed to match: that site belongs
to Lux Assets Management LLC, a different company, and it says so.

Two kinds of line were left for a person rather than rewritten. A dated
news item recording that Lux Partners Limited was established in the Isle
of Man is a historical fact, and renaming it would falsify the record. And
a sentence asserting that the company is domiciled in the Isle of Man is a
claim about a jurisdiction, not a name — an Inc or an LLC is not an Isle
of Man company, so swapping the name there would assert something untrue.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-28 12:58:35 -07:00
zeekayandHanzo Dev 321850a154 consensus: state the liveness rule the retry loop holds
The loop explained itself by naming a network that froze. The rule is general:
processPendingBlocks only checks IsAccepted and never re-requests, so without a
poll-timeout retry one lagging validator is enough to hold finality at a height
indefinitely. This is a pure liveness retry and changes nothing about the
finality predicate.

Comment only.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 13:27:33 -07:00
zeekayandHanzo Dev 0c8e4f1163 consensus: comments state the contract, not the history behind it
The vote topology, the cert path and the bootstrap accept rule each explained
themselves by describing a failure they had already been changed to prevent,
including one written out in enough detail to be a recipe. What a maintainer
needs is the property being held.

Finality does not depend on any one node's inbound Chits, because a vote that
reaches only the proposer leaves the round resting on what that node hears.
Arrival is not agreement, so a follower Accepts on an α-of-K witness and not
before. A position is recorded before the path that reads it opens, or there is
a window where the path is open and the position absent. The ledger height alone
cannot decide what is already synced.

Behaviour is unchanged; every line here is a comment.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 13:18:52 -07:00
zeekayandHanzo Dev 9ade22886c consensus: the finalized-height oracle's reason, stated so it stays true
FinalizedBlockAtHeight explained itself as standing in for a height index that
was "unhandled over ZAP". That stopped being true: the zap server handles
MsgGetBlockIDAtHeight and the C-Chain implements the method. A reader checking
the premise would find it gone and conclude this oracle is now redundant.

It is not. Reading finality from the ledger is a choice about where finality is
decided: the ledger is what finality means here, while a VM's height index
answers what that VM has accepted, and the two can disagree while a decision is
in flight. Answering from whichever replies is how a rule ends up enforced at
one door and not the other.

Behaviour unchanged. Same correction has been made to the two copies of this
note in the node.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 10:30:53 -07:00
zeekayandHanzo Dev 5c5cf340cc test: wait for completed re-poll finality
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 18:18:00 -07:00
zeekayandHanzo Dev b3c247e262 bootstrap: connect catch-up by accepted parent ID
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 17:45:38 -07:00
zeekayandHanzo Dev 39aabaee8c consensus: single-flight concurrent cert delivery
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 14:48:47 -07:00
zeekayandHanzo Dev 54d4a7f87e style: gofmt the tracked consensus suite
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 11:45:32 -07:00
zeekayandHanzo Dev 0f8815e34f ci: format consensus and isolate cross-build caches
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 11:44:51 -07:00
zeekayandHanzo Dev 0b5c4ce7c3 tests: lock certified finality safety invariants
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 11:28:36 -07:00
zeekayandHanzo Dev a823912b27 chain: re-validate epoch when a parent lands, and seed the anchor by decision
Two findings close here.

The epoch bound ran once, at arrival, against a parent the sender chose not to
have sent yet. A child delivered before its parent was admitted as an orphan at a
stale epoch, and nothing re-read it when the parent became tracked — so a
departed coalition holding no stake in the live set reached VM.Accept at Quasar
(export) finality. The child's epoch is now re-checked against its parent at the
point the parent becomes tracked, and a regressed child is refused there, closing
the ordering the attacker picked.

And the build anchor / hint was seeded from an envelope id where it must be
seeded from the inner canonical decision, so a sibling wrapper of the tip's
execution read as a different anchor and the anchor could regress. It routes
through decision() now — the one canonical-vs-envelope primitive — so two
wrappers of one execution seed the same anchor and it never moves backward.

Full engine/chain suite green: no regression against the eight findings already
closed on this branch.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 08:37:55 -07:00
zeekayandHanzo Dev f179b47da7 chain: a canonical cert codec, and equivocation evidence that outlives the cert
The cert accept byte decoded with `ab != 0` and re-encoded as 1, so 254 byte
values that the encoder can never produce still decoded — two byte strings naming
one cert. Anything treating cert bytes as identity (the per-block serving cache,
a dedup, a digest) can then be split by flipping a bit no field reads. A canonical
codec refuses what it cannot reproduce: the accept byte is 0 or 1, and anything
else is corrupt.

Ingest dropped the equivocation evidence for a double-sign that arrives AFTER the
height certifies. A double-signer racing the honest cert gains nothing, so that
is precisely the ordering it picks — and the already-certified early return fired
before the subject was recorded, discarding the second-execution evidence along
with the vote. The signature verified; only the record of it was skipped.
Evidence is a property of the signed votes, not of whether a cert exists, so it
is now recorded before the early return.

And a nil stake source is a rejection at the top of Ingest rather than a nil
dereference at q.stake.ValidatorCount deep inside. The file documents nil as
fail-closed; it was a remote crash, because every entry point takes peer data and
the first verified attestation reached the deref.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 07:15:50 -07:00
zeekayandHanzo Dev e9be2c96de chain: an orphan's epoch is anchored to finalized history, and both doors share it
The epoch bound admitted any block whose parent it did not track — "an orphan
cannot extend finalized history anyway". An orphan only stays one as long as the
sender withholds its parent, so a child sent first with a stale epoch was tracked
at that epoch, and nothing re-read it when the parent arrived. An untracked-parent
block now must also clear the finalized tip's epoch, which no arrival order can
lower.

The two doors are collapsed onto one predicate. The gossip door open-coded the
tracked-parent case only, so the same block it admitted reached the catch-up door
and then VM.Accept; epochRegresses is the single site now.

This raises the cost but does not close the class: the bound is still evaluated
ONCE, at arrival, so a stale epoch above the finalized floor with a
withheld-then-delivered parent is still tracked without re-validation. Closing
that needs the child's epoch re-checked against its parent when the parent lands,
which is a change to WHEN the bound runs. TestEpochOrder_* stay in the tree,
failing, as the standing evidence for that work.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 07:01:33 -07:00
zeekayandHanzo Dev 7277e8c9ba chain: ancestry is a function of its blocks, not of map order
WrapperByCanonical and Children both ranged the live block map and returned
whichever element iteration reached first. Finalize was therefore not a function
of its inputs: 512 folds of one (ledger, cert, DAG) produced two outcomes, the
fold accepting whichever wrapper the map handed it and rejecting the other. Two
nodes holding identical blocks record different envelopes at one height, hand the
VM opposite Accept/Reject for the same pair, and answer a bootstrapping peer's
outer-envelope identity check differently — a fork with no faulty node.

A committed wrapper still wins outright, because that is the block the VM
actually holds. Among uncommitted aliases the lowest id wins, and Children is
sorted, so every node makes the same choice from the same blocks. Any total order
would do; id order is the one already used for the canonical representative.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 06:59:37 -07:00
zeekayandHanzo Dev 9fa7ec5117 chain: a cert is not the source of its own bar, and an asker does not size a buffer
NovaSignerFloor saturates at NovaQuorum(minBFTCommittee) = 3 for every K, so a
committee of nine was asked for three signatures. With no stake source wired
nothing downstream re-derives the majority either, leaving the cert's own
declared Threshold as the entire bar — a security parameter chosen by the party
it constrains. Quasar's weighted verifier already refuses that and pins its own
minimum; the engine cert had no floor at all.

Three consequences, each with a test: four of nine finalizes where the majority
is five; two disjoint triples both certify different blocks at one height with no
validator equivocating and none misbehaving; and a three-vote cert for a losing
sibling clears verification and jails three validators who each signed one block
once — jailing a majority of them halts the chain more cheaply than the fork the
cert claims to prove. Votes are broadcast, so anyone on the gossip path holds
enough of them.

The floor is now the live committee's majority, checked against the votes the
cert CARRIES rather than the number it claims to need, at both doors. Leaving
catch-up behind would have kept the whole hole open one path over.

Separately: From reserved make([]Certified, 0, max) with max straight from the
asker, so a peer writing MaxInt cost a makeslice panic in one packet. It was the
only peer-sized quantity here without a cap — vote_count is checked against the
remaining frame, sig_len against the buffer, the Merkle step count at 64. Nothing
past the served window can be answered anyway, and the run is built by appending,
so capping the reservation changes no reply.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 02:38:20 -07:00
zeekayandHanzo Dev 5f67ce5744 chain: walk through the catch-up epoch door instead of describing it
The far-past epoch bound was given a second door and a test file, and the test
file drives the predicate. Where it calls the door it hands AcceptCatchupBlock a
three-byte cert, which fails UnmarshalQuorumCert about forty lines before the
epoch is read: the error it observes is a codec error, and it is asserted as
proof of a safety property. Deleting the bound from both of its sites in
catchup_accept.go leaves that file green.

These drive the door with a real 4-of-5 cert -- the same assembly the liveness
path accepts -- over a VM whose blocks carry an epoch, on a node stranded at N
with the parent tracked. A block at N+1 stamped below its parent's epoch is
refused, reaches VM.Accept zero times, and leaves the finalized height where it
was. Epoch 0 under a live parent is the same attack with the cheapest payload.

The forward-epoch case is admitted through identical machinery, differing only
in the epoch, so the refusals are attributable to the bound and not to some
earlier gate. It is also the half that matters for liveness: catch-up exists to
serve exactly the history across which an honest chain's epoch advances, and a
bound that froze the epoch rather than directing it would stop the fetch it was
added to protect.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 19:36:55 -07:00
zeekayandHanzo Dev 7d8cb7bde5 chain: the envelope-vs-canonical rule lives in one place
"Canonical if recorded, else the outer id" reads as two trivial lines, which is
why it was re-typed inline across the package and behind three separately-named
helpers -- decision, certCanonical, canonicalIDOf -- each expressing the same
meaning in its own words. A rule with N copies is a rule with N chances to
diverge, and a suite that pins one copy says nothing about the others.

certCanonical and canonicalIDOf differ only in where they FETCH the pair from: a
cert's position, or a VM block's optional commitment. That difference is real and
stays; the rule they then apply is not theirs to restate, so both hand the pair
to decision.

The degrade direction is the part worth pinning: an absent canonical becomes the
OUTER id, never Empty. Empty would make every unwrapped block equal to every
other one, which is the opposite of what a bare chain means.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 13:22:05 -07:00
zeekayandHanzo Dev a2af600581 chain: the far-past epoch bound has two doors
A block's P-chain epoch selects the validator set that gets to attest, and it is
read out of a block a peer handed us. The gossip door has bounded it since the
far-past epoch work; the catch-up door read the same field and enforced nothing,
so a node fetching history could be steered onto a set the live path would have
refused -- including epoch 0, which drops a running chain back to its genesis
set. Every test of the invariant drove followVerifiedBlock; none drove
AcceptCatchupBlock.

One predicate now, epochRegresses, called on both doors and on the read-only
certificate check, so the invariant has one enforcement site rather than one
enforcement site and one omission. A block with no tracked parent is still
admitted: there is nothing to regress against, an orphan cannot extend finalized
history, and refusing there would break the very path that fetches unseen
ancestors.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 12:35:32 -07:00
zeekayandHanzo Dev 631c27e2a6 chain: a parent we do not hold is not a cert we rejected
Catch-up reported both as ErrCatchupCertRejected. They have opposite remedies: a
rejected cert means ask a different peer, a missing ancestor means ask this peer
for the ancestor. Collapsed into one value, the only move the requester made was
the one move that could not work, since every peer serves the same envelope --
and the operator-visible string said "forged/sub-quorum cert" about a block that
was neither, followed by a bare "not found" that named nothing.

The node reaching this holds byte-identical execution to its peers. It has the
right inner block at the parent height under its own wrapper; the network names a
different wrapper of that same block; the outer-parent lookup misses and falls
through to asking the inner chain for an eth block whose hash is an outer id,
which can never hit. The wrapper is what is missing, and a wrapper is fetchable.

The blanket skip below the settled height went with it. "At or below settled" is
not "already ours": the envelope such a node needs arrives inside that band and
was discarded unexamined, hundreds of entries per batch, which is why a batch
could report everything skipped while the node advanced nothing. Now only a block
this VM actually holds is skipped; the rest is verified so the envelope resolves
and is stored. That path decides nothing -- the inner block at that height is
already applied, so the inner VM returns immediately, and nothing is tracked,
voted, accepted, or folded into the ledger.

Only database.ErrNotFound takes the new name, matched through wrapping, so a
genuinely bad block still rejects and this cannot launder one into a retry loop.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 12:10:26 -07:00
zeekayandHanzo Dev 3dcb73ccff chain: the frontier is a decision, not an envelope
Finality's subject is (height, inner canonical) everywhere in this package. A
cert binds the canonical block and the canonical parent; attestKey excludes the
outer envelope on purpose, because the envelope carries the proposer and the
timestamp and neither is part of what was decided. Two wrappers of one execution
commit to one state transition.

The ancestry walk compared outer ids. Arriving at the finalized height under a
sibling wrapper of the tip therefore read as arriving on a losing branch, and a
cert that does extend the frontier was refused for naming it differently.

The asymmetry is what gives it away. When the peer's envelope is UNTRACKED the
walk already substitutes a local wrapper of the same execution and finality
advances -- so a node holding less data recovered where a node holding more
refused. One rule, both arms: reaching the finalized height at the tip's
execution is reaching the tip, and the child's parent link rebases onto the
wrapper this node actually holds so the accept path and the reject-subtree walk
agree on one envelope.

decision() degrades an absent canonical to the outer id rather than to Empty, so
a bare chain compares byte-for-byte as before: there, two blocks are the same
decision only when they are the same block. The relaxation is keyed to the
finalized height exactly -- an ancestor below it is never the frontier's alias --
and a divergent execution at that height still conflicts. Both halves are pinned
by mutation: drop the height test and the below-frontier case finalizes; drop the
canonical test and a fork finalizes.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 10:32:39 -07:00
zeekayandHanzo Dev 98569febb2 chain: finalized history addressed by position
A node that has fallen behind knows one thing for certain about what it needs:
its own height. It does not know the id of the block above it, and not knowing
that id IS the condition it is in.

Recovery asks it for that id anyway. requestCatchup takes a missing block id and
CertForBlock is keyed by id, so the only ids a behind node can name are ones it
was already handed -- which are the tip's, because that is what live gossip
carries. It asks about the tip, the tip does not extend its head, and the height
directly above its head is never named by anyone. On mainnet: advanced=0 held
across 904 consecutive samples with one block id repeating 396 times, on four of
five validators, unrecoverable by restart at a gap of 53 blocks.

Descent asks the other way -- give me what follows height H -- so identity is the
answer instead of the argument.

Certified is a block and its cert as ONE type, because neither half is a value:
a block without its cert cannot be accepted, a cert without its block has nothing
to accept. Run is contiguous by construction, Chain[i] at Base+i, so a gap cannot
be served, cached, or counted as progress; a server that finalized 100..102 and
104 returns three entries and stops at 103 rather than sliding 104 into its place.

This adds the seam and its tests; it does not rewire the transport. Wiring is a
wire-format change and belongs with the catch-up rewrite in flight, not under it.
CertForBlock stays -- it answers a different question, for a caller that genuinely
knows the id, and neither subsumes the other.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-19 22:49:37 -07:00
zeekayandHanzo Dev c56fe082bb consensus: normalize release formatting
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-18 22:38:27 -07:00
zeekayandHanzo Dev e413379c45 consensus: retire obsolete SDK facade and refresh vm pin
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-18 22:35:43 -07:00
zeekayandHanzo Dev 9c24b9541e consensus: require cryptographic quorum evidence
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-18 22:21:49 -07:00
zeekayandHanzo Dev 7b9b2b179f summary: ask the node for its height, not for a block type it declares itself
The round needed the local tip and reached it through LastAccepted plus
GetBlock, which named this package's Block. The node's VM answers with its own,
so the whole interface went unsatisfied and adoption refused every chain — the
same braiding as the summary type, one field over.

Tip is a height. That is the entire question the round asks: the floor no
candidate may sit at or below. Nothing about which type a block arrives as
belongs in it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-16 00:41:25 -07:00
zeekayandHanzo Dev 97c8d74e76 summary: the round is generic over the summary type, like the serving half is
A VM's summary interface is declared where the VM is built. Naming one concrete
declaration here meant every VM carrying an identical one under another name
failed the assertion — and a failed assertion is indistinguishable from a VM
that cannot sync at all, so a wired client sat in a shipped build doing nothing.
That is exactly what happened: the node's VM answers with its own StateSummary,
the round demanded this package's, and adoption never ran on any chain.

The serving half took the summary type as a parameter for this reason and did
not have the problem. Now both halves read the same way, and neither cares which
declaration a summary arrives under so long as it answers the two or three
questions being asked of it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-16 00:38:09 -07:00
zeekayandHanzo Dev 29d1ba7d70 a chain with no stake model has no membership to check, and none to protect
The membership check refused the vote when no stake source was wired, on the
reasoning that a vote we cannot attribute is one we cannot count. That reads
backwards on the chains it actually hits: alpha there is a plain count over a
fixed set whose admission is enforced before a peer can connect, so there is no
stranger to keep out and refusing costs a single-validator or equal-stake chain
every vote it has — the halt the transport-authenticated path exists to prevent.

The check earns its keep exactly where a stake model exists, which is every
chain a stranger could connect to without being a validator.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-15 23:57:45 -07:00
zeekayandHanzo Dev 37e27fbd49 summary: ship the file the package is built out of
source.go carries Source, VM, Offer and Ballot — every type summary.go names.
It was never tracked, so the published module did not compile at all for a
consumer: undefined Source, undefined VM, undefined Ballot. The package's own
tests passed the whole time, because a test builds against the working tree.

Also corrects two of my own from the previous commit. A seed at or below the
recovery hint stands again: the certified frontier belonging under what the VM
has already applied is the safe direction, and the dangerous one — above — is
what the walk-from-hint arm exists for. And the quorum-engine test harness now
wires stake weighting, so a transport-authenticated vote is judged the way
production judges it; without it the membership check refused every vote,
including the validators' own.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-15 23:51:36 -07:00