mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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]>
This commit is contained in:
@@ -413,34 +413,84 @@ number its own assembler declares. Every committee the test fixtures use is 4 or
|
||||
which is exactly where the two agree — `TestTheTwoRoadsDeriveTheNumberTheyDeclare`
|
||||
walks 1..64 so the divergence is stated rather than discovered at K=6 in production.
|
||||
|
||||
Two corollaries, both load-bearing:
|
||||
Four corollaries, all load-bearing:
|
||||
|
||||
- **The count-only road derives its floor too, and the two doors answer
|
||||
differently because the threat differs.** `BuildVerifiedQuorumCert` MINTS a token
|
||||
from raw votes against the CALLER's alpha, so with no set to check that alpha
|
||||
against there is nothing to check at all: `chain.exportNeedsStake` refuses any
|
||||
export tier outright there. `Transitive.verifyCert` ADMITS a certificate that
|
||||
ARRIVED, carrying N real signatures from N distinct in-set validators — all that
|
||||
was missing is that its declared quorum was its own. So the floor is derived
|
||||
there too, from `effectiveCommittee`, which is that road's authoritative view of
|
||||
its set. Not "an equal-stake chain cannot export", but "it may not export on a
|
||||
quorum the certificate wrote for itself". Without either clause a gossiped Quasar
|
||||
cert declaring a quorum of ONE, over one signature, reached `AcceptWithCert`.
|
||||
That committee view is weaker than a stake source (it is K, the sample size), and
|
||||
that asymmetry is why the minting door refuses export on this road rather than
|
||||
weighing it: a configured committee is strong enough to CHECK a declaration and
|
||||
not strong enough to ORIGINATE one. The price, and it is real: with no stake model
|
||||
the committee is configured, so this road now needs the fleet to agree on K — two
|
||||
nodes that disagree about their committee disagree about which certificates exist,
|
||||
where before any declaration was admitted and they interoperated by accident.
|
||||
Rust and C++ need no such clause — their weighted verifiers take a set by
|
||||
reference, so the absent case is unrepresentable rather than checked.
|
||||
- **The assembler reads the same n the verifier does.** `assembleCertLocked`
|
||||
- **Minting states no quorum, and mints nothing without a set.**
|
||||
`BuildVerifiedQuorumCert` took the floor as an ARGUMENT — the caller's alpha —
|
||||
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. So there is no argument. The floor is
|
||||
`SignerFloor(tier, SignerCount(epoch))`, read at the door, and a `nil` stake
|
||||
source is `ErrCertNeedsStake` at BOTH rungs: a floor is a property of the set,
|
||||
and with no set there is no floor and nothing to mint. This is what Rust's
|
||||
`Tally::cert` and `QuasarConsensus::create_certificate` and C++'s
|
||||
`Cert::verify_weighted` already had by their types — each takes a set by
|
||||
reference, so the absent case is unrepresentable rather than checked — and Go
|
||||
now agrees by rule where they agree by construction.
|
||||
- **The arrival roads read NO floor of their own.** `HandleIncomingCert` and
|
||||
`VerifyCatchupCertificate` each carried a cheap pre-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 eleven it demanded
|
||||
six; and where the set is smaller than the committee the door asked a set of
|
||||
three for the four-seat committee's three when the set derives two. An honest
|
||||
certificate — the exact bytes the node's own assembler produces — never reached
|
||||
the predicate that would have admitted it. **A door in front of a gate may only
|
||||
refuse what the gate refuses**, so the doors are gone and the floor is read once,
|
||||
where it is authoritative. Nothing is lost: the gate demands EQUALITY with the
|
||||
derived floor, which is strictly stronger than any "at least" a door applied, and
|
||||
no work is saved by refusing early — the dropped certificates were the ones
|
||||
carrying FEW votes, which are the cheap ones.
|
||||
`TestTheDoorAdmitsWhatTheGateAdmits` walks the sizes where the two numbers part.
|
||||
- **`verifyCert` derives on the arrival road's own terms.** It ADMITS a
|
||||
certificate that arrived carrying N real signatures from N distinct in-set
|
||||
validators — all that was missing is that its declared quorum was its own. With a
|
||||
stake source it is `VerifyWeighted`; without one the floor comes from
|
||||
`effectiveCommittee`, that road's authoritative view of its set. Not "an
|
||||
equal-stake chain cannot export", but "it may not export on a quorum the
|
||||
certificate wrote for itself". Without the clause a gossiped Quasar cert
|
||||
declaring a quorum of ONE, over one signature, reached `AcceptWithCert`. That
|
||||
committee view is weaker than a stake source (it is K, the sample size), and that
|
||||
asymmetry is why the MINTING door refuses without a set rather than weighing a
|
||||
configured number: a committee is strong enough to CHECK a declaration and not
|
||||
strong enough to ORIGINATE one. The price, and it is real: with no stake model the
|
||||
committee is configured, so this road now needs the fleet to agree on K — two
|
||||
nodes that disagree about their committee disagree about which certificates
|
||||
exist, where before any declaration was admitted and they interoperated by
|
||||
accident.
|
||||
- **Every assembler reads the same n the verifier does.** `assembleCertLocked`
|
||||
derives Nova's declared floor from `stakeSource.SignerCount`, not from
|
||||
`effectiveCommittee` (which sizes the SAMPLE and is floored at
|
||||
`minBFTCommittee`). At three signers the two disagreed — the sampler said three,
|
||||
the set said two — so a node would have built a certificate its own peers refuse.
|
||||
`attestation.go` already read `SignerCount` for the export rung.
|
||||
`attestation.go`'s export trigger reads `SignerFloor(Quasar, SignerCount)`, the
|
||||
same function, so the trigger and the rule cannot part.
|
||||
|
||||
`SignerFloor` and `Quorum` return `int`, not `uint32`: the floor is counted at the
|
||||
width the SET is counted at, and narrowed only where a certificate is written.
|
||||
Narrowing first wraps — `⌊2n/3⌋+1` passes 2³² at about six billion signers, and a
|
||||
wrapped floor of 1 is a floor a lone signer meets. Because the derived clause
|
||||
compares at full width, a mint that narrowed is caught by it rather than believed.
|
||||
|
||||
**Five things produce the authority token**, and every one of them runs the full
|
||||
predicate first or has no votes to run it on:
|
||||
|
||||
1. `BuildVerifiedQuorumCert` — the exported minting door, from raw votes over a set.
|
||||
2. `HandleIncomingCert` — a gossiped certificate, through `verifyCert`.
|
||||
3. `VerifyCatchupCertificate` → `AcceptCatchupBlock` — the same, on the catch-up road.
|
||||
4. `assembleVerifiedCertLocked` → `assembleCertLocked` — the engine's own assembly,
|
||||
which verifies before it caches.
|
||||
5. `buildSingleValidatorCertLocked` — the K==1 road, and the one that is NOT a
|
||||
predicate: on a genuinely single-validator chain it synthesizes a zero-vote Nova
|
||||
token from the position, because the sole validator's own accept IS the quorum
|
||||
and there is no other party to protect against. It is fenced three ways — both
|
||||
callers gate on `K()==1`, it prefers a real signed 1-of-1 whenever one
|
||||
assembles, and it returns the ZERO token (which `AcceptWithCert` refuses) once
|
||||
the live count passes one on a chain that has finalized anything. It is Nova
|
||||
only, so it is never an export door.
|
||||
|
||||
Both numbers are read over the SIGNERS: `signer` is `StakeSource.SignerStake` and
|
||||
`n` is `StakeSource.SignerCount`, never the membership roll. A member the chain
|
||||
@@ -548,15 +598,20 @@ point. They are walls behind walls, they are correct, and chasing them costs an
|
||||
afternoon each — so they are written down instead.
|
||||
|
||||
- `cert.go` `verifyNovaMajority`/`verifyQuasarSupermajority`'s COUNT clauses
|
||||
(`VoterCount() < SignerFloor(...)`), since the derived-threshold rule landed.
|
||||
`Verify` proves `count >= Threshold` and the derived clause proves
|
||||
`Threshold == SignerFloor`, so `count >= SignerFloor` follows and neither clause
|
||||
can fire. They are kept deliberately: they are where the floor is DEFINED for a
|
||||
reader, and they are the guard that survives any future change to the
|
||||
declaration rule — removing them would leave the count floor resting entirely on
|
||||
a clause about a wire field. Rust and C++ carry the same redundancy for the same
|
||||
reason. This is why the corpus's under-quorum rows are now named `belowThreshold`
|
||||
by `Verify` rather than by the rung.
|
||||
(`VoterCount() < SignerFloor(...)`) — **unreachable under an IDEMPOTENT source,
|
||||
and not otherwise.** `Verify` proves `count >= Threshold` and the derived clause
|
||||
proves `Threshold == SignerFloor(n)`, so `count >= SignerFloor(n)` follows — but
|
||||
each clause reads `SignerCount` for itself, and a source that answers the two
|
||||
reads differently makes them two numbers. Red raced 3,600 pairs against a source
|
||||
that moves between reads; in 1,924 of them these count clauses were the ONLY
|
||||
clause that refused, and they refused fail-closed. So they are not dead code kept
|
||||
for a reader: they are the guard that survives a set moving under the predicate,
|
||||
and they are where the floor is DEFINED. Rust and C++ carry the same redundancy
|
||||
for the same reason. The ISSUE road has no such race — `Tally::cert` reads the
|
||||
count once and answers every clause of one issue with it — because there the two
|
||||
numbers would be the assembler disagreeing with itself. This is also why the
|
||||
corpus's under-quorum rows are now named `belowThreshold` by `Verify` rather than
|
||||
by the rung.
|
||||
- `cert.go` `VerifyWeighted`'s `default:` unknown-tier arm. `Verify` runs first
|
||||
and refuses any tier outside {Nova, Quasar}, so the switch below it never sees
|
||||
one. `TestATierIsRefusedByVerifyBeforeVerifyWeightedSeesIt` pins that ORDER, so
|
||||
@@ -565,7 +620,9 @@ afternoon each — so they are written down instead.
|
||||
- `pop.go` `Verify`'s canonical re-compress check, and `Verify`'s own
|
||||
`Message` error. `bls.PublicKeyFromCompressedBytes` is canonical — probed over
|
||||
all 256 spellings of byte 0 and every single-bit flip of a real key, zero
|
||||
decode to a point that re-compresses differently — and the width was checked
|
||||
decode to a point that re-compresses differently — a property of the RE-CAPTURE,
|
||||
where 9 of the 13 probe inputs were rewritten, and not of a run that happened to
|
||||
find none — and the width was checked
|
||||
two lines above. Rust's `pop.rs:83` and C++'s `bls.cpp:145` are the same wall.
|
||||
- `registration.go` `Register`'s decode after `pop.Verify` succeeded, which
|
||||
decoded the same bytes. C++ marks its twin `unreachable: pop_verify decoded it`.
|
||||
|
||||
@@ -279,7 +279,7 @@ func refusal(err error) string {
|
||||
// has failed a floor, not the wire and not a signature.
|
||||
func finality(name, note string, rung chain.Finality, s stake, signers []int) FinalityCase {
|
||||
return weigh(name, note, rung, s, signers,
|
||||
chain.SignerFloor(rung, s.SignerCount(verdictEpoch)))
|
||||
uint32(chain.SignerFloor(rung, s.SignerCount(verdictEpoch))))
|
||||
}
|
||||
|
||||
// weigh is the body: one certificate, declaring `declared`, weighed against one
|
||||
@@ -330,7 +330,7 @@ func weigh(name, note string, rung chain.Finality, s stake, signers []int, decla
|
||||
// number the derived-authority clause compares a certificate's declaration
|
||||
// against, so a corpus that computed it a second way could freeze a floor the
|
||||
// predicate does not enforce.
|
||||
signerFloor := int(chain.SignerFloor(rung, n))
|
||||
signerFloor := chain.SignerFloor(rung, n)
|
||||
|
||||
// The decision, read from the live predicate — never restated here.
|
||||
decided := cert.VerifyWeighted(trust{set: s}, s, verdictEpoch)
|
||||
|
||||
@@ -283,13 +283,13 @@ func TestVerifiedQuorumCertUnforgeableOutsideBuilder(t *testing.T) {
|
||||
t.Fatal("zero VerifiedQuorumCert must be empty (no embedded cert)")
|
||||
}
|
||||
|
||||
// 2) Builder REFUSES a sub-quorum (3 votes, α=4): no token minted.
|
||||
// 2) Builder REFUSES a sub-quorum (3 votes against a derived floor of 4): no token.
|
||||
sub := []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
}
|
||||
if vc, err := BuildVerifiedQuorumCert(vs, equal, Quasar, 4, 1, pos, sub); err == nil || !vc.IsZero() {
|
||||
if vc, err := BuildVerifiedQuorumCert(vs, equal, Quasar, 1, pos, sub); err == nil || !vc.IsZero() {
|
||||
t.Fatalf("builder must refuse a sub-α vote set (got err=%v zero=%v)", err, vc.IsZero())
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ func TestVerifiedQuorumCertUnforgeableOutsideBuilder(t *testing.T) {
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
{NodeID: vs.nodeID(3), Accept: true, Signature: vs.sign(3, pos)},
|
||||
}
|
||||
if vc, err := BuildVerifiedQuorumCert(vs, equal, Quasar, 4, 1, pos, forged); err == nil || !vc.IsZero() {
|
||||
if vc, err := BuildVerifiedQuorumCert(vs, equal, Quasar, 1, pos, forged); err == nil || !vc.IsZero() {
|
||||
t.Fatalf("builder must refuse a forged-signature vote set (got err=%v zero=%v)", err, vc.IsZero())
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ func TestVerifiedQuorumCertUnforgeableOutsideBuilder(t *testing.T) {
|
||||
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
|
||||
{NodeID: vs.nodeID(3), Accept: true, Signature: vs.sign(3, pos)},
|
||||
}
|
||||
vc, err := BuildVerifiedQuorumCert(vs, equal, Quasar, 4, 1, pos, good)
|
||||
vc, err := BuildVerifiedQuorumCert(vs, equal, Quasar, 1, pos, good)
|
||||
if err != nil || vc.IsZero() {
|
||||
t.Fatalf("builder must mint a token for a real ⅔ quorum (err=%v zero=%v)", err, vc.IsZero())
|
||||
}
|
||||
|
||||
+12
-11
@@ -46,7 +46,6 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
@@ -176,17 +175,19 @@ func (q *QuasarAttestor) Ingest(pos VotePosition, epoch uint64, att SignedVote)
|
||||
cp.Signature = append([]byte(nil), att.Signature...)
|
||||
b.votes[att.NodeID] = cp
|
||||
|
||||
// Try to emit once the export count floor ⌊2n/3⌋+1 is reached. n is the LIVE committee
|
||||
// at the epoch (no static K/α), so the quorum tracks the live set each height. This call
|
||||
// is the assembly TRIGGER and not the gate: it is read from the one definition of the
|
||||
// count floor so the attestor never stops trying before the verifier would pass, and
|
||||
// VerifyWeighted below is the sole authority on whether it does. That asymmetry is
|
||||
// deliberate — the trigger is allowed to be looser than the rule and must never be
|
||||
// tighter. It is looser at exactly one place: a set below minBFTCommittee reaches this
|
||||
// count and is refused by the export rung's committee clause, so such a chain assembles
|
||||
// and discards rather than emitting. One authority, and it is the verifier.
|
||||
// Try to emit once the export count floor is reached. n is the LIVE signing set at
|
||||
// the epoch (no static K/α), so the quorum tracks the set each height. This call is
|
||||
// the assembly TRIGGER and not the gate: it is read from the one definition of the
|
||||
// floor — SignerFloor, the same function the verifier compares this certificate's
|
||||
// declaration against — so the attestor never stops trying before the verifier would
|
||||
// pass, and VerifyWeighted below is the sole authority on whether it does. That
|
||||
// asymmetry is deliberate: the trigger is allowed to be looser than the rule and must
|
||||
// never be tighter. It is looser at exactly one place: a set below minBFTCommittee
|
||||
// reaches this count and is refused by the export rung's committee clause, so such a
|
||||
// chain assembles and discards rather than emitting. One authority, and it is the
|
||||
// verifier.
|
||||
n := q.stake.SignerCount(epoch)
|
||||
threshold := config.TwoThirdsCount(n)
|
||||
threshold := SignerFloor(Quasar, n)
|
||||
if len(b.votes) < threshold {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
@@ -185,10 +185,9 @@ func TestPruneBelowDropsEveryHeightUnderTheWindow(t *testing.T) {
|
||||
// operator reads the other.
|
||||
func TestBuildVerifiedQuorumCertFailsClosed(t *testing.T) {
|
||||
f := newCertFixture(t, 4)
|
||||
alpha := uint32(NovaSignerFloor(4))
|
||||
|
||||
// The control: a verified token, and its cert is the one that was checked.
|
||||
tok, err := BuildVerifiedQuorumCert(f.vs, f.stake, Nova, alpha, f.pos.Height, f.pos, f.votes[:3])
|
||||
tok, err := BuildVerifiedQuorumCert(f.vs, f.stake, Nova, f.pos.Height, f.pos, f.votes[:3])
|
||||
if err != nil {
|
||||
t.Fatalf("BuildVerifiedQuorumCert: %v", err)
|
||||
}
|
||||
@@ -204,13 +203,13 @@ func TestBuildVerifiedQuorumCertFailsClosed(t *testing.T) {
|
||||
{
|
||||
holds: "no verifier is a refusal, never a token nothing checked",
|
||||
build: func() (VerifiedQuorumCert, error) {
|
||||
return BuildVerifiedQuorumCert(nil, f.stake, Nova, alpha, f.pos.Height, f.pos, f.votes[:3])
|
||||
return BuildVerifiedQuorumCert(nil, f.stake, Nova, f.pos.Height, f.pos, f.votes[:3])
|
||||
},
|
||||
},
|
||||
{
|
||||
holds: "votes short of the count floor do not assemble",
|
||||
build: func() (VerifiedQuorumCert, error) {
|
||||
return BuildVerifiedQuorumCert(f.vs, f.stake, Nova, alpha, f.pos.Height, f.pos, f.votes[:1])
|
||||
return BuildVerifiedQuorumCert(f.vs, f.stake, Nova, f.pos.Height, f.pos, f.votes[:1])
|
||||
},
|
||||
cause: ErrQCBelowThreshold,
|
||||
},
|
||||
@@ -219,21 +218,28 @@ func TestBuildVerifiedQuorumCertFailsClosed(t *testing.T) {
|
||||
build: func() (VerifiedQuorumCert, error) {
|
||||
short := f.stake
|
||||
short.signerStake = 0
|
||||
return BuildVerifiedQuorumCert(f.vs, short, Nova, alpha, f.pos.Height, f.pos, f.votes[:3])
|
||||
return BuildVerifiedQuorumCert(f.vs, short, Nova, f.pos.Height, f.pos, f.votes[:3])
|
||||
},
|
||||
cause: ErrQCStakeBelowMajority,
|
||||
},
|
||||
{
|
||||
holds: "with no stake source the count-only predicate still runs, and still refuses",
|
||||
holds: "a signature that does not verify is not a token",
|
||||
build: func() (VerifiedQuorumCert, error) {
|
||||
bad := make([]SignedVote, 3)
|
||||
copy(bad, f.votes[:3])
|
||||
bad[0].Signature = append([]byte(nil), bad[0].Signature...)
|
||||
bad[0].Signature[0] ^= 0xFF
|
||||
return BuildVerifiedQuorumCert(f.vs, nil, Nova, alpha, f.pos.Height, f.pos, bad)
|
||||
return BuildVerifiedQuorumCert(f.vs, f.stake, Nova, f.pos.Height, f.pos, bad)
|
||||
},
|
||||
cause: ErrQCSigInvalid,
|
||||
},
|
||||
{
|
||||
holds: "with no set there is no floor, so there is nothing to mint",
|
||||
build: func() (VerifiedQuorumCert, error) {
|
||||
return BuildVerifiedQuorumCert(f.vs, nil, Nova, f.pos.Height, f.pos, f.votes[:3])
|
||||
},
|
||||
cause: ErrCertNeedsStake,
|
||||
},
|
||||
} {
|
||||
t.Run(row.holds, func(t *testing.T) {
|
||||
tok, err := row.build()
|
||||
|
||||
@@ -271,23 +271,20 @@ func (rt *Runtime) VerifyCatchupCertificate(ctx context.Context, blockBytes, cer
|
||||
t := rt.Transitive
|
||||
t.mu.RLock()
|
||||
chainID := t.chainID
|
||||
floor := t.consensus.Alpha()
|
||||
if cert.Tier == Nova {
|
||||
// The live committee's majority. NovaSignerFloor saturates at 3 for every
|
||||
// K, so it asked a committee of nine for three signatures — and this door
|
||||
// is the mirror of the gossip one, so leaving it behind would keep the
|
||||
// whole hole open on the catch-up path. One invariant, two doors.
|
||||
floor = NovaQuorum(t.consensus.K())
|
||||
}
|
||||
setRootSource := t.setRootSource
|
||||
t.mu.RUnlock()
|
||||
|
||||
// The clauses here bind the proof to THIS chain, THIS block and THIS canonical
|
||||
// commitment. The quorum is not among them: it is one number with one definition
|
||||
// and it is read in verifyCert below, which derives it from the set (or, with no
|
||||
// stake source, from this node's own committee) and demands the certificate's
|
||||
// declaration equal it. A second floor spelled here — the sample committee's
|
||||
// majority, as it used to be — is a different quantity, and a door that asks for
|
||||
// more than the gate refuses certificates the gate would admit. One invariant,
|
||||
// one place, and this is the mirror of the gossip road for that reason.
|
||||
if cert.Position.ChainID != chainID ||
|
||||
cert.Position.Height != blk.Height() ||
|
||||
certCanonical(cert) != canonicalIDOf(blk) ||
|
||||
// The votes carried, not the number claimed: a self-declared threshold is
|
||||
// a bar chosen by the party it constrains.
|
||||
(floor > 0 && (cert.Threshold < uint32(floor) || cert.VoterCount() < floor)) {
|
||||
certCanonical(cert) != canonicalIDOf(blk) {
|
||||
return ErrCatchupCertRejected
|
||||
}
|
||||
|
||||
|
||||
+17
-9
@@ -133,12 +133,20 @@ var (
|
||||
// An unknown tier has no floor, so it gets none: 0 is returned and every caller
|
||||
// treats it as a refusal. n<1 is an unresolved set, refused by the predicate
|
||||
// before this is consulted.
|
||||
func SignerFloor(tier Finality, n int) uint32 {
|
||||
//
|
||||
// Counted in an int, which is the width the set is counted in. A cert states its
|
||||
// quorum in a uint32, and a floor that does not fit one is a floor no cert can
|
||||
// state — so the number is carried at full width to HERE and narrowed only where
|
||||
// a cert is written. Narrowing first would wrap: ⌊2n/3⌋+1 passes 2^32 at about
|
||||
// six billion signers and a wrapped floor of 1 is a floor a lone signer meets.
|
||||
// Because the comparison below is made at full width, a mint that narrowed is
|
||||
// caught by it rather than believed.
|
||||
func SignerFloor(tier Finality, n int) int {
|
||||
switch tier {
|
||||
case Nova:
|
||||
return uint32(NovaSignerFloor(n))
|
||||
return NovaSignerFloor(n)
|
||||
case Quasar:
|
||||
return uint32(config.TwoThirdsCount(n))
|
||||
return config.TwoThirdsCount(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
@@ -167,12 +175,12 @@ func SignerFloor(tier Finality, n int) uint32 {
|
||||
// count-only road assembleCertLocked declares NovaQuorum(n), which is 4 at six
|
||||
// signers where NovaSignerFloor is 3. Every K below six hides it, which is every K
|
||||
// the tests use.
|
||||
func Quorum(tier Finality, n int) uint32 {
|
||||
func Quorum(tier Finality, n int) int {
|
||||
switch tier {
|
||||
case Nova:
|
||||
return uint32(NovaQuorum(n))
|
||||
return NovaQuorum(n)
|
||||
case Quasar:
|
||||
return uint32(config.TwoThirdsCount(n))
|
||||
return config.TwoThirdsCount(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
@@ -556,7 +564,7 @@ func (c *QuorumCert) VerifyWeighted(verifier VoteVerifier, stake StakeSource, ep
|
||||
// and the rung's own clause refuses the cert by the name it has always been
|
||||
// refused under. Skipping here is not a pass: both rungs fail closed on n<1.
|
||||
if n := stake.SignerCount(epochHeight); n >= 1 {
|
||||
if floor := SignerFloor(c.Tier, n); c.Threshold != floor {
|
||||
if floor := SignerFloor(c.Tier, n); int(c.Threshold) != floor {
|
||||
return fmt.Errorf("%w: cert declares %d, a set of %d signers derives %d for %s at epoch %d",
|
||||
ErrQCThresholdNotDerived, c.Threshold, n, floor, c.Tier, epochHeight)
|
||||
}
|
||||
@@ -599,7 +607,7 @@ func (c *QuorumCert) verifyNovaMajority(stake StakeSource, epochHeight uint64) e
|
||||
return fmt.Errorf("%w: nova tier over an unresolved validator set (n=%d) at epoch %d",
|
||||
ErrQCBelowThreshold, n, epochHeight)
|
||||
}
|
||||
if floor := int(SignerFloor(Nova, n)); c.VoterCount() < floor {
|
||||
if floor := SignerFloor(Nova, n); c.VoterCount() < floor {
|
||||
return fmt.Errorf("%w: nova cert has %d distinct voters, need at least %d of %d at epoch %d",
|
||||
ErrQCBelowThreshold, c.VoterCount(), floor, n, epochHeight)
|
||||
}
|
||||
@@ -717,7 +725,7 @@ func (c *QuorumCert) verifyQuasarSupermajority(stake StakeSource, epochHeight ui
|
||||
"Byzantine committee f=⌊(n−1)/3⌋ is 0 and a two-thirds supermajority tolerates no "+
|
||||
"fault, at epoch %d", ErrQCBelowThreshold, n, minBFTCommittee, epochHeight)
|
||||
}
|
||||
if floor := int(SignerFloor(Quasar, n)); c.VoterCount() < floor {
|
||||
if floor := SignerFloor(Quasar, n); c.VoterCount() < floor {
|
||||
return fmt.Errorf("%w: quasar cert has %d distinct voters, need at least %d of %d at epoch %d",
|
||||
ErrQCBelowThreshold, c.VoterCount(), floor, n, epochHeight)
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@ package chain
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// certDeclaring assembles a certificate over votes and stamps the threshold a set
|
||||
@@ -36,7 +38,7 @@ func certDeclaring(t *testing.T, pos VotePosition, tier Finality, n int, votes [
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
c.Threshold = SignerFloor(tier, n)
|
||||
c.Threshold = uint32(SignerFloor(tier, n))
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -45,10 +47,10 @@ func certDeclaring(t *testing.T, pos VotePosition, tier Finality, n int, votes [
|
||||
// which every caller reads as a refusal.
|
||||
func TestSignerFloorIsTheOneDefinition(t *testing.T) {
|
||||
for n := 1; n <= 64; n++ {
|
||||
if got, want := SignerFloor(Nova, n), uint32(NovaSignerFloor(n)); got != want {
|
||||
if got, want := SignerFloor(Nova, n), NovaSignerFloor(n); got != want {
|
||||
t.Fatalf("n=%d: nova floor %d, want %d", n, got, want)
|
||||
}
|
||||
if got, want := SignerFloor(Quasar, n), uint32(config.TwoThirdsCount(n)); got != want {
|
||||
if got, want := SignerFloor(Quasar, n), config.TwoThirdsCount(n); got != want {
|
||||
t.Fatalf("n=%d: quasar floor %d, want %d", n, got, want)
|
||||
}
|
||||
}
|
||||
@@ -57,10 +59,9 @@ func TestSignerFloorIsTheOneDefinition(t *testing.T) {
|
||||
t.Fatalf("%s is not an accept rung and must derive no floor, got %d", rung, got)
|
||||
}
|
||||
}
|
||||
// The function is exported and its result is unsigned, so a set size below one
|
||||
// must not underflow into a floor no quorum could ever reach. Both rungs are
|
||||
// total there and answer one — the predicate refuses an unresolved set before
|
||||
// consulting this, and a wrapped floor would make that refusal look like a bar.
|
||||
// A set size below one must not produce a floor no quorum could ever reach.
|
||||
// Both rungs are total there and answer one — the predicate refuses an
|
||||
// unresolved set before consulting this.
|
||||
for _, n := range []int{0, -1, -41} {
|
||||
for _, rung := range []Finality{Nova, Quasar} {
|
||||
if got := SignerFloor(rung, n); got != 1 {
|
||||
@@ -123,54 +124,64 @@ func TestDerivedThresholdRefusesAnUnderClaim(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountOnlyRoadCannotMintExport is MED-2. BuildVerifiedQuorumCert is the sole
|
||||
// producer of the finality authority token, and its count-only branch counts votes
|
||||
// against the number the CALLER names. Handed an export rung, one vote and an
|
||||
// alpha of one, every clause on that branch is satisfied by the same self-declared
|
||||
// 1 — and AcceptWithCert trusts the token it returns, which is the whole point of
|
||||
// the token.
|
||||
// TestMintingNeedsTheSet is MED-2 and the finding after it, in the form the fix
|
||||
// takes: the minting door names no quorum of its own and mints nothing without a
|
||||
// set to derive one from.
|
||||
//
|
||||
// An export certificate's floors are read off a validator set. With no set there is
|
||||
// nothing to read them from, so there is nothing to mint.
|
||||
func TestCountOnlyRoadCannotMintExport(t *testing.T) {
|
||||
// BuildVerifiedQuorumCert is the exported producer of the finality authority token,
|
||||
// and it USED to count the caller's votes against the caller's own alpha whenever no
|
||||
// stake source was wired. One vote and an alpha of one satisfied every clause on that
|
||||
// branch with the same self-declared 1 — for the export rung until that was refused
|
||||
// outright, and for the accept rung after, on a chain of five, while a certificate
|
||||
// carrying exactly those bytes was refused on arrival. AcceptWithCert trusts the
|
||||
// token, which is the whole point of the token.
|
||||
//
|
||||
// A floor is a property of the SET. There is no set here, so there is no floor and
|
||||
// nothing to mint — at BOTH rungs, which is the rule stated once instead of a list
|
||||
// of tier names kept in step with another list.
|
||||
func TestMintingNeedsTheSet(t *testing.T) {
|
||||
vs := newTestValidatorSet(4)
|
||||
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
|
||||
one := []SignedVote{{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)}}
|
||||
|
||||
v, err := BuildVerifiedQuorumCert(vs, nil, Quasar, 1, 1, pos, one)
|
||||
if !errors.Is(err, ErrExportNeedsStake) {
|
||||
t.Fatalf("an export token minted over one signer with no stake source, err=%v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrNoVerifiedQC) {
|
||||
t.Fatalf("the refusal must still present as the liveness answer, got %v", err)
|
||||
}
|
||||
if !v.IsZero() {
|
||||
t.Fatal("a refused build must return the zero token")
|
||||
}
|
||||
|
||||
// Every export rung, not a list of tier names kept in step with another list.
|
||||
for _, rung := range []Finality{Quasar, Horizon} {
|
||||
if !rung.AuthorizesExport() {
|
||||
t.Fatalf("%s is expected to authorize export", rung)
|
||||
}
|
||||
if _, err := BuildVerifiedQuorumCert(vs, nil, rung, 1, 1, pos, one); !errors.Is(err, ErrExportNeedsStake) {
|
||||
t.Fatalf("%s minted with no stake source: %v", rung, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The accept rung keeps its count-only road: it authorizes local execution the
|
||||
// chain can still reorg away, and an equal-stake chain has to be able to make
|
||||
// progress on it. Three of four, declaring the count majority that road enforces.
|
||||
votes := make([]SignedVote, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
votes := make([]SignedVote, 0, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
nova, err := BuildVerifiedQuorumCert(vs, nil, Nova, uint32(NovaQuorum(4)), 1, pos, votes)
|
||||
if err != nil {
|
||||
t.Fatalf("the accept rung must still build without a stake source: %v", err)
|
||||
one := votes[:1]
|
||||
|
||||
for _, rung := range []Finality{Nova, Quasar, Horizon} {
|
||||
v, err := BuildVerifiedQuorumCert(vs, nil, rung, 1, pos, one)
|
||||
if !errors.Is(err, ErrCertNeedsStake) {
|
||||
t.Fatalf("%s minted over one signer with no set, err=%v", rung, err)
|
||||
}
|
||||
if !errors.Is(err, ErrNoVerifiedQC) {
|
||||
t.Fatalf("%s: the refusal must still present as the liveness answer, got %v", rung, err)
|
||||
}
|
||||
if !v.IsZero() {
|
||||
t.Fatalf("%s: a refused build must return the zero token", rung)
|
||||
}
|
||||
}
|
||||
if nova.IsZero() {
|
||||
t.Fatal("a nova build that returned no error must carry a witness")
|
||||
|
||||
// With a set in hand the floor is the set's, and the caller has no say in it:
|
||||
// there is no alpha to pass. Four of four at unit stake carries both rungs.
|
||||
set := &stakeMap{w: map[ids.NodeID]uint64{
|
||||
vs.nodeID(0): 1, vs.nodeID(1): 1, vs.nodeID(2): 1, vs.nodeID(3): 1}, total: 4}
|
||||
for _, rung := range []Finality{Nova, Quasar} {
|
||||
tok, err := BuildVerifiedQuorumCert(vs, set, rung, 1, pos, votes)
|
||||
if err != nil {
|
||||
t.Fatalf("%s over a real set must mint: %v", rung, err)
|
||||
}
|
||||
if tok.IsZero() || tok.Cert() == nil {
|
||||
t.Fatalf("%s: a build that returned no error must carry a witness", rung)
|
||||
}
|
||||
if got, want := tok.Cert().Threshold, uint32(SignerFloor(rung, 4)); got != want {
|
||||
t.Fatalf("%s: the minted cert declares %d, the set derives %d", rung, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A rung that is not an accept tier derives no floor at all, so there is no
|
||||
// quorum for a certificate to state and none is stated.
|
||||
if _, err := BuildVerifiedQuorumCert(vs, set, Photon, 1, pos, votes); !errors.Is(err, ErrCertFloorUnstatable) {
|
||||
t.Fatalf("a non-accept rung derives no floor and must mint nothing, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,9 +197,8 @@ func TestCountOnlyRoadCannotMintExport(t *testing.T) {
|
||||
// The two doors answer differently and the difference is the threat, not an
|
||||
// oversight:
|
||||
//
|
||||
// - MINTING an export token from raw votes takes the floor from the CALLER's
|
||||
// alpha, so with no set to check it against there is nothing to check at all.
|
||||
// Refused outright.
|
||||
// - MINTING a token from raw votes derives the floor from the set, so with no set
|
||||
// there is no floor and nothing to mint. Refused outright, at both rungs.
|
||||
// - 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 here too — from the node's live committee, which is
|
||||
@@ -196,23 +206,7 @@ func TestCountOnlyRoadCannotMintExport(t *testing.T) {
|
||||
// keeps the export rung it has always had. Not "such a chain cannot export",
|
||||
// but "it may not export on a quorum the certificate wrote for itself".
|
||||
func TestGossipRoadDerivesItsFloorToo(t *testing.T) {
|
||||
for _, rung := range []Finality{Quasar, Horizon} {
|
||||
if err := exportNeedsStake(rung, nil); !errors.Is(err, ErrExportNeedsStake) {
|
||||
t.Fatalf("%s with no stake source must be refused, got %v", rung, err)
|
||||
}
|
||||
}
|
||||
if err := exportNeedsStake(Nova, nil); err != nil {
|
||||
t.Fatalf("the accept rung keeps its count-only road: %v", err)
|
||||
}
|
||||
// With a set in hand the rule says nothing — it is about the absence, not the tier.
|
||||
set := &stakeMap{w: map[ids.NodeID]uint64{}, total: 4}
|
||||
for _, rung := range []Finality{Nova, Quasar, Horizon} {
|
||||
if err := exportNeedsStake(rung, set); err != nil {
|
||||
t.Fatalf("%s with a stake source must not be refused by this clause: %v", rung, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AND THE OTHER DOOR, on a real stake-less engine. Its committee is K, and the
|
||||
// A real stake-less engine. Its committee is K, and the
|
||||
// export quorum over five seats is four — so a certificate declaring one is a
|
||||
// certificate naming its own quorum, and a certificate declaring four is the
|
||||
// one this node would have built.
|
||||
@@ -234,7 +228,7 @@ func TestGossipRoadDerivesItsFloorToo(t *testing.T) {
|
||||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||||
}
|
||||
|
||||
honest, err := AssembleQuorumCert(pos, Quasar, Quorum(Quasar, 5), votes)
|
||||
honest, err := AssembleQuorumCert(pos, Quasar, uint32(Quorum(Quasar, 5)), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
@@ -280,12 +274,12 @@ func TestTheTwoRoadsDeriveTheNumberTheyDeclare(t *testing.T) {
|
||||
diverged := false
|
||||
for n := 1; n <= 64; n++ {
|
||||
// What the count-only assembler declares (engine.go, the nil-stake arm).
|
||||
if got, want := Quorum(Nova, n), uint32(NovaQuorum(n)); got != want {
|
||||
if got, want := Quorum(Nova, n), NovaQuorum(n); got != want {
|
||||
t.Fatalf("n=%d: the count-only road derives %d for nova, its assembler declares %d",
|
||||
n, got, want)
|
||||
}
|
||||
// What the weighted assembler declares (engine.go, the stake arm).
|
||||
if got, want := SignerFloor(Nova, n), uint32(NovaSignerFloor(n)); got != want {
|
||||
if got, want := SignerFloor(Nova, n), NovaSignerFloor(n); got != want {
|
||||
t.Fatalf("n=%d: the weighted road derives %d for nova, its assembler declares %d",
|
||||
n, got, want)
|
||||
}
|
||||
@@ -333,3 +327,191 @@ func TestDerivedThresholdSurvivesAnUnresolvedSet(t *testing.T) {
|
||||
t.Fatalf("an unresolved set derives no floor and must not be named by the derived clause: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// arrivalFleet is one node holding a set of `signers` weighted validators, under a
|
||||
// committee of K seats. The two numbers are deliberately allowed to differ: K sizes
|
||||
// the sample a ROUND asks, and the set is what a certificate's floor is a property
|
||||
// of. Every place that has confused the two has produced a second floor.
|
||||
type arrivalFleet struct {
|
||||
vs *testValidatorSet
|
||||
stake *stakeMap
|
||||
chainID ids.ID
|
||||
rt *Runtime
|
||||
vm *catchupVM
|
||||
}
|
||||
|
||||
func newArrivalFleet(t *testing.T, k, signers int) *arrivalFleet {
|
||||
t.Helper()
|
||||
vs := newTestValidatorSet(k)
|
||||
weights := make([]uint64, signers)
|
||||
for i := range weights {
|
||||
weights[i] = 1
|
||||
}
|
||||
f := &arrivalFleet{
|
||||
vs: vs,
|
||||
stake: newStakeMap(vs, weights...),
|
||||
chainID: ids.GenerateTestID(),
|
||||
vm: newCatchupVM(),
|
||||
}
|
||||
f.rt = NewRuntime(NetworkConfig{
|
||||
ChainID: f.chainID,
|
||||
NetworkID: ids.GenerateTestID(),
|
||||
NodeID: vs.nodeID(0),
|
||||
Logger: log.Noop(),
|
||||
Params: ptrParams(matrixParams(k, quorumq(k))),
|
||||
VoteVerifier: vs,
|
||||
VoteSigner: vs.signerFor(0),
|
||||
StakeSource: f.stake,
|
||||
Gossiper: &recordingGossiper{},
|
||||
VM: f.vm,
|
||||
})
|
||||
if err := f.rt.Start(context.Background(), true); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = f.rt.Stop(context.Background()) })
|
||||
return f
|
||||
}
|
||||
|
||||
// novaCert is the certificate an honest assembler on this chain produces: `voters`
|
||||
// signatures under the floor the SET derives, which is the number VerifyWeighted
|
||||
// demands and the only one it admits.
|
||||
func (f *arrivalFleet) novaCert(t *testing.T, blk *verifyOnceBlock, voters int) (*QuorumCert, []byte) {
|
||||
t.Helper()
|
||||
pos := VotePosition{ChainID: f.chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
|
||||
votes := make([]SignedVote, 0, voters)
|
||||
for i := 0; i < voters; i++ {
|
||||
votes = append(votes, SignedVote{NodeID: f.vs.nodeID(i), Accept: true, Signature: f.vs.sign(i, pos)})
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, Nova, uint32(SignerFloor(Nova, f.stake.SignerCount(0))), votes)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
b, err := cert.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return cert, b
|
||||
}
|
||||
|
||||
// TestTheDoorAdmitsWhatTheGateAdmits is the invariant a pre-filter has to keep and
|
||||
// could not: a certificate the finality predicate ACCEPTS must reach it.
|
||||
//
|
||||
// Both arrival roads used to read a floor of their own before verifyCert ran — the
|
||||
// sample committee's majority for Nova, Alpha() for Quasar. 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
|
||||
// eleven it demanded six. And where the set is SMALLER than the committee the door
|
||||
// asked the set for more signatures than the set's own floor: three signers derive
|
||||
// two, and a door reading a committee of four asked for three.
|
||||
//
|
||||
// So an honest certificate — the exact bytes this fleet's own assembler produces —
|
||||
// never reached the predicate that would have admitted it. The rows below are the
|
||||
// sizes where the two numbers part; a door in front of the gate has to pass all of
|
||||
// them, and the arithmetic assertion is what makes each row non-vacuous.
|
||||
func TestTheDoorAdmitsWhatTheGateAdmits(t *testing.T) {
|
||||
for _, row := range []struct{ k, signers, voters int }{
|
||||
// A set smaller than its committee: the floor is the SET's two, and the
|
||||
// committee's majority is three.
|
||||
{k: 4, signers: 3, voters: 2},
|
||||
// Set == committee, past the point NovaSignerFloor saturates. The stake
|
||||
// majority still needs more voters than the floor names, which is exactly
|
||||
// why an honest certificate carries more signatures than it declares.
|
||||
{k: 6, signers: 6, voters: 4},
|
||||
{k: 9, signers: 9, voters: 5},
|
||||
{k: 11, signers: 11, voters: 6},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("k=%d/signers=%d", row.k, row.signers), func(t *testing.T) {
|
||||
f := newArrivalFleet(t, row.k, row.signers)
|
||||
derived := SignerFloor(Nova, row.signers)
|
||||
if NovaQuorum(row.k) <= derived {
|
||||
t.Fatalf("vacuous: the committee majority %d must exceed the set's floor %d, "+
|
||||
"or this row proves nothing about a door reading the committee",
|
||||
NovaQuorum(row.k), derived)
|
||||
}
|
||||
|
||||
// THE GATE, on its own: the predicate accepts this certificate.
|
||||
blk := newTestBlock(1, ids.Empty, "arrival")
|
||||
trackVerifiedBlock(f.rt, blk, 0)
|
||||
cert, wire := f.novaCert(t, blk, row.voters)
|
||||
if err := f.rt.Transitive.verifyCert(cert, 0); err != nil {
|
||||
t.Fatalf("the finality predicate refused an honest certificate: %v", err)
|
||||
}
|
||||
if int(cert.Threshold) != derived {
|
||||
t.Fatalf("the cert declares %d, the set derives %d", cert.Threshold, derived)
|
||||
}
|
||||
|
||||
// THE GOSSIP ROAD: it reaches the gate, and finalizes.
|
||||
if !f.rt.HandleIncomingCert(wire) {
|
||||
t.Fatalf("the gossip road dropped a certificate the predicate accepts — "+
|
||||
"a door reading the committee's majority %d refuses the set's floor %d",
|
||||
NovaQuorum(row.k), derived)
|
||||
}
|
||||
if got := blk.AcceptCalled(); got != 1 {
|
||||
t.Fatalf("VM.Accept=%d want 1", got)
|
||||
}
|
||||
|
||||
// THE CATCH-UP ROAD: the mirror of it, and the same answer. A separate
|
||||
// runtime so the height gate does not see the block already finalized.
|
||||
g := newArrivalFleet(t, row.k, row.signers)
|
||||
far := newTestBlock(41, ids.GenerateTestID(), "arrival-frontier")
|
||||
g.vm.register(far)
|
||||
_, farWire := g.novaCert(t, far, row.voters)
|
||||
if err := g.rt.VerifyCatchupCertificate(context.Background(), far.bytes, farWire); err != nil {
|
||||
t.Fatalf("the catch-up road refused a certificate the predicate accepts: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheArrivalRoadsRefuseAQuorumTheSetDoesNotDerive is the other half, and the
|
||||
// reason dropping the pre-filters costs nothing: what the door used to catch, the
|
||||
// gate catches — and catches harder, because the gate demands EQUALITY with the
|
||||
// derived floor where the door asked only for "at least".
|
||||
func TestTheArrivalRoadsRefuseAQuorumTheSetDoesNotDerive(t *testing.T) {
|
||||
f := newArrivalFleet(t, 9, 9)
|
||||
blk := newTestBlock(1, ids.Empty, "self-named")
|
||||
trackVerifiedBlock(f.rt, blk, 0)
|
||||
|
||||
// One signature under a self-named quorum of one: what a door reading any
|
||||
// "at least" floor also refused, stated the way an attacker builds it.
|
||||
pos := VotePosition{ChainID: f.chainID, Height: 1, Round: 0, BlockID: blk.id, ParentID: ids.Empty}
|
||||
lone, err := AssembleQuorumCert(pos, Nova, 1,
|
||||
[]SignedVote{{NodeID: f.vs.nodeID(0), Accept: true, Signature: f.vs.sign(0, pos)}})
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
if lone.Verify(f.vs, 0) != nil {
|
||||
t.Fatal("the lone certificate must clear the structural predicate, or the case proves nothing")
|
||||
}
|
||||
if err := f.rt.Transitive.verifyCert(lone, 0); !errors.Is(err, ErrQCThresholdNotDerived) {
|
||||
t.Fatalf("a self-named quorum of one must be refused on the derived clause, got %v", err)
|
||||
}
|
||||
wire, err := lone.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if f.rt.HandleIncomingCert(wire) || blk.AcceptCalled() != 0 {
|
||||
t.Fatal("SAFETY BREAK: one signature under a self-named quorum finalized a block")
|
||||
}
|
||||
|
||||
// And an OVER-claim, which no "at least" door ever caught: a certificate
|
||||
// declaring more than its set requires is naming a number that is not the
|
||||
// set's, in the other direction.
|
||||
honest, honestWire := f.novaCert(t, blk, 5)
|
||||
over := *honest
|
||||
over.Threshold = uint32(NovaQuorum(9))
|
||||
if int(over.Threshold) == SignerFloor(Nova, 9) {
|
||||
t.Fatal("vacuous: the over-claim must differ from the derived floor")
|
||||
}
|
||||
if err := f.rt.Transitive.verifyCert(&over, 0); !errors.Is(err, ErrQCThresholdNotDerived) {
|
||||
t.Fatalf("an over-claim must be refused on the derived clause, got %v", err)
|
||||
}
|
||||
|
||||
// The honest certificate, last, so the two refusals above are attributable to
|
||||
// the number each names and not to the fixture.
|
||||
if !f.rt.HandleIncomingCert(honestWire) || blk.AcceptCalled() != 1 {
|
||||
t.Fatal("control broke: the honest certificate must finalize")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3242,7 +3242,7 @@ func (t *Transitive) assembleCertLocked(pending *PendingBlock, blockID ids.ID) *
|
||||
// set derives two, and the certificate this node just built would be refused by
|
||||
// the node it is sent to. This is the export path's rule (attestation.go reads
|
||||
// SignerCount for the same reason) applied to the accept rung.
|
||||
novaThreshold = int(SignerFloor(Nova, t.stakeSource.SignerCount(epochHeight)))
|
||||
novaThreshold = SignerFloor(Nova, t.stakeSource.SignerCount(epochHeight))
|
||||
}
|
||||
if novaThreshold <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -48,7 +48,7 @@ func (s flatSet) unanimous(t *testing.T) *QuorumCert {
|
||||
// The certificate declares the floor this set derives for the export rung — the
|
||||
// only threshold VerifyWeighted admits — so a row that refuses is refused on the
|
||||
// committee floor and not on a number the certificate chose for itself.
|
||||
cert.Threshold = SignerFloor(Quasar, s.n)
|
||||
cert.Threshold = uint32(SignerFloor(Quasar, s.n))
|
||||
return cert
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestExportCommitteeFloorLeavesNovaAlone(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("n=%d: assemble nova: %v", n, err)
|
||||
}
|
||||
cert.Threshold = SignerFloor(Nova, n)
|
||||
cert.Threshold = uint32(SignerFloor(Nova, n))
|
||||
if err := cert.VerifyWeighted(alwaysValid{}, set, 0); err != nil {
|
||||
t.Errorf("n=%d: a unanimous NOVA certificate was refused (%v). Nova ignites "+
|
||||
"local execution on a bare majority and has no Byzantine claim to protect; "+
|
||||
|
||||
@@ -52,7 +52,7 @@ func buildCertAtRound(t *testing.T, vs *testValidatorSet, chainID, blockID, pare
|
||||
if err != nil {
|
||||
t.Fatalf("assemble cert (round %d): %v", round, err)
|
||||
}
|
||||
cert.Threshold = SignerFloor(Quasar, len(vs.ids))
|
||||
cert.Threshold = uint32(SignerFloor(Quasar, len(vs.ids)))
|
||||
b, err := cert.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cert (round %d): %v", round, err)
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestQuasarTallyRefusesAWrappingStakeSource(t *testing.T) {
|
||||
// Declaring the floor the five-signer set derives, so the row reaches the tally
|
||||
// rather than being answered by a threshold the certificate named for itself.
|
||||
cert := certDeclaring(t, pos, Quasar, 5, votes)
|
||||
if floor := int(SignerFloor(Quasar, src.SignerCount(epoch))); cert.VoterCount() < floor {
|
||||
if floor := SignerFloor(Quasar, src.SignerCount(epoch)); cert.VoterCount() < floor {
|
||||
t.Fatalf("this set does not reach the export signer floor (%d of %d), so the tally is never read",
|
||||
cert.VoterCount(), floor)
|
||||
}
|
||||
|
||||
+21
-40
@@ -192,46 +192,27 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
if verifier == nil {
|
||||
return false
|
||||
}
|
||||
// The cert's threshold must meet our own floor for its tier: a cert asserting a lower
|
||||
// threshold than this chain requires for that tier is rejected even if its internal
|
||||
// signatures verify. This is a cheap sub-quorum forgery filter; the authority is
|
||||
// verifyCert → VerifyWeighted below, which re-derives the tier threshold from the
|
||||
// validator set at the cert's epoch. A gossiped Nova cert legitimately carries a
|
||||
// bare-majority threshold below the ⅔ Quasar floor, so the floor is tier-selected:
|
||||
// NovaSignerFloor(K) for Nova, the ⅔ count Alpha() for Quasar. K/Alpha track the live
|
||||
// committee (construction clamp + reclampCommitteeLocked), so both floors follow the
|
||||
// live set. An unknown tier is left to verifyCert, which rejects fail-closed.
|
||||
// NO FLOOR IS READ HERE. There used to be one — a cheap sub-quorum filter over
|
||||
// the sample committee, K's majority for Nova and Alpha() for Quasar — and it
|
||||
// was a THIRD spelling of a quantity that has one definition. A door in front of
|
||||
// a gate may only ever refuse what the gate refuses, and this one could not: the
|
||||
// gate derives the accept rung's floor from the SIGNING SET (SignerFloor, which
|
||||
// saturates at three), while the door read the sample committee's majority, which
|
||||
// grows. On nine validators the gate accepts a certificate declaring three and the
|
||||
// door demanded five, so an honest certificate never reached the predicate that
|
||||
// would have admitted it. The two numbers are different quantities and no amount of
|
||||
// re-spelling makes them one.
|
||||
//
|
||||
// The Nova floor is the signer floor, not the count majority: on a stake-weighted chain
|
||||
// the Nova majority is measured in stake, so a legitimate cert's self-declared threshold
|
||||
// is the signer floor and a count-majority pre-filter here would drop it before the
|
||||
// authoritative stake check ever ran. On an equal-stake chain the signer floor is ≤ the
|
||||
// count majority every legitimate cert already carries, so the filter is unweakened.
|
||||
floor := t.consensus.Alpha() // Quasar ⅔ count floor
|
||||
if cert.Tier == Nova {
|
||||
// The MAJORITY of the live committee, not NovaSignerFloor. That floor
|
||||
// saturates at NovaQuorum(minBFTCommittee) = 3 for every K, so on a
|
||||
// committee of nine it asked for three — and with no stake source wired
|
||||
// nothing downstream re-derives the majority, leaving the cert's own
|
||||
// declared Threshold as the whole bar. Three relayed votes then finalize
|
||||
// on any committee, two disjoint triples both certify at one height with
|
||||
// no validator equivocating, and a three-vote cert for a losing sibling
|
||||
// jails three validators who each signed one block once.
|
||||
floor = NovaQuorum(t.consensus.K())
|
||||
}
|
||||
// Against the votes the cert ACTUALLY carries, not the number it claims to
|
||||
// need. A self-declared threshold is a security parameter chosen by the party
|
||||
// the parameter is meant to constrain; quasar's own weighted verifier already
|
||||
// refuses to let a cert be the sole source of its own bar.
|
||||
if floor > 0 && (cert.Threshold < uint32(floor) || cert.VoterCount() < floor) {
|
||||
if !rt.config.Logger.IsZero() {
|
||||
rt.config.Logger.Warn("incoming cert: threshold below chain floor for its tier",
|
||||
log.Stringer("blockID", cert.Position.BlockID),
|
||||
log.Uint32("certThreshold", cert.Threshold),
|
||||
log.Int("tierFloor", floor))
|
||||
}
|
||||
return false
|
||||
}
|
||||
// So the floor is read once, where it is authoritative: verifyCert, below and on
|
||||
// every branch out of this function — the stake-weighted predicate when a stake
|
||||
// source is wired, the count-only predicate against this node's own committee
|
||||
// otherwise, and on either road the certificate's declared quorum must EQUAL the
|
||||
// one this node derives. That is strictly stronger than any "at least" filter a
|
||||
// door could have applied, so nothing is lost by not applying it twice.
|
||||
//
|
||||
// Nor is any work saved by refusing early: the dropped certificates were the ones
|
||||
// carrying FEW votes, which are the cheap ones. A certificate carrying many costs
|
||||
// what it always cost, and Verify stops at the first signature that fails.
|
||||
if cert.Position.ChainID != chainID {
|
||||
return false
|
||||
}
|
||||
@@ -669,7 +650,7 @@ func (t *Transitive) verifyCert(cert *QuorumCert, epochHeight uint64) error {
|
||||
return err
|
||||
}
|
||||
if committee >= 1 {
|
||||
if floor := Quorum(cert.Tier, committee); cert.Threshold != floor {
|
||||
if floor := Quorum(cert.Tier, committee); int(cert.Threshold) != floor {
|
||||
return fmt.Errorf("%w: cert declares %d, a committee of %d derives %d for %s at epoch %d",
|
||||
ErrQCThresholdNotDerived, cert.Threshold, committee, floor, cert.Tier, epochHeight)
|
||||
}
|
||||
|
||||
@@ -13,19 +13,37 @@
|
||||
// finalize a block is Transitive.AcceptWithCert, and it CANNOT be called without
|
||||
// a VerifiedQuorumCert value.
|
||||
//
|
||||
// A VerifiedQuorumCert is UNFORGEABLE outside this file: its only field (qc) is
|
||||
// unexported, so no other package — and no other file in this package by
|
||||
// accident — can construct a non-zero one with a struct literal. The sole
|
||||
// producer is BuildVerifiedQuorumCert, which runs the full stake-weighted
|
||||
// predicate (QuorumCert.VerifyWeighted, the strict >⅔-of-stake gate) before it
|
||||
// will wrap a cert. A raw α-of-K COUNT ("enough voters responded",
|
||||
// A VerifiedQuorumCert is UNFORGEABLE outside this package: its only field (qc) is
|
||||
// unexported, so no other package can construct a non-zero one with a struct
|
||||
// literal. FIVE things produce it, and every one either runs the full predicate
|
||||
// first or has no votes to run it on:
|
||||
//
|
||||
// 1. BuildVerifiedQuorumCert — the exported minting door, from raw votes over a set.
|
||||
// 2. HandleIncomingCert — a gossiped certificate, through verifyCert.
|
||||
// 3. VerifyCatchupCertificate/AcceptCatchupBlock — the same, on the catch-up road.
|
||||
// 4. assembleVerifiedCertLocked → assembleCertLocked — the engine's own assembly,
|
||||
// which verifies before it caches.
|
||||
// 5. buildSingleValidatorCertLocked — the K==1 road, and the one that is not a
|
||||
// predicate: on a genuinely single-validator chain it synthesizes a zero-vote
|
||||
// Nova token, because the sole validator's own accept IS the quorum and there
|
||||
// is no other party to protect against. Fenced three ways (both callers gate on
|
||||
// K()==1, a real signed 1-of-1 is preferred whenever one assembles, and the
|
||||
// zero token is returned once the live count passes one on a chain that has
|
||||
// finalized anything), and Nova only, so it is never an export door.
|
||||
//
|
||||
// 2 through 4 promote through wrapVerifiedCert, which is unexported and refuses
|
||||
// nil — there is no exported escape hatch that skips verification. A raw α-of-K
|
||||
// COUNT ("enough voters responded",
|
||||
// consensus.IsAccepted, "enough pending callbacks") is a LIVENESS signal only:
|
||||
// it may trigger TryAccept, but it can never itself produce a VerifiedQuorumCert
|
||||
// and therefore can never finalize. This is the structural form of HIGH-3: the
|
||||
// count road is no longer an acceptance authority — it is a retry signal.
|
||||
package chain
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ErrNoVerifiedQC is returned by TryAccept when no verified quorum certificate
|
||||
// exists for the block yet. It is NOT an error condition to log loudly — it is
|
||||
@@ -34,46 +52,18 @@ import "errors"
|
||||
// finalize in response to it.
|
||||
var ErrNoVerifiedQC = errors.New("chain: no verified quorum cert for block — not final (liveness retry, not an accept)")
|
||||
|
||||
// ErrExportNeedsStake is the derived-authority refusal at the minting door: an
|
||||
// export-grade authority token was asked for with no stake source to derive the
|
||||
// rung's floors from. Export finality is a claim about a validator set; without
|
||||
// one there is nothing to make the claim against, so there is nothing to mint.
|
||||
var ErrExportNeedsStake = errors.New("chain: an export-tier cert cannot be minted without a stake source — its floors are derived from the validator set, and there is no set here")
|
||||
// ErrCertNeedsStake is the derived-authority refusal at the minting door: a
|
||||
// certificate was asked for with no stake source to derive its floor from. Every
|
||||
// floor a certificate declares and is held to is a function of the validator set
|
||||
// and the rung; with no set there is no floor, and a caller naming one in its
|
||||
// place is the certificate choosing its own quorum with an extra step.
|
||||
var ErrCertNeedsStake = errors.New("chain: a cert cannot be minted without a stake source — the floor it declares is derived from the validator set, and there is no set here")
|
||||
|
||||
// exportNeedsStake is the rule "no set, no export", in one place.
|
||||
//
|
||||
// An export certificate's floors are read off the validator set — how much stake
|
||||
// agreed, how many signers, how big the set is at all — and the derived-threshold
|
||||
// clause is read off it too. With no stake source none of those numbers exist, and
|
||||
// the only predicate left is the count-only Verify, which counts votes against the
|
||||
// number the CERTIFICATE declares. That road mints a Quasar token over one signer:
|
||||
// one vote, a declared threshold of one, and both the assemble clause and the count
|
||||
// clause are satisfied by the same 1. Nothing downstream re-checks, because
|
||||
// AcceptWithCert trusts the token — which is the whole point of the token.
|
||||
//
|
||||
// The accept rung keeps the count-only road. It authorizes local execution the chain
|
||||
// can still reorg away, and the road is documented as equal-stake-only — a chain that
|
||||
// weighs nothing has to be able to make progress on something.
|
||||
//
|
||||
// THIS DOOR ONLY. The other producer of the token, Transitive.verifyCert, admits a
|
||||
// certificate that ARRIVED — carrying signatures from distinct in-set validators,
|
||||
// every one of them checked — so the thing missing there was never the votes, only
|
||||
// that the certificate named its own quorum. It DERIVES the floor from the node's
|
||||
// live committee instead of refusing the rung, which is what lets an equal-stake
|
||||
// deployment keep exporting. Here there are no arrived signatures to weigh: the
|
||||
// caller hands over raw votes AND the alpha to count them against, so with no set
|
||||
// there is nothing to check the caller against and refusing is the only answer. Two
|
||||
// doors, one principle, and the difference between them is what the doors are.
|
||||
//
|
||||
// Asked through Finality.AuthorizesExport, so this covers every export tier that
|
||||
// exists and every one that is added: it is not a list of tier names to keep in step
|
||||
// with another list somewhere else.
|
||||
func exportNeedsStake(tier Finality, stake StakeSource) error {
|
||||
if stake == nil && tier.AuthorizesExport() {
|
||||
return ErrExportNeedsStake
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// ErrCertFloorUnstatable is the refusal for a floor no certificate can carry: a
|
||||
// tier with no floor at all (an unknown rung), a set that derives none (n<1), or —
|
||||
// at about six billion signers — a floor past what the certificate's uint32
|
||||
// threshold can hold. Narrowing it would wrap to a number a lone signer meets.
|
||||
var ErrCertFloorUnstatable = errors.New("chain: the floor this set derives for this tier is not a quorum a cert can state")
|
||||
|
||||
// VerifiedQuorumCert is proof that a block met the finality predicate: α distinct
|
||||
// validators signed ACCEPT over the exact position AND (on a stake-weighted
|
||||
@@ -104,38 +94,46 @@ func (v VerifiedQuorumCert) Cert() *QuorumCert { return v.qc }
|
||||
|
||||
// BuildVerifiedQuorumCert assembles a quorum certificate from the collected
|
||||
// SIGNED accept votes and verifies it under the FULL finality predicate before
|
||||
// wrapping it. It is the sole multi-validator producer of the finality
|
||||
// authority token.
|
||||
// wrapping it. It is the exported producer of the finality authority token.
|
||||
//
|
||||
// verifier — the chain's VoteVerifier (BLS / ML-DSA / secp256k1). nil ⇒ fail closed.
|
||||
// stake — the chain's StakeSource. Non-nil ⇒ stake-weighted (VerifyWeighted,
|
||||
// tier-selected). nil ⇒ count-only Verify, and then Nova only: an
|
||||
// export tier with no stake source is refused (ErrExportNeedsStake),
|
||||
// because its floors are DERIVED from the set and there is no set.
|
||||
// The count-only road is for equal-stake chains, which MUST enforce
|
||||
// the equal-stake admission invariant.
|
||||
// stake — the chain's StakeSource: the set this certificate's floor is read
|
||||
// off. nil ⇒ ErrCertNeedsStake, at BOTH rungs. See below.
|
||||
// tier — Nova (local-execution majority) or Quasar (export ⅔-by-stake); selects
|
||||
// which threshold VerifyWeighted enforces.
|
||||
// alpha — the quorum floor the cert will DECLARE, and it must be the floor the
|
||||
// set derives: SignerFloor(tier, stake.SignerCount(epochHeight)). It is
|
||||
// a parameter because the caller assembles before the set is consulted,
|
||||
// not because the caller may choose it — VerifyWeighted refuses a cert
|
||||
// whose declared threshold is not the derived one, so an alpha that
|
||||
// disagrees with the set yields no token.
|
||||
// which floor is derived and which stake clause VerifyWeighted enforces.
|
||||
// epochHeight — the P-chain epoch the per-voter pubkeys, set-root and stake tally
|
||||
// are all read at (MEDIUM-1).
|
||||
// pos — the consensus position the votes (and the cert) bind to.
|
||||
// votes — the collected SIGNED accept records (caller has already filtered
|
||||
// to those whose signature verified; Assemble+Verify re-check).
|
||||
//
|
||||
// NO CALLER STATES A QUORUM. The floor is SignerFloor(tier, n) over the set as it
|
||||
// stands at this epoch, read here and now, and it is what the certificate carries.
|
||||
// It was an argument once, and the argument was the hole: on the count-only road
|
||||
// nothing re-derived it, so one vote and an alpha of one minted an authority token
|
||||
// on a chain of five while the same certificate arriving over the wire was refused.
|
||||
// A door that can be opened with a number the caller chose is not a door. Now there
|
||||
// is nothing to state, so there is nothing to disagree with — the shape Rust's
|
||||
// Tally::cert and the C++ engine already have.
|
||||
//
|
||||
// NO SET, NO CERTIFICATE, at both rungs. A floor is a property of the set; with no
|
||||
// stake source there is no set, no floor and nothing to check a would-be quorum
|
||||
// against. This is where the arrival road and the minting road part, and the
|
||||
// difference is what the roads are: Transitive.verifyCert admits a certificate that
|
||||
// ALREADY carries checked signatures from distinct in-set validators, so with no
|
||||
// stake source it can still derive a floor from this node's own committee and hold
|
||||
// the declaration to it. Here there are no arrived signatures — the caller hands
|
||||
// over raw votes — so a configured committee would be checking the caller against
|
||||
// the caller. An equal-stake chain mints through the engine, whose committee is its
|
||||
// own, or supplies a uniform StakeSource; both are a set.
|
||||
//
|
||||
// Returns the zero VerifiedQuorumCert and ErrNoVerifiedQC (wrapping the precise
|
||||
// predicate failure) if a verified ⅔-stake quorum is not yet present — this is
|
||||
// the LIVENESS answer, never a force. NEVER weakens VerifyWeighted.
|
||||
// predicate failure) if a verified quorum is not yet present — this is the
|
||||
// LIVENESS answer, never a force. NEVER weakens VerifyWeighted.
|
||||
func BuildVerifiedQuorumCert(
|
||||
verifier VoteVerifier,
|
||||
stake StakeSource,
|
||||
tier Finality,
|
||||
alpha uint32,
|
||||
epochHeight uint64,
|
||||
pos VotePosition,
|
||||
votes []SignedVote,
|
||||
@@ -143,25 +141,29 @@ func BuildVerifiedQuorumCert(
|
||||
if verifier == nil {
|
||||
return VerifiedQuorumCert{}, ErrNoVerifiedQC
|
||||
}
|
||||
// No set, no export — the rule, asked at the minting door before any work.
|
||||
if err := exportNeedsStake(tier, stake); err != nil {
|
||||
return VerifiedQuorumCert{}, errors.Join(ErrNoVerifiedQC, err)
|
||||
if stake == nil {
|
||||
return VerifiedQuorumCert{}, errors.Join(ErrNoVerifiedQC, ErrCertNeedsStake)
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, tier, alpha, votes)
|
||||
// The floor, derived. An unknown tier derives 0 and an unresolved set derives a
|
||||
// number no honest assembler could have meant, so both land here rather than in a
|
||||
// certificate. The upper bound is the width a certificate states its quorum in:
|
||||
// SignerFloor counts at full width precisely so this narrowing is a decision and
|
||||
// not an accident.
|
||||
alpha := SignerFloor(tier, stake.SignerCount(epochHeight))
|
||||
if alpha < 1 || alpha > math.MaxUint32 {
|
||||
return VerifiedQuorumCert{}, errors.Join(ErrNoVerifiedQC, ErrCertFloorUnstatable)
|
||||
}
|
||||
cert, err := AssembleQuorumCert(pos, tier, uint32(alpha), votes)
|
||||
if err != nil {
|
||||
// Quorum not assembled yet (sub-threshold / not-yet-arrived). Liveness:
|
||||
// keep waiting. Wrap the precise cause for diagnosis, present
|
||||
// ErrNoVerifiedQC to the caller's control flow.
|
||||
return VerifiedQuorumCert{}, errors.Join(ErrNoVerifiedQC, err)
|
||||
}
|
||||
// THE finality predicate. Stake-weighted (strict >⅔) when a stake source is
|
||||
// wired; count-only Verify otherwise. Either way the cert must clear it
|
||||
// before it can wrap into the authority token.
|
||||
if stake != nil {
|
||||
if err := cert.VerifyWeighted(verifier, stake, epochHeight); err != nil {
|
||||
return VerifiedQuorumCert{}, errors.Join(ErrNoVerifiedQC, err)
|
||||
}
|
||||
} else if err := cert.Verify(verifier, epochHeight); err != nil {
|
||||
// THE finality predicate — the strict stake floors, the rung's signer floor, and
|
||||
// the derived-threshold clause, which re-reads the set and so re-checks the very
|
||||
// number this function just stamped. Nothing wraps without clearing it.
|
||||
if err := cert.VerifyWeighted(verifier, stake, epochHeight); err != nil {
|
||||
return VerifiedQuorumCert{}, errors.Join(ErrNoVerifiedQC, err)
|
||||
}
|
||||
return VerifiedQuorumCert{qc: cert}, nil
|
||||
|
||||
Reference in New Issue
Block a user