mirror of
https://github.com/luxfi/consensus.git
synced 2026-10-30 17:00:34 +00:00
Local, unreleased. Acceptance and the ledger's decided height move only on the Quasar (two-thirds) certificate; a certificate's tier label is read at that rung. Winner in two tiers; votes parked only from their authenticated signer, bounded and expiring; relay once per proposer a height; stateless blocks charged to no sender. Co-authored-by: Hanzo Dev <[email protected]>
810 lines
38 KiB
Go
810 lines
38 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"
|
||
"math"
|
||
"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 this node does not hold cannot be verified yet: the frame
|
||
// carries no position, and the block is what supplies it. Such a vote is parked and
|
||
// verified like any live vote when the block lands (drainBufferedVotes). Its voter
|
||
// pushes the block it signed just before the vote (send), so a vote that outruns its
|
||
// block on the wire is the ordinary reorder, and dropping it would hide from a
|
||
// validator back from a gap the votes the winner rule reads. Parking fetches nothing:
|
||
// a block the network decided is fetched with its certificate (HandleIncomingCert).
|
||
//
|
||
// Nothing unverified is parked on anyone's word but the voter's own (parkable): `from`
|
||
// is the peer the transport authenticated, and a vote is parked only when it names
|
||
// `from` as its signer, `from` is a validator, and its signature is the scheme's
|
||
// length. The park is bounded per voter and expires by height (bufferVoteLocked).
|
||
//
|
||
// Returns true iff the vote verified and was counted toward the block's cert.
|
||
func (rt *Runtime) HandleIncomingVote(from ids.NodeID, 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. A block neither pending nor remembered is one this node does not hold:
|
||
// the vote is parked, and verified when the block lands.
|
||
ap, remembered := t.lookupAcceptedPos(blockID)
|
||
if !remembered {
|
||
v := Vote{BlockID: blockID, NodeID: nodeID, Accept: true, Signature: sig}
|
||
t.mu.Lock()
|
||
_, landed := t.pendingBlocks[blockID]
|
||
if !landed && t.parkableLocked(from, nodeID, sig) {
|
||
t.bufferVoteLocked(v)
|
||
}
|
||
t.mu.Unlock()
|
||
if landed {
|
||
t.ReceiveVote(v) // it landed meanwhile: handleVote verifies it
|
||
}
|
||
return false
|
||
}
|
||
if !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
|
||
}
|
||
// A certificate is a finality proof only at the ⅔ rung: that is what this node
|
||
// accepts a block on, re-gossips, serves, and reads as equivocation evidence. One
|
||
// equivocator can put a majority on each of two blocks at one height, and cannot
|
||
// put two thirds on both.
|
||
//
|
||
// The tier and the declared threshold are labels no signature covers (the vote
|
||
// message binds the position only, CanonicalVoteMessage), so they are read at the
|
||
// ⅔ rung here and the certificate is held to the floor this set derives for it:
|
||
// one assembled at the majority rung that carries two thirds of the signers is a
|
||
// finality proof, one carrying a bare majority is not. The certificate is
|
||
// relabeled in place, so what the caller accepts, re-gossips and serves says the
|
||
// rung it was checked at.
|
||
if cert != nil && cert.Tier == Nova {
|
||
floor := Quorum(Quasar, committee)
|
||
if stake != nil {
|
||
floor = SignerFloor(Quasar, stake.SignerCount(epochHeight))
|
||
}
|
||
if floor < 1 || floor > math.MaxUint32 {
|
||
return fmt.Errorf("%w: no two-thirds floor for this set at epoch %d", ErrQCBelowThreshold, epochHeight)
|
||
}
|
||
cert.Tier, cert.Threshold = Quasar, uint32(floor)
|
||
}
|
||
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)
|
||
}
|