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]>
This commit is contained in:
2026-08-21 02:37:58 -07:00
co-authored by Hanzo Dev
parent 5f67ce5744
commit d77a44e511
3 changed files with 36 additions and 4 deletions
+8 -2
View File
@@ -247,7 +247,11 @@ func (rt *Runtime) VerifyCatchupCertificate(ctx context.Context, blockBytes, cer
chainID := t.chainID
floor := t.consensus.Alpha()
if cert.Tier == Nova {
floor = NovaSignerFloor(t.consensus.K())
// 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()
@@ -255,7 +259,9 @@ func (rt *Runtime) VerifyCatchupCertificate(ctx context.Context, blockBytes, cer
if cert.Position.ChainID != chainID ||
cert.Position.Height != blk.Height() ||
certCanonical(cert) != canonicalIDOf(blk) ||
(floor > 0 && cert.Threshold < uint32(floor)) {
// 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)) {
return ErrCatchupCertRejected
}
+14
View File
@@ -83,6 +83,20 @@ func (rt *Runtime) From(ctx context.Context, height uint64, max int) (Run, error
return Run{Base: height}, nil
}
// The asker names max, so the reservation cannot be sized from it. A peer that
// writes MaxInt costs a `makeslice: cap out of range` panic in one packet, and
// this is the only peer-sized quantity in the package that was not capped at
// the read — vote_count is checked against the remaining frame, sig_len
// against the buffer, the Merkle step count at 64, and the served-cert window
// is a constant.
//
// Nothing beyond that window can be served anyway, so a larger request is not
// a bigger answer; it is only a bigger allocation. The loop below stops at the
// first height this node cannot answer, so capping the RESERVATION changes no
// reply — the run is built by appending, not by filling.
if max > maxServedCerts {
max = maxServedCerts
}
run := Run{Base: height, Chain: make([]Certified, 0, max)}
for h := height; h < height+uint64(max); h++ {
id, ok := rt.finalizedAt(h)
+14 -2
View File
@@ -201,9 +201,21 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
// count majority every legitimate cert already carries, so the filter is unweakened.
floor := t.consensus.Alpha() // Quasar ⅔ count floor
if cert.Tier == Nova {
floor = NovaSignerFloor(t.consensus.K()) // Nova signer floor
// 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())
}
if floor > 0 && cert.Threshold < uint32(floor) {
// 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),