mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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]>
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
package chain
|
package chain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -143,3 +144,129 @@ func TestExportCommitteeFloorLeavesNovaAlone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countOnlyEngine is a real stake-less engine whose committee is exactly k.
|
||||||
|
//
|
||||||
|
// With no stake source effectiveCommittee answers the CONFIGURED K verbatim — no
|
||||||
|
// clamp, no floor — so k IS the number verifyCert reads a declaration against, and
|
||||||
|
// setting it is how a case names the committee it is asking about.
|
||||||
|
func countOnlyEngine(t *testing.T, vs *testValidatorSet, k int) (*Transitive, ids.ID) {
|
||||||
|
t.Helper()
|
||||||
|
chainID := ids.GenerateTestID()
|
||||||
|
e := NewWithConfig(
|
||||||
|
Config{Params: config.Parameters{K: k, AlphaPreference: k, AlphaConfidence: k, Beta: 2}},
|
||||||
|
WithQuorumCert(chainID, vs.nodeID(0), vs, &recordingGossiper{}, vs.signerFor(0)))
|
||||||
|
if err := e.Start(context.Background(), true); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = e.Stop(context.Background()) })
|
||||||
|
if e.stakeSource != nil {
|
||||||
|
t.Fatal("this engine is supposed to have no stake model; the case tests the count-only road")
|
||||||
|
}
|
||||||
|
if got, _ := e.effectiveCommittee(1); got != k {
|
||||||
|
t.Fatalf("the committee is %d and the case asked for %d — it is not testing the size it says it is", got, k)
|
||||||
|
}
|
||||||
|
return e, chainID
|
||||||
|
}
|
||||||
|
|
||||||
|
// arrival is a certificate as it ARRIVES: unanimous over the committee's k seats,
|
||||||
|
// declaring the quorum that committee derives. Everything a certificate can be made
|
||||||
|
// to satisfy is satisfied, so whatever refuses it refuses it on the committee.
|
||||||
|
func arrival(t *testing.T, vs *testValidatorSet, chainID ids.ID, tier Finality, k int) *QuorumCert {
|
||||||
|
t.Helper()
|
||||||
|
pos := VotePosition{ChainID: chainID, Height: 9, Round: 1, BlockID: ids.GenerateTestID()}
|
||||||
|
votes := make([]SignedVote, 0, k)
|
||||||
|
for i := 0; i < k; i++ {
|
||||||
|
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||||
|
}
|
||||||
|
cert, err := AssembleQuorumCert(pos, tier, uint32(Quorum(tier, k)), votes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assemble %s over %d seats: %v", tier, k, err)
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExportCommitteeFloorHoldsOnTheCountOnlyRoad is the same floor as
|
||||||
|
// TestExportNeedsAByzantineCommittee, on the OTHER road.
|
||||||
|
//
|
||||||
|
// There are two roads to the export rung and only one of them had this clause. The
|
||||||
|
// weighted road reads the floor off the stake source (verifyQuasarSupermajority);
|
||||||
|
// the count-only road — a chain with no stake model — reads its committee from
|
||||||
|
// effectiveCommittee, and read NOTHING against it but the derived quorum. That
|
||||||
|
// clause cannot catch a small committee, because it shrinks with it:
|
||||||
|
// TwoThirdsCount(2) is 2, so two signatures over a two-member committee ARE the
|
||||||
|
// number that committee derives and the certificate passes as the one this node
|
||||||
|
// would have built itself.
|
||||||
|
//
|
||||||
|
// What passed there was export finality with a Byzantine budget of zero, and it did
|
||||||
|
// not stop at the node that admitted it: TryAccept re-gossips the bytes and
|
||||||
|
// applyBranchFinalization serves them as the catch-up proof, so one admission made
|
||||||
|
// the node a second source for a certificate carrying no fault budget.
|
||||||
|
//
|
||||||
|
// It had to arrive from a peer and it took every key in the committee, which is why
|
||||||
|
// this was a gap in Go's own uniformity rather than a live forgery. The road it
|
||||||
|
// joins is Go's WEIGHTED road, pinned by the test above — same sentinel, same
|
||||||
|
// TwoThirdsCount number. There is no third implementation in the comparison: Rust's
|
||||||
|
// Cert::verify is the structural predicate its weighted road runs first, not an
|
||||||
|
// accept rule, and C++'s verify_cert fails closed on an empty stake model, so
|
||||||
|
// neither has a stake-less accept road for this clause to exist on.
|
||||||
|
func TestExportCommitteeFloorHoldsOnTheCountOnlyRoad(t *testing.T) {
|
||||||
|
vs := newTestValidatorSet(minBFTCommittee)
|
||||||
|
|
||||||
|
for n := 1; n < minBFTCommittee; n++ {
|
||||||
|
e, chainID := countOnlyEngine(t, vs, n)
|
||||||
|
cert := arrival(t, vs, chainID, Quasar, n)
|
||||||
|
|
||||||
|
// Both clauses this road already had are MET. If either were short, the
|
||||||
|
// refusal below would prove nothing about the committee clause.
|
||||||
|
if err := cert.Verify(vs, 1); err != nil {
|
||||||
|
t.Fatalf("committee=%d: the certificate does not clear the structural predicate (%v), "+
|
||||||
|
"so this case never reaches the committee clause", n, err)
|
||||||
|
}
|
||||||
|
if floor := Quorum(Quasar, n); int(cert.Threshold) != floor {
|
||||||
|
t.Fatalf("committee=%d: the certificate declares %d where the committee derives %d, so "+
|
||||||
|
"the derived clause refuses it and the committee clause never runs", n, cert.Threshold, floor)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := e.verifyCert(cert, 1)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("committee=%d: a gossiped EXPORT certificate signed by every seat was ADMITTED. "+
|
||||||
|
"f=⌊(n−1)/3⌋ is %d there, so it tolerates no Byzantine fault and one compromised key "+
|
||||||
|
"forges irreversible finality — which this node would then re-gossip and serve on "+
|
||||||
|
"catch-up. Go's weighted road refuses it",
|
||||||
|
n, (n-1)/3)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrQCBelowThreshold) {
|
||||||
|
t.Errorf("committee=%d: refused with %v, want ErrQCBelowThreshold — the weighted road's "+
|
||||||
|
"own sentinel, so the two Go roads refuse the same certificate by the same name", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And at the floor the same shape carries: a floor on the committee, not a
|
||||||
|
// retirement of the road.
|
||||||
|
e, chainID := countOnlyEngine(t, vs, minBFTCommittee)
|
||||||
|
if err := e.verifyCert(arrival(t, vs, chainID, Quasar, minBFTCommittee), 1); err != nil {
|
||||||
|
t.Fatalf("committee=%d is the minimum Byzantine committee and its unanimous certificate "+
|
||||||
|
"was refused: %v", minBFTCommittee, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCountOnlyCommitteeFloorLeavesNovaAlone — the clause belongs to the export
|
||||||
|
// rung on this road too, and must not migrate down the ladder.
|
||||||
|
//
|
||||||
|
// Nova authorizes local execution the chain can still reorg away. A four-member
|
||||||
|
// floor there would stop a small or partitioned stake-less chain making any
|
||||||
|
// progress at all, in exchange for a guarantee the rung never offered.
|
||||||
|
func TestCountOnlyCommitteeFloorLeavesNovaAlone(t *testing.T) {
|
||||||
|
vs := newTestValidatorSet(minBFTCommittee)
|
||||||
|
|
||||||
|
for n := 1; n < minBFTCommittee; n++ {
|
||||||
|
e, chainID := countOnlyEngine(t, vs, n)
|
||||||
|
if err := e.verifyCert(arrival(t, vs, chainID, Nova, n), 1); err != nil {
|
||||||
|
t.Errorf("committee=%d: a unanimous NOVA certificate was refused (%v). Nova ignites "+
|
||||||
|
"local execution and has no Byzantine claim to protect; the export rung's "+
|
||||||
|
"committee floor has leaked down a rung", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -655,6 +655,61 @@ func (t *Transitive) verifyCert(cert *QuorumCert, epochHeight uint64) error {
|
|||||||
ErrQCThresholdNotDerived, cert.Threshold, committee, floor, cert.Tier, epochHeight)
|
ErrQCThresholdNotDerived, cert.Threshold, committee, floor, cert.Tier, epochHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The export rung's floor on the COMMITTEE, which the clause above cannot give.
|
||||||
|
// That one asks whether the declared quorum is the number this committee derives;
|
||||||
|
// this one asks whether the committee is large enough for the answer to mean
|
||||||
|
// anything. Byzantine tolerance is f = ⌊(n−1)/3⌋, and that is 0 at one, two and
|
||||||
|
// three members: a two-thirds supermajority of such a committee tolerates no fault,
|
||||||
|
// so every signer is load-bearing and one compromised key is not a fault absorbed by
|
||||||
|
// a margin, it is a forged export certificate.
|
||||||
|
//
|
||||||
|
// The derived clause shrinks with the committee and therefore cannot catch it —
|
||||||
|
// TwoThirdsCount(2) is 2, so two signatures over a two-member committee ARE the
|
||||||
|
// derived quorum, and the certificate passes as the one this node would have built.
|
||||||
|
//
|
||||||
|
// WHAT IT COST, which is more than one node's opinion of one certificate. A cert
|
||||||
|
// admitted here does not stop here: TryAccept hands the bytes to GossipCert, and
|
||||||
|
// applyBranchFinalization files them as the served catch-up proof. So a stake-less
|
||||||
|
// node that admitted a sub-BFT export cert then RE-GOSSIPED it and SERVED it to
|
||||||
|
// every straggler that asked — the node became a second source for a certificate
|
||||||
|
// carrying no fault budget. The floor stops the amplification at each hop, which is
|
||||||
|
// why it belongs at the arrival gate and not only at the accept.
|
||||||
|
//
|
||||||
|
// This is the same clause verifyQuasarSupermajority holds on the WEIGHTED road,
|
||||||
|
// read here against the committee because that is this road's authoritative view of
|
||||||
|
// its set: one rule, both roads, the same sentinel and the same TwoThirdsCount
|
||||||
|
// number, so the two refuse identically.
|
||||||
|
//
|
||||||
|
// Both roads are Go's, and that is the whole comparison — there is no third
|
||||||
|
// implementation to conform to here, because no other implementation HAS a
|
||||||
|
// stake-less accept road. Rust's Cert::verify is the structural predicate its
|
||||||
|
// weighted road runs first (verify_weighted calls it, then the floors), never an
|
||||||
|
// accept rule on its own; 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 clause, and it is Go's own weighted road it is being
|
||||||
|
// brought up to.
|
||||||
|
//
|
||||||
|
// Nova has 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 predicate is AuthorizesExport and 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. Reading the rung's own predicate makes that impossible
|
||||||
|
// rather than merely unlikely.
|
||||||
|
//
|
||||||
|
// The committee is never below one — NewChainConsensus floors k there and
|
||||||
|
// Parameters.Valid refuses K<1 — so the comparison guards a case a started engine
|
||||||
|
// cannot present. It is written as the full inequality anyway: this clause should
|
||||||
|
// be readable as "the export rung needs a Byzantine committee" without also having
|
||||||
|
// to know which invariant upstream keeps the number positive.
|
||||||
|
if cert.Tier.AuthorizesExport() && committee < minBFTCommittee {
|
||||||
|
return fmt.Errorf("%w: quasar cert over a committee of %d, need at least %d — below the "+
|
||||||
|
"minimum Byzantine committee f=⌊(n−1)/3⌋ is 0 and a two-thirds supermajority "+
|
||||||
|
"tolerates no fault, at epoch %d", ErrQCBelowThreshold, committee, minBFTCommittee, epochHeight)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const (
|
|||||||
identityG1 = "c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
identityG1 = "c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||||
// identityG2 is the same point one group up: the compressed G2 infinity, which
|
// identityG2 is the same point one group up: the compressed G2 infinity, which
|
||||||
// as a "proof" verifies against nothing and must not be read as a proof.
|
// as a "proof" verifies against nothing and must not be read as a proof.
|
||||||
identityG2 = "c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
identityG2 = "c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||||
// subgroupG1 is on the curve and outside the prime-order subgroup — the point
|
// subgroupG1 is on the curve and outside the prime-order subgroup — the point
|
||||||
// the subgroup check exists for. Decoding it and skipping that check is the
|
// the subgroup check exists for. Decoding it and skipping that check is the
|
||||||
// classic small-subgroup opening, so it must be an encoding refusal.
|
// classic small-subgroup opening, so it must be an encoding refusal.
|
||||||
|
|||||||
Reference in New Issue
Block a user