mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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]>
764 lines
36 KiB
Go
764 lines
36 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
// The vote/cert distribution handlers that make α-of-K finality both safe and live.
|
||
//
|
||
// The topology in one paragraph: a follower verifies a gossiped block and
|
||
// broadcasts its signed accept vote to every validator (followVerifiedBlock,
|
||
// integration.go). Each validator feeds incoming signed votes into the engine
|
||
// (HandleIncomingVote), which collects them toward a QuorumCert. Whichever node
|
||
// first collects α distinct verified votes assembles the cert and gossips it
|
||
// (tryFinalizeBlock); every node finalizes the block on receipt of that
|
||
// verifiable proof (HandleIncomingCert). No node finalizes without a cert —
|
||
// that is safety. The cert reaches everyone by three independent routes
|
||
// (vote-broadcast, cert-gossip, the poll-timeout re-request), so finality never
|
||
// depends on any single node's inbound chits — that is liveness.
|
||
package chain
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/luxfi/consensus/core/slashing"
|
||
"github.com/luxfi/ids"
|
||
"github.com/luxfi/log"
|
||
)
|
||
|
||
// ErrVoteWireCorrupt is returned by decodeSignedVote on any structural defect.
|
||
var ErrVoteWireCorrupt = errors.New("chain: signed vote wire corrupt")
|
||
|
||
// signed-vote wire layout (big-endian):
|
||
//
|
||
// node_id:20
|
||
// sig_len:4 sig:sig_len
|
||
//
|
||
// The signed vote travels with the blockID in the gossip envelope (the network
|
||
// message already names the chain + block), so the canonical message a verifier
|
||
// rebuilds is derived from the receiver's tracked position for that block — a
|
||
// vote cannot smuggle a different position because the signature is checked
|
||
// against the receiver's own (chain,height,round,block,parent).
|
||
|
||
// encodeSignedVote encodes (nodeID, signature) for broadcast.
|
||
func encodeSignedVote(nodeID ids.NodeID, sig []byte) ([]byte, error) {
|
||
buf := make([]byte, 0, ids.NodeIDLen+4+len(sig))
|
||
buf = append(buf, nodeID[:]...)
|
||
var u32 [4]byte
|
||
binary.BigEndian.PutUint32(u32[:], uint32(len(sig)))
|
||
buf = append(buf, u32[:]...)
|
||
buf = append(buf, sig...)
|
||
return buf, nil
|
||
}
|
||
|
||
// decodeSignedVote is the inverse of encodeSignedVote. Strict trailing-bytes
|
||
// policy; fail-closed on short reads.
|
||
func decodeSignedVote(data []byte) (ids.NodeID, []byte, error) {
|
||
var nodeID ids.NodeID
|
||
if len(data) < ids.NodeIDLen+4 {
|
||
return nodeID, nil, ErrVoteWireCorrupt
|
||
}
|
||
copy(nodeID[:], data[:ids.NodeIDLen])
|
||
rest := data[ids.NodeIDLen:]
|
||
sigLen := binary.BigEndian.Uint32(rest[:4])
|
||
rest = rest[4:]
|
||
if uint64(sigLen) != uint64(len(rest)) {
|
||
return nodeID, nil, fmt.Errorf("%w: sig_len %d != remaining %d", ErrVoteWireCorrupt, sigLen, len(rest))
|
||
}
|
||
sig := make([]byte, sigLen)
|
||
copy(sig, rest)
|
||
return nodeID, sig, nil
|
||
}
|
||
|
||
// HandleIncomingVote ingests a signed accept vote received from another
|
||
// validator's broadcast. The vote is bound to a blockID (carried by the gossip
|
||
// envelope); the engine rebuilds the canonical message from its own tracked
|
||
// position for that block and verifies the signature before counting the vote.
|
||
// A vote for a block we are not tracking is dropped (we cannot know its
|
||
// position to verify against — the proposer's block gossip carries that, and
|
||
// arrives via HandleIncomingBlock).
|
||
//
|
||
// Returns true iff the vote verified and was counted toward the block's cert.
|
||
func (rt *Runtime) HandleIncomingVote(blockID ids.ID, voteBytes []byte) bool {
|
||
nodeID, sig, err := decodeSignedVote(voteBytes)
|
||
if err != nil {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Debug("incoming vote: decode failed", log.Stringer("blockID", blockID), log.Err(err))
|
||
}
|
||
return false
|
||
}
|
||
|
||
t := rt.Transitive
|
||
t.mu.RLock()
|
||
pending, exists := t.pendingBlocks[blockID]
|
||
verifier := t.voteVerifier
|
||
var pos VotePosition
|
||
var epochHeight uint64
|
||
if exists {
|
||
pos = t.blockPositionLocked(pending, blockID)
|
||
epochHeight = t.epochHeightLocked(pending)
|
||
}
|
||
t.mu.RUnlock()
|
||
|
||
if verifier == nil {
|
||
return false
|
||
}
|
||
if !exists {
|
||
// The block is not pending. It may be a finalized (Nova-accepted) block whose trailing
|
||
// ⅔-stake vote is still arriving — the ⅔-th stake vote necessarily follows the
|
||
// bare-majority accept — so route a verified accept for it to the late-attestation path
|
||
// (handleVote → attestFinalizedVote → the Quasar attestor) to complete the export cert.
|
||
// Verify against the remembered accepted position: the exact bytes the accept votes
|
||
// signed. An unknown or aged-out block drops.
|
||
ap, remembered := t.lookupAcceptedPos(blockID)
|
||
if !remembered || !verifier.VerifyVote(nodeID, CanonicalVoteMessage(ap.pos), sig, ap.epoch) {
|
||
return false
|
||
}
|
||
t.ReceiveVote(Vote{
|
||
BlockID: blockID,
|
||
NodeID: nodeID,
|
||
Accept: true,
|
||
Signature: sig,
|
||
ParentID: ap.pos.ParentID,
|
||
Round: ap.pos.Round,
|
||
})
|
||
return true
|
||
}
|
||
|
||
// Verify the signature against our position for this block, resolving the
|
||
// voter's pubkey at the block's P-chain epoch height. A vote that signed a
|
||
// different position (different height/round/parent/set-root) fails.
|
||
if !verifier.VerifyVote(nodeID, CanonicalVoteMessage(pos), sig, epochHeight) {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Debug("incoming vote: signature invalid",
|
||
log.Stringer("blockID", blockID), log.Stringer("from", nodeID))
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Count it. ReceiveVote routes through handleVote, which records the signed
|
||
// vote toward the cert and triggers tryFinalizeBlock once alpha is reached.
|
||
t.ReceiveVote(Vote{
|
||
BlockID: blockID,
|
||
NodeID: nodeID,
|
||
Accept: true,
|
||
Signature: sig,
|
||
ParentID: pos.ParentID,
|
||
Round: pos.Round,
|
||
})
|
||
return true
|
||
}
|
||
|
||
// HandleIncomingCert ingests a finality cert gossiped by another validator and,
|
||
// if it verifies as a valid α-of-K witness for a block we have verified,
|
||
// finalizes that block. A follower commits a gossiped block only against a
|
||
// verifiable α-of-K proof.
|
||
//
|
||
// What a cert must clear:
|
||
// - it must decode and Verify under our VoteVerifier (α distinct
|
||
// correctly-signed accepts over the cert's position),
|
||
// - its position chain must match ours,
|
||
// - we must have verified the block locally (it is in pendingBlocks). A block
|
||
// whose contents we have not validated is not accepted even with a valid
|
||
// cert — the cert proves agreement, local Verify proves validity; finality
|
||
// needs both.
|
||
//
|
||
// Returns true iff the block was finalized as a result of this cert.
|
||
func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||
cert, err := UnmarshalQuorumCert(certBytes)
|
||
if err != nil {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Debug("incoming cert: decode failed", log.Err(err))
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Certificate gossip is deliberately redundant, so the same cert commonly
|
||
// arrives from several validators at once. Serialize after the cheap decode:
|
||
// the first delivery performs the cryptographic verification/finalization;
|
||
// queued duplicates observe the advanced finalized height below and stop
|
||
// before repeating signature checks or VM work.
|
||
rt.certMu.Lock()
|
||
defer rt.certMu.Unlock()
|
||
|
||
t := rt.Transitive
|
||
t.mu.RLock()
|
||
verifier := t.voteVerifier
|
||
chainID := t.chainID
|
||
pending, exists := t.pendingBlocks[cert.Position.BlockID]
|
||
t.mu.RUnlock()
|
||
|
||
if verifier == nil {
|
||
return false
|
||
}
|
||
// 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.
|
||
//
|
||
// 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
|
||
}
|
||
|
||
// Height gate — reject on height before any finalize work:
|
||
// - a cert at or below the last-finalized height is stale or a fork attempt
|
||
// (the height is already decided); and
|
||
// - a cert whose height does not match the height of the block we are
|
||
// tracking under that ID is internally inconsistent.
|
||
// This is the cheap front-line check; FinalizeBranch (inside AcceptWithCert)
|
||
// is the authoritative backstop that also produces equivocation evidence.
|
||
if fh, set := t.consensus.GetFinalizedHeight(); set && cert.Position.Height <= fh {
|
||
// Equivocation is decided on the canonical commitment, not the outer envelope.
|
||
// Under anyone-can-propose every validator wraps the same inner execution block
|
||
// in its own envelope, so envelope identity says nothing about agreement: a
|
||
// different canonical block already finalized at this height is a potential
|
||
// fork, while the same canonical id under a different envelope is a harmless
|
||
// alias (and an identical envelope is a stale replay). Either way the cert
|
||
// finalizes nothing new.
|
||
certCanonical := cert.Position.CanonicalID
|
||
if certCanonical == ids.Empty {
|
||
certCanonical = cert.Position.BlockID
|
||
}
|
||
finCanonical, have := t.consensus.FinalizedBlockAtHeight(cert.Position.Height)
|
||
if have && finCanonical != certCanonical {
|
||
// A conflicting cert is fork evidence only if it is a verified QC, so run the
|
||
// full predicate (α distinct in-set signatures over the canonical position,
|
||
// stake-weighted when a stake source is wired) before recording any slashing
|
||
// evidence. Naming a validator costs nothing to forge: junk signatures over a
|
||
// random canonical at a decided height would otherwise jail honest validators
|
||
// below quorum and halt the chain — the attack is cheaper than the fork it
|
||
// claims to prove. Resolve the epoch from the tracked block if we have it; on a
|
||
// fixed-set chain the verifier ignores epoch (equal-stake admission), and on a
|
||
// stake-weighted chain an unresolvable epoch fails verification and yields no
|
||
// evidence. Fail-closed here means we may miss a slash, never invent one.
|
||
var epochHeight uint64
|
||
if exists && pending.ConsensusBlock != nil {
|
||
epochHeight = pending.ConsensusBlock.pChainHeight
|
||
}
|
||
if verr := t.verifyCert(cert, epochHeight); verr != nil {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("incoming cert: UNVERIFIED conflicting cert at a finalized height — dropping, NO evidence (forged/junk signatures cannot slash)",
|
||
log.Uint64("certHeight", cert.Position.Height), log.Err(verr))
|
||
}
|
||
return false
|
||
}
|
||
// A verified α-of-K cert over a different canonical at a decided height is a
|
||
// genuine, attributable equivocation. Now, and only now, it is evidence.
|
||
rt.reportCertEquivocation(cert, finCanonical)
|
||
} else if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Debug("incoming cert: height at/below finalized; dropping (duplicate or stale, not a fork)",
|
||
log.Uint64("certHeight", cert.Position.Height),
|
||
log.Uint64("finalizedHeight", fh))
|
||
}
|
||
return false
|
||
}
|
||
if exists && pending.ConsensusBlock != nil && pending.ConsensusBlock.height != cert.Position.Height {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("incoming cert: height does not match tracked block; dropping",
|
||
log.Stringer("blockID", cert.Position.BlockID),
|
||
log.Uint64("certHeight", cert.Position.Height),
|
||
log.Uint64("trackedHeight", pending.ConsensusBlock.height))
|
||
}
|
||
return false
|
||
}
|
||
// We found this block by the cert's OUTER id, and the outer ids are the one
|
||
// part of a position no signature covers — the signed identity is the inner
|
||
// canonical. So the id that led us here proves nothing on its own: re-point it
|
||
// at a sibling at the same height and a genuine cert for one block arrives
|
||
// asking us to accept another. The block we found has to BE the block the
|
||
// signatures name. The no-wrapper arm below already compares the full canonical
|
||
// tuple before it finalizes an alias; this is the same question asked on the
|
||
// arm that happens to hold a wrapper.
|
||
if exists && pending.ConsensusBlock != nil && pending.ConsensusBlock.canonicalRep() != certCanonical(cert) {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("incoming cert: canonical does not match the block tracked under this id; dropping",
|
||
log.Stringer("blockID", cert.Position.BlockID),
|
||
log.Stringer("certCanonical", certCanonical(cert)),
|
||
log.Stringer("trackedCanonical", pending.ConsensusBlock.canonicalRep()))
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Resolve the cert's epoch height from our locally-tracked, locally-verified
|
||
// block — the proposervm P-chain height we recorded for this block ID. Every
|
||
// honest node derives the same epoch height from the same signed block, so the
|
||
// cert verifies against an identical set/root/stake everywhere.
|
||
//
|
||
// A cert can name an envelope we do not track while we hold a DIFFERENT wrapper
|
||
// of the same inner execution block (the storm alias handled below). The epoch
|
||
// is a property of the inner block, not of the outer envelope, so resolve it by
|
||
// canonical id in that case. Reading it off the envelope alone left the epoch at
|
||
// 0 — the genesis validator set — and on any chain whose set is not the height-0
|
||
// set every voter's pubkey and the whole stake tally resolved against the wrong
|
||
// committee, so the cert failed verification here and the alias cure below was
|
||
// unreachable exactly on the chains old enough to need it. No wrapper of the
|
||
// inner block at all leaves the epoch at 0, which is what a node that has never
|
||
// seen the block can honestly say.
|
||
var epochHeight uint64
|
||
if exists && pending.ConsensusBlock != nil {
|
||
epochHeight = pending.ConsensusBlock.pChainHeight
|
||
} else {
|
||
canon := cert.Position.CanonicalID
|
||
if canon == ids.Empty {
|
||
canon = cert.Position.BlockID
|
||
}
|
||
t.mu.RLock()
|
||
if local, _ := t.pendingByCanonicalLocked(canon); local != nil {
|
||
epochHeight = t.epochHeightLocked(local)
|
||
}
|
||
t.mu.RUnlock()
|
||
}
|
||
|
||
// Defence in depth (epoch binding): the cert's set-root must equal the set-root
|
||
// we recompute at our epoch height for this block. The set-root is folded into
|
||
// every signed vote, so a verifying cert already implies the signers agreed on
|
||
// it; this cross-check additionally rejects a cert whose epoch (set-root) does
|
||
// not match the epoch of the block we tracked under this ID — a cert laundered
|
||
// from a different validator-set epoch. nil source ⟹ Empty on both sides (no
|
||
// epoch bound), so this is a no-op for a fixed-set chain.
|
||
t.mu.RLock()
|
||
setRootSrc := t.setRootSource
|
||
t.mu.RUnlock()
|
||
if setRootSrc != nil && exists {
|
||
localRoot := setRootSrc.ValidatorSetRoot(epochHeight)
|
||
if localRoot != cert.Position.ValidatorSetRoot {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("incoming cert: set-root does not match our epoch for this block; dropping",
|
||
log.Stringer("blockID", cert.Position.BlockID),
|
||
log.Uint64("epochHeight", epochHeight))
|
||
}
|
||
return false
|
||
}
|
||
}
|
||
|
||
// The finality predicate — the same gate the equivocation-evidence path runs
|
||
// (verifyCert): α distinct in-set signatures over the canonical position,
|
||
// stake-weighted to a strict ⅔ when a stake source is wired, count-only
|
||
// otherwise (equal-stake admission). A forged cert dies here.
|
||
if verr := t.verifyCert(cert, epochHeight); verr != nil {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("incoming cert: verification failed",
|
||
log.Stringer("blockID", cert.Position.BlockID), log.Err(verr))
|
||
}
|
||
return false
|
||
}
|
||
|
||
// The cert is a valid α-of-K finality proof. Finalize the block if we have
|
||
// verified it locally. If we do not track the block (an eclipsed follower that
|
||
// adopted a losing sibling envelope, or a node behind the frontier), we cannot
|
||
// safely Accept it — and silently dropping a verified finality proof leaves the
|
||
// node stuck with no way back. Trigger a throttled, best-effort catch-up fetch
|
||
// for the certified block so finalization resumes once a reachable peer serves
|
||
// it. The fetch is reachable only after the cert verified above, so a forged
|
||
// cert can never make us fetch arbitrary ids. Peer selection is the node layer's
|
||
// job (EmptyNodeID ⇒ sample a peer); claimCatchupLocked rate-limits the request.
|
||
if !exists {
|
||
// The cert verified as a valid α-of-K witness, but its outer envelope is not one
|
||
// we track. Under pChainHeight=0 anyone-can-propose, every validator wraps the
|
||
// same inner execution block in its own proposervm envelope, so the cert's
|
||
// envelope is an alias of a wrapper we do hold and verified. The signed vote
|
||
// message binds the canonical execution identity, not the outer ids
|
||
// (CanonicalVoteMessage), so the α votes verify against our wrapper's position
|
||
// too — and Accepting any wrapper of the certified inner block applies the same
|
||
// execution. Finalize the local wrapper, so the VM advances on a network-final
|
||
// height instead of stalling while it fetches an alias it already has. Only when
|
||
// we hold no wrapper of this inner block at all do we fall back to catch-up.
|
||
if localID, ok := t.finalizeLocalAliasFromVerifiedCert(cert); ok {
|
||
rt.fastFollowMu.Lock()
|
||
if cert.Position.Height > rt.fastFollowHeight {
|
||
rt.fastFollowHeight = cert.Position.Height
|
||
}
|
||
rt.fastFollowMu.Unlock()
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Info("finalized local wrapper via α-of-K cert for a sibling envelope (storm-alias)",
|
||
log.Stringer("localEnvelope", localID),
|
||
log.Stringer("certEnvelope", cert.Position.BlockID),
|
||
log.Stringer("canonical", cert.Position.CanonicalID),
|
||
log.Uint64("height", cert.Position.Height),
|
||
log.Int("voters", cert.VoterCount()))
|
||
}
|
||
return true
|
||
}
|
||
rt.requestCatchup(cert.Position.BlockID, ids.EmptyNodeID)
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Debug("incoming cert: valid but block not locally tracked; fetching the certified block",
|
||
log.Stringer("blockID", cert.Position.BlockID))
|
||
}
|
||
return false
|
||
}
|
||
|
||
ctx := context.Background()
|
||
if c := t.ctx; c != nil {
|
||
ctx = c
|
||
}
|
||
|
||
// Record the cert candidate, then finalize through the one finalizer.
|
||
t.mu.Lock()
|
||
if pending.Decided {
|
||
t.mu.Unlock()
|
||
return false
|
||
}
|
||
pending.cert = cert
|
||
t.mu.Unlock()
|
||
|
||
// The cert cleared VerifyWeighted/Verify above (the same predicate
|
||
// BuildVerifiedQuorumCert runs), so promote it to the finality authority token.
|
||
vcert, ok := wrapVerifiedCert(cert)
|
||
if !ok {
|
||
return false
|
||
}
|
||
|
||
// AcceptWithCert is the one finalizer: it commits the certified branch through
|
||
// FinalizeBranch (the per-height equivocation gate plus the sibling reorg — prune
|
||
// the losers, accept the path) and applies the VM effects. A safety violation is
|
||
// returned here and nothing is VM-accepted:
|
||
// - ErrHeightAlreadyFinalized → a second cert at an already-finalized height:
|
||
// surface the conflict as equivocation evidence (two certs, one height);
|
||
// - ErrConflictsWithFinalizedBranch → a cert for a losing/pruned branch: drop;
|
||
// - ErrAncestorNotTracked → we are behind: drop (the gap fetch re-applies it).
|
||
if err := t.AcceptWithCert(ctx, cert.Position.BlockID, vcert); err != nil {
|
||
if errors.Is(err, ErrHeightAlreadyFinalized) {
|
||
if fin, ok := t.consensus.FinalizedBlockAtHeight(cert.Position.Height); ok {
|
||
rt.reportCertEquivocation(cert, fin)
|
||
}
|
||
}
|
||
// ErrAncestorNotTracked means we do track the certified block but are missing an
|
||
// intermediate ancestor between it and our finalized tip — we are behind. A node in
|
||
// that state cannot heal itself by waiting: every later cert hits the same missing
|
||
// ancestor, so it never finalizes again and the set runs one validator short until
|
||
// it restarts. Trigger the throttled ancestor fetch for the specific missing block
|
||
// so the node layer serves the finalized gap oldest-first and this verified cert
|
||
// re-applies. The fetch is reachable only after the cert verified above, so a forged
|
||
// cert can never make us fetch arbitrary ids; claimCatchupLocked rate-limits it.
|
||
// Peer selection is the node layer's job (EmptyNodeID ⇒ sample a serving peer).
|
||
var missingAncestor *AncestorNotTracked
|
||
if errors.As(err, &missingAncestor) {
|
||
rt.requestCatchup(missingAncestor.Missing, ids.EmptyNodeID)
|
||
}
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("incoming cert: REFUSED by finality guard (no VM.Accept)",
|
||
log.Stringer("blockID", cert.Position.BlockID),
|
||
log.Uint64("height", cert.Position.Height), log.Err(err))
|
||
}
|
||
// Roll back the speculative cert cache so a later legitimate finalize of
|
||
// this ID is not confused.
|
||
t.mu.Lock()
|
||
if pd, ok := t.pendingBlocks[cert.Position.BlockID]; ok && !pd.Decided {
|
||
pd.cert = nil
|
||
}
|
||
t.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
rt.fastFollowMu.Lock()
|
||
if cert.Position.Height > rt.fastFollowHeight {
|
||
rt.fastFollowHeight = cert.Position.Height
|
||
}
|
||
rt.fastFollowMu.Unlock()
|
||
|
||
// The equivocation guard for this (now-decided) height was already dropped inside
|
||
// the finalizer AcceptWithCert → acceptWithCertCore, the one funnel every finality
|
||
// path shares, so no separate prune is needed here.
|
||
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Info("finalized block via α-of-K quorum cert",
|
||
log.Stringer("blockID", cert.Position.BlockID),
|
||
log.Uint64("height", cert.Position.Height),
|
||
log.Int("voters", cert.VoterCount()))
|
||
}
|
||
return true
|
||
}
|
||
|
||
// finalizeLocalAliasFromVerifiedCert takes an already-verified α-of-K cert whose outer
|
||
// envelope this node does not track and finalizes a local wrapper of the same certified
|
||
// inner execution block, if we hold one.
|
||
//
|
||
// It is safe because one certified inner block has one execution, and any wrapper of it
|
||
// Accepts that same execution:
|
||
// - It resolves the local wrapper by the cert's canonical id (pendingByCanonicalLocked),
|
||
// then requires the wrapper's full canonical tuple (id + parent-canonical + exec-state
|
||
// root + payload root) to equal the cert's, so it can only ever Accept the exact
|
||
// execution the cert certified, never a colliding-id impostor. A verified local wrapper
|
||
// always satisfies this (its inner execution was re-executed at Verify); the check makes
|
||
// the invariant explicit and fails closed on any drift.
|
||
// - It rebases the cert onto the local wrapper by copying the cert's Position and swapping
|
||
// only the transport ids (BlockID/ParentID). Every signed field — canonical id/parent,
|
||
// exec-state root, payload root, height, round, validator-set root — is unchanged, so the
|
||
// α votes carry over unmodified (outer ids are never in the signed message).
|
||
// - It re-verifies the rebased cert against the local wrapper's epoch before finalizing —
|
||
// the same predicate verifyCert runs (α distinct in-set signatures, ⅔-by-stake when
|
||
// wired) plus the set-root epoch cross-check the exists-path applies — and finalizes
|
||
// only through the one finalizer (AcceptWithCert → FinalizeBranch). A forged or
|
||
// epoch-mismatched cert fails here and we fall back to catch-up. This cannot fork: the
|
||
// per-height gate keys on the canonical id, so a duplicate envelope of an already-final
|
||
// canonical is idempotent.
|
||
//
|
||
// Returns the finalized local envelope id and true on success. (Empty,false) means either
|
||
// no local wrapper of this inner block, or it did not re-verify; the caller then defers to
|
||
// catch-up.
|
||
func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.ID, bool) {
|
||
canon := cert.Position.CanonicalID
|
||
if canon == ids.Empty {
|
||
canon = cert.Position.BlockID
|
||
}
|
||
|
||
t.mu.RLock()
|
||
pL, localID := t.pendingByCanonicalLocked(canon)
|
||
if pL == nil || pL.ConsensusBlock == nil {
|
||
t.mu.RUnlock()
|
||
return ids.Empty, false
|
||
}
|
||
cb := pL.ConsensusBlock
|
||
// Full canonical-tuple equality — we finalize only a wrapper committing to the exact
|
||
// certified inner execution, never a canonical-id collision with a different execution.
|
||
// Height is bound too: a canonical (inner execution) id is unique per height, so a height
|
||
// mismatch means the resolver crossed heights — fail closed.
|
||
if cb.height != cert.Position.Height ||
|
||
cb.canonicalID != cert.Position.CanonicalID ||
|
||
cb.parentCanonicalID != cert.Position.ParentCanonicalID ||
|
||
cb.execStateRoot != cert.Position.ExecutionStateRoot ||
|
||
cb.payloadRoot != cert.Position.PayloadRoot {
|
||
t.mu.RUnlock()
|
||
return ids.Empty, false
|
||
}
|
||
localParentID := cb.parentID
|
||
// The epoch our wrapper was built at — every honest node that wraps this inner block
|
||
// derives the identical set/root/stake here (the votes were cast under it).
|
||
epochHeight := t.epochHeightLocked(pL)
|
||
setRootSrc := t.setRootSource
|
||
t.mu.RUnlock()
|
||
|
||
// Set-root epoch cross-check (parity with the exists-path defense in depth): the cert's
|
||
// bound validator-set root must equal the root WE compute for this epoch, so a cert
|
||
// laundered from a different validator-set epoch is refused even if its signatures verify.
|
||
if setRootSrc != nil {
|
||
if setRootSrc.ValidatorSetRoot(epochHeight) != cert.Position.ValidatorSetRoot {
|
||
return ids.Empty, false
|
||
}
|
||
}
|
||
|
||
// Rebase: copy the verified cert's Position, swap only the transport ids to the local
|
||
// wrapper. Every signed field is unchanged, so the votes verify unmodified.
|
||
rebasedPos := cert.Position
|
||
rebasedPos.BlockID = localID
|
||
rebasedPos.ParentID = localParentID
|
||
|
||
rebased, err := AssembleQuorumCert(rebasedPos, cert.Tier, cert.Threshold, cert.Votes)
|
||
if err != nil {
|
||
return ids.Empty, false
|
||
}
|
||
// The rebased cert must clear the same α-of-K predicate. Because the signed message is
|
||
// byte-identical to the incoming cert's (outer ids excluded), this passes exactly when
|
||
// the incoming cert did — re-run for defence in depth, since a nil verifier or a stake
|
||
// shortfall fails closed.
|
||
if verr := t.verifyCert(rebased, epochHeight); verr != nil {
|
||
return ids.Empty, false
|
||
}
|
||
vcert, ok := wrapVerifiedCert(rebased)
|
||
if !ok {
|
||
return ids.Empty, false
|
||
}
|
||
|
||
ctx := context.Background()
|
||
if c := t.ctx; c != nil {
|
||
ctx = c
|
||
}
|
||
if err := t.AcceptWithCert(ctx, localID, vcert); err != nil {
|
||
return ids.Empty, false
|
||
}
|
||
return localID, true
|
||
}
|
||
|
||
// verifyCert runs the full finality predicate — α distinct in-set signatures over the
|
||
// cert's canonical position, stake-weighted to a strict ⅔ when a stake source is wired,
|
||
// count-only otherwise, and on EITHER road the certificate's declared quorum must be
|
||
// the floor this node DERIVES rather than one the certificate named for itself. It is
|
||
// the single gate a cert passes before it counts either as
|
||
// finality (the finalize path) or as equivocation evidence (the height-gate fork path):
|
||
// a forged cert with junk signatures fails here and can neither finalize a block nor
|
||
// slash a validator. epochHeight is the P-chain height the per-voter pubkeys and stake
|
||
// are resolved at. A nil verifier fails closed.
|
||
func (t *Transitive) verifyCert(cert *QuorumCert, epochHeight uint64) error {
|
||
t.mu.RLock()
|
||
verifier := t.voteVerifier
|
||
stake := t.stakeSource
|
||
// Read under the same lock the sources are read under, and by the same function
|
||
// assembleCertLocked sizes its own floor with — so the number this node checks a
|
||
// declaration against is the number it would have declared itself.
|
||
committee, _ := t.effectiveCommittee(epochHeight)
|
||
t.mu.RUnlock()
|
||
if verifier == nil {
|
||
return ErrQCVerifierNil
|
||
}
|
||
if stake != nil {
|
||
return cert.VerifyWeighted(verifier, stake, epochHeight)
|
||
}
|
||
// THE COUNT-ONLY ROAD, and the derived rule still holds on it. Verify's last
|
||
// clause counts distinct valid accepts against the number the CERTIFICATE
|
||
// declares, so on its own this road promotes a Quasar certificate declaring a
|
||
// quorum of one over one signature — and wrapVerifiedCert hands that to
|
||
// AcceptWithCert, which trusts it. The hole the minting door just closed stood
|
||
// open one function away, on the road a certificate ARRIVES by.
|
||
//
|
||
// The floor is derived here too, from the node's own committee — what
|
||
// effectiveCommittee answers, and what this road's equal-stake admission
|
||
// invariant makes a signer count.
|
||
//
|
||
// The number is Quorum and NOT SignerFloor, because on this road the count IS the
|
||
// predicate rather than a guard standing beside a stake clause. They are the same
|
||
// at Quasar and diverge at Nova past five signers, and reading the weighted road's
|
||
// number here would make a node refuse its own certificates: assembleCertLocked
|
||
// declares NovaQuorum(n) on this road, which is 4 at six signers where
|
||
// NovaSignerFloor is 3. Every K below six hides it.
|
||
//
|
||
// This is what lets the equal-stake road keep the export rung: it is not that
|
||
// such a chain cannot export, it is that it may not export on a quorum the
|
||
// certificate wrote for itself. Refusing the rung outright here instead would
|
||
// retire that deployment, which is a larger decision than this clause is.
|
||
//
|
||
// THE PRICE, stated because it is real: with no stake model the committee is a
|
||
// CONFIGURED number, so this road now needs the fleet to agree on it. Before,
|
||
// any declaration was admitted and a node with a different K interoperated by
|
||
// accident; now two nodes that disagree about their committee disagree about
|
||
// which certificates exist. That is the cost of not letting a certificate name
|
||
// its own quorum, and it is why the MINTING door above refuses the export rung
|
||
// on this road rather than weighing it — a configured committee is a weaker
|
||
// authority than a stake source, strong enough to check a declaration against
|
||
// and not strong enough to originate one.
|
||
//
|
||
// AFTER Verify, as on the weighted road: the tier is answered first, so a
|
||
// wire-decoded certificate carrying a garbage tier byte is named by the tier
|
||
// clause and not by a floor derived for a rung that does not exist.
|
||
if err := cert.Verify(verifier, epochHeight); err != nil {
|
||
return err
|
||
}
|
||
if committee >= 1 {
|
||
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)
|
||
}
|
||
}
|
||
// 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
|
||
}
|
||
|
||
// reportCertEquivocation records that a second, conflicting finality cert was
|
||
// presented for a height already finalized to a different canonical commitment — a
|
||
// provable safety equivocation: two valid certs selecting different execution blocks
|
||
// at one height. `finalizedCanonical` is the canonical id already final at this
|
||
// height; the conflicting cert's canonical id differs. Evidence is keyed on canonical
|
||
// identity, so a duplicate envelope wrapping the same canonical block cannot reach
|
||
// here — an alias is agreement, not a fork, and slashing on it would punish honest
|
||
// validators. Each voter is recorded as a DoubleVote. Best effort: it never blocks
|
||
// the safety reject.
|
||
func (rt *Runtime) reportCertEquivocation(cert *QuorumCert, finalizedCanonical ids.ID) {
|
||
conflicting := cert.Position.CanonicalID
|
||
if conflicting == ids.Empty {
|
||
conflicting = cert.Position.BlockID
|
||
}
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Crit("EQUIVOCATION: conflicting finality cert at finalized height (different canonical block)",
|
||
log.Uint64("height", cert.Position.Height),
|
||
log.Stringer("finalizedCanonical", finalizedCanonical),
|
||
log.Stringer("conflictingCanonical", conflicting),
|
||
log.Stringer("conflictingEnvelope", cert.Position.BlockID),
|
||
log.Int("conflictingVoters", cert.VoterCount()))
|
||
}
|
||
t := rt.Transitive
|
||
t.mu.RLock()
|
||
sdb := t.slashingDB
|
||
t.mu.RUnlock()
|
||
if sdb == nil {
|
||
return
|
||
}
|
||
for i := range cert.Votes {
|
||
proof := fmt.Appendf(nil, "height=%d finalizedCanonical=%s conflictingCanonical=%s",
|
||
cert.Position.Height, finalizedCanonical, conflicting)
|
||
sdb.RecordEvidence(slashing.Evidence{
|
||
Type: slashing.DoubleVote,
|
||
ValidatorID: cert.Votes[i].NodeID,
|
||
Height: cert.Position.Height,
|
||
Timestamp: time.Now(),
|
||
Proof: proof,
|
||
})
|
||
}
|
||
}
|
||
|
||
// certCanonical is the execution identity a cert's signatures actually cover.
|
||
// An empty CanonicalID means the position names a bare block, whose canonical is
|
||
// its own id — the same resolution every other reader of a position uses.
|
||
func certCanonical(cert *QuorumCert) ids.ID {
|
||
return decision(cert.Position.CanonicalID, cert.Position.BlockID)
|
||
}
|