mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
Merge branch 'fix/consensus-v1.31.1-downleader'
This commit is contained in:
@@ -165,6 +165,71 @@ func (b *blockHandler) sampleAncestorBeacons() (set.Set[ids.NodeID], bool) {
|
||||
return sample, sample.Len() > 0
|
||||
}
|
||||
|
||||
// fullyConnectedBeacons reports whether the node has reached its ENTIRE EXTERNAL beacon set — every
|
||||
// beacon OTHER than itself is connected and tracking this chain's network. This is the eclipse-free
|
||||
// condition that makes the FrontierTip SELF-VOTE safe: when the full set is reached no beacon can be
|
||||
// suppressed, so a unanimous "nobody ahead" from the full set PLUS the node itself is a TRUE caught-up
|
||||
// — an eclipse hiding any ahead-producer would drop that beacon from `connected` and break full-set,
|
||||
// falling back to the partition-capture floor (fail safe). `connected` is connectedBeacons(weights);
|
||||
// the external-set size is the beacon set minus the node itself (a node cannot, and need not, connect
|
||||
// to itself). Returns false for an empty external set (a degenerate single-beacon / self-only set, so
|
||||
// the self-vote is never the SOLE basis for caught-up).
|
||||
func (b *blockHandler) fullyConnectedBeacons(weights map[ids.NodeID]uint64, connected []ids.NodeID) bool {
|
||||
external := len(weights)
|
||||
if _, selfIsBeacon := weights[b.selfNodeID]; selfIsBeacon {
|
||||
external-- // the node is in its own beacon set but is never one of its own peers
|
||||
}
|
||||
return external > 0 && len(connected) >= external
|
||||
}
|
||||
|
||||
// withSelfVote returns `replies` plus the node's OWN accepted frontier as a beacon reply — the
|
||||
// SELF-VOTE. The node is itself a beacon (selfNodeID in `weights`, the trust anchor) and knows its
|
||||
// own accepted tip (lastID) with certainty, so it vouches for it exactly as a connected peer's reply
|
||||
// would (deduped by NodeID in the policy's tally; collectFrontierReplies samples only PEERS, never
|
||||
// self, so there is no double-count). Returned UNCHANGED when the node is not in the beacon set
|
||||
// (degenerate / single-node / a P-chain whose CustomBeacons omit self) or has no accepted tip — the
|
||||
// self-vote is then inert. self only ever vouches for the block IT HAS ACCEPTED, so it can never name
|
||||
// a forged tip (C1 untouched); the SOLE caller gates its use on FULL connectivity, so it can never
|
||||
// tip a partial (eclipsed) view into caught-up.
|
||||
func (b *blockHandler) withSelfVote(replies []BeaconReply, weights map[ids.NodeID]uint64, lastID ids.ID) []BeaconReply {
|
||||
w, selfIsBeacon := weights[b.selfNodeID]
|
||||
if !selfIsBeacon || lastID == ids.Empty {
|
||||
return replies
|
||||
}
|
||||
out := make([]BeaconReply, 0, len(replies)+1)
|
||||
out = append(out, replies...)
|
||||
out = append(out, BeaconReply{NodeID: b.selfNodeID, Tip: lastID, Weight: w})
|
||||
return out
|
||||
}
|
||||
|
||||
// repliedCovers reports whether EVERY connected beacon answered THIS frontier
|
||||
// round — set containment {reply.NodeID} ⊇ connected, NOT a count (frontier
|
||||
// replies are not yet round/connected-filtered, so a stale/non-connected reply
|
||||
// could pad a length check). The self-vote caught-up shortcut requires this:
|
||||
// without it the gate keys on CONNECTIVITY (fullyConnectedBeacons) while CaughtUp
|
||||
// tallies REPLIES, and the two diverge for a beacon that is CONNECTED but SILENT
|
||||
// — TCP up, its frontier reply delayed or withheld. That is a capability strictly
|
||||
// WEAKER than a full eclipse (no TCP drop needed) and it occurs NATURALLY when an
|
||||
// ahead beacon replays state on a mass co-restart. Such a beacon counts as "fully
|
||||
// connected" yet is absent from the tally, so self's (heavy) weight backfills the
|
||||
// floor and the node goes live at a forged STALE height — the precise failure the
|
||||
// frontier-quorum gate exists to prevent (RED CRITICAL). Requiring every connected
|
||||
// beacon to have replied means a silent ahead-beacon blocks caught-up → the node
|
||||
// waits / fails safe, while a genuine fresh net (every connected beacon answers
|
||||
// genesis) still completes.
|
||||
func repliedCovers(replies []BeaconReply, connected []ids.NodeID) bool {
|
||||
replied := make(map[ids.NodeID]struct{}, len(replies))
|
||||
for _, r := range replies {
|
||||
replied[r.NodeID] = struct{}{}
|
||||
}
|
||||
for _, id := range connected {
|
||||
if _, ok := replied[id]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FrontierTip implements chainbootstrap.BlockSource. It returns a FrontierStatus that
|
||||
// decomplects the THREE reasons a tip may not be named — the fix for the mainnet canary
|
||||
// where a freshly-booted STALE node, asking the frontier BEFORE any beacon had connected,
|
||||
@@ -273,9 +338,36 @@ func (b *blockHandler) FrontierTip(ctx context.Context) (ids.ID, chainbootstrap.
|
||||
log.Bool("accepted", b.Accepted(ctx, frontier.ID)))
|
||||
return frontier.ID, chainbootstrap.FrontierNamed
|
||||
case errors.Is(err, ErrInsufficientBootstrapResponses):
|
||||
// Fewer than MinResponses configured beacons have RESPONDED — not a partition-capture-safe
|
||||
// quorum yet. More may connect: WAIT (bounded by the loop's ConnectDeadline, then fail
|
||||
// safe). Never false-complete at the stale height; never trust the captured few.
|
||||
// Below the PEER-ONLY response floor. Before WAITING, apply the SELF-VOTE under FULL
|
||||
// connectivity — THE FRESH-NET FIX. The node is itself a beacon and knows its OWN accepted
|
||||
// tip with certainty. When it has reached its ENTIRE beacon set (fullyConnectedBeacons — so no
|
||||
// eclipse can be hiding an ahead-tip) and that full set PLUS itself unanimously hold a tip the
|
||||
// node has ALREADY ACCEPTED, the node IS at the network frontier — even though the peer-only
|
||||
// responder weight could not reach the stake-majority NAMING floor. That floor is unsatisfiable
|
||||
// by PEERS ALONE precisely when the node's own stake is large relative to the rest (a heavy
|
||||
// validator, a small beacon set like the P-chain's CustomBeacons, or skewed stake): peers =
|
||||
// total − self, so peers < total/2+1 ⟺ self > total/2−1. On a fresh net every validator holds
|
||||
// only genesis, so such a node would otherwise hang in FrontierConnecting forever with the
|
||||
// WHOLE set connected. SAFE: self is counted ONLY under full connectivity, so an eclipse that
|
||||
// suppresses ANY beacon breaks full-set and we fall through to the partition-capture floor
|
||||
// (FrontierConnecting → fail safe) — self can never tip a PARTIAL (eclipsed) view into a false
|
||||
// caught-up. self also only ever vouches for its OWN accepted tip, so C1 (forged-frontier
|
||||
// naming) is untouched, and a genuinely behind node has an ahead peer in the full set → CaughtUp
|
||||
// is false → it keeps waiting/syncing.
|
||||
if haveLast && b.fullyConnectedBeacons(weights, connected) &&
|
||||
repliedCovers(replies, connected) &&
|
||||
policy.CaughtUp(b.withSelfVote(replies, weights, lastID), lastH, b.acceptedHeight) {
|
||||
b.logger.Debug("bootstrap frontier: CAUGHT UP at own tip (full beacon set + self all at/below it, peer-only weight below the naming floor)",
|
||||
log.Stringer("chainID", b.chainID),
|
||||
log.Stringer("tip", lastID),
|
||||
log.Uint64("height", lastH),
|
||||
log.Int("beaconSetSize", len(weights)),
|
||||
log.Int("connected", len(connected)))
|
||||
return lastID, chainbootstrap.FrontierCaughtUp
|
||||
}
|
||||
// Fewer than MinResponses configured beacons have RESPONDED (and not provably caught-up under
|
||||
// full connectivity) — not a partition-capture-safe quorum yet. More may connect: WAIT (bounded
|
||||
// by the loop's ConnectDeadline, then fail safe). Never false-complete; never trust the few.
|
||||
b.logger.Debug("bootstrap frontier: CONNECTING (below the MinResponses floor)",
|
||||
log.Stringer("chainID", b.chainID),
|
||||
log.Int("beaconSetSize", len(weights)),
|
||||
|
||||
@@ -765,6 +765,79 @@ func TestNodeBootstrap_NoBeaconSet_ReportsNoBeacons(t *testing.T) {
|
||||
require.Equal(t, ids.Empty, tip)
|
||||
}
|
||||
|
||||
// TestNodeBootstrap_FreshNet_SelfVoteUnderFullConnectivity_CaughtUp is THE FRESH-NET FIX. The node
|
||||
// is itself a beacon (selfNodeID) on a 2-validator set; on a fresh net both hold only genesis. The
|
||||
// PEER-ONLY responder weight (100) is BELOW the stake-majority naming floor (200/2+1 = 101), so the
|
||||
// node could NEVER name a frontier and hung in FrontierConnecting forever — "bootstrap waiting for
|
||||
// beacon connectivity" — DESPITE the whole set being connected. With the self-vote, under FULL
|
||||
// connectivity (the one other beacon reached) the full set PLUS the node itself unanimously hold
|
||||
// genesis with nobody ahead → FrontierCaughtUp, and the loop completes at the node's own genesis tip.
|
||||
func TestNodeBootstrap_FreshNet_SelfVoteUnderFullConnectivity_CaughtUp(t *testing.T) {
|
||||
const N = 5
|
||||
chain, byID := buildBSChain(N, -1)
|
||||
vm := newBSVM(chain) // the node is at genesis (lastAccepted = genesis)
|
||||
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 2) // 2 equal-weight (100) beacons
|
||||
bh.selfNodeID = beacons[0] // THIS node is beacon 0 — it is itself a validator
|
||||
bh.msgCreator = bsMsgBuilder{}
|
||||
|
||||
// FULL connectivity: the ONE other beacon is connected and reports GENESIS (a fresh net — every
|
||||
// validator holds only genesis). connected EXCLUDES self (a node is never one of its own peers).
|
||||
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[1:], byID: byID, tip: chain[0]}
|
||||
|
||||
bh.bsActive.Store(true)
|
||||
tip, status := bh.FrontierTip(context.Background())
|
||||
bh.bsActive.Store(false)
|
||||
require.Equal(t, chainbootstrap.FrontierCaughtUp, status,
|
||||
"fresh net, FULL set + self all at genesis, peer-only weight below the naming floor → CAUGHT UP (the fix), never a FrontierConnecting hang")
|
||||
require.Equal(t, chain[0].id, tip, "caught up at the node's OWN genesis tip")
|
||||
}
|
||||
|
||||
// TestNodeBootstrap_SelfVote_PeerAheadDefeatsCaughtUp proves the self-vote NEVER false-completes a
|
||||
// genuinely BEHIND node. Same 2-beacon set with the node a beacon and FULL connectivity, but the
|
||||
// peer is AHEAD at N (an EXISTING net the empty node must SYNC to). A responder is above the node's
|
||||
// height, so CaughtUp(self+peers) is false → NOT FrontierCaughtUp: the node keeps waiting/syncing,
|
||||
// never goes live at its stale genesis. (The luxfi/consensus loop then descends once a frontier is
|
||||
// named, or fails safe.)
|
||||
func TestNodeBootstrap_SelfVote_PeerAheadDefeatsCaughtUp(t *testing.T) {
|
||||
const N = 20
|
||||
chain, byID := buildBSChain(N, -1)
|
||||
vm := newBSVM(chain) // node at genesis
|
||||
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 2)
|
||||
bh.selfNodeID = beacons[0]
|
||||
bh.msgCreator = bsMsgBuilder{}
|
||||
// FULL connectivity, but the peer reports the AHEAD tip N — the node is genuinely behind.
|
||||
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[1:], byID: byID, tip: chain[N], serveAncestors: true}
|
||||
|
||||
bh.bsActive.Store(true)
|
||||
_, status := bh.FrontierTip(context.Background())
|
||||
bh.bsActive.Store(false)
|
||||
require.NotEqual(t, chainbootstrap.FrontierCaughtUp, status,
|
||||
"a peer AHEAD must defeat the self-vote — the node is behind and must sync, never false-complete caught-up at genesis")
|
||||
}
|
||||
|
||||
// TestNodeBootstrap_SelfVote_PartialConnectivityFailsSafe proves the self-vote is SAFE against an
|
||||
// eclipse. The node is a beacon on a 3-validator set, but only ONE of the two OTHER beacons is
|
||||
// connected (NOT full connectivity). Even though that one + self report genesis, a SUPPRESSED beacon
|
||||
// could be hiding an ahead-tip — so the self-vote must NOT apply. fullyConnectedBeacons is false →
|
||||
// the node falls back to the partition-capture floor and reports FrontierConnecting (fail safe),
|
||||
// exactly as before the fix. self can never tip a PARTIAL view into a false caught-up.
|
||||
func TestNodeBootstrap_SelfVote_PartialConnectivityFailsSafe(t *testing.T) {
|
||||
const N = 5
|
||||
chain, byID := buildBSChain(N, -1)
|
||||
vm := newBSVM(chain) // node at genesis
|
||||
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 3) // self + 2 others
|
||||
bh.selfNodeID = beacons[0]
|
||||
bh.msgCreator = bsMsgBuilder{}
|
||||
// ECLIPSE: only ONE of the two OTHER beacons is connected (beacons[1]); beacons[2] is suppressed.
|
||||
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[1:2], byID: byID, tip: chain[0]}
|
||||
|
||||
bh.bsActive.Store(true)
|
||||
_, status := bh.FrontierTip(context.Background())
|
||||
bh.bsActive.Store(false)
|
||||
require.Equal(t, chainbootstrap.FrontierConnecting, status,
|
||||
"partial connectivity (an eclipse could hide an ahead-tip) → the self-vote must NOT apply → fail safe, NOT caught up")
|
||||
}
|
||||
|
||||
// TestRED_PeersTrackNetNotChain_StaleNodeConverges is THE MAINNET-CANARY (luxd-2) intended
|
||||
// SUCCESS and the regression guard for the beacon-connectivity bug.
|
||||
//
|
||||
|
||||
+12
-2
@@ -1587,7 +1587,7 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
|
||||
// through (blockBuilder == the P-chain-height wrapper on K>1, the inner VM
|
||||
// on K==1), so the container bytes it parses match the bytes the engine
|
||||
// framed — one codec, no raw-vs-wrapped split.
|
||||
bh := newBlockHandler(blockBuilder, m.Log, consensusEngine, m.Net, m.MsgCreator, chainParams.ID, networkID, beacons, expectsStakedBeacons)
|
||||
bh := newBlockHandler(blockBuilder, m.Log, consensusEngine, m.Net, m.MsgCreator, chainParams.ID, networkID, beacons, m.NodeID, expectsStakedBeacons)
|
||||
// Gate this native chain's bootstrap frontier-TRUST on the P-chain having finished its
|
||||
// initial sync, so the staked beacon set (and thus the stake-majority floor denominator)
|
||||
// is the TRUE full validator set, not a partial mid-replay set. Wired ONLY for native
|
||||
@@ -2735,6 +2735,15 @@ type blockHandler struct {
|
||||
// cannot. nil ⇒ no beacon quorum (single-node / skip-bootstrap), bootstrap is inert.
|
||||
beacons validators.Manager
|
||||
|
||||
// selfNodeID is THIS node's own NodeID (m.NodeID). The node is itself a beacon (a
|
||||
// validator in `beacons`), and it KNOWS its own accepted frontier with certainty — it
|
||||
// cannot, and need not, ask itself over the network. FrontierTip counts this SELF-VOTE in
|
||||
// the caught-up determination ONLY under FULL beacon connectivity (the fresh-net fix), so a
|
||||
// node whose own stake makes the PEER-ONLY responder weight fall below the stake-majority
|
||||
// floor still concludes caught-up at genesis instead of hanging in FrontierConnecting. Empty
|
||||
// for degenerate / single-node handlers (no self in the beacon set ⇒ the self-vote is inert).
|
||||
selfNodeID ids.NodeID
|
||||
|
||||
// expectsStakedBeacons is true when this chain syncs against the STAKED primary-network
|
||||
// validator set (m.Validators) — i.e. a native non-platform chain (C/X/Q/...) on a
|
||||
// sybil-protected network. That set is populated by the already-bootstrapped P-chain, so
|
||||
@@ -2836,7 +2845,7 @@ type contextRequest struct {
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
func newBlockHandler(vm consensuschain.BlockBuilder, logger log.Logger, engine *consensuschain.Runtime, net network.Network, msgCreator message.OutboundMsgBuilder, chainID ids.ID, networkID ids.ID, beacons validators.Manager, expectsStakedBeacons bool) *blockHandler {
|
||||
func newBlockHandler(vm consensuschain.BlockBuilder, logger log.Logger, engine *consensuschain.Runtime, net network.Network, msgCreator message.OutboundMsgBuilder, chainID ids.ID, networkID ids.ID, beacons validators.Manager, selfNodeID ids.NodeID, expectsStakedBeacons bool) *blockHandler {
|
||||
return &blockHandler{
|
||||
vm: vm,
|
||||
logger: logger,
|
||||
@@ -2846,6 +2855,7 @@ func newBlockHandler(vm consensuschain.BlockBuilder, logger log.Logger, engine *
|
||||
chainID: chainID,
|
||||
networkID: networkID,
|
||||
beacons: beacons,
|
||||
selfNodeID: selfNodeID,
|
||||
expectsStakedBeacons: expectsStakedBeacons,
|
||||
pendingContext: make(map[ids.ID]contextRequest),
|
||||
maxContextBlocks: 256, // Default max context blocks to request/serve
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// zz_red_probe_test.go — RED adversarial security-regression probe for the fresh-net self-vote
|
||||
// caught-up path. Demonstrates the connected-vs-replied divergence: the fix gates the self-vote
|
||||
// on fullyConnectedBeacons (CONNECTIVITY, from net.PeerInfo) but tallies CaughtUp over `replies`
|
||||
// (from collectFrontierReplies). A beacon that is CONNECTED but does NOT answer the frontier
|
||||
// query this round counts as "fully connected" yet contributes nothing to the caught-up tally —
|
||||
// and the self-vote backfills its missing weight, so a HEAVY validator self-completes caught-up
|
||||
// at a STALE height while an honest connected beacon is genuinely ahead. Blue's bsBeaconNet
|
||||
// cannot express this (its Send answers for EVERY connected beacon), so the regression slipped
|
||||
// through. These assertions encode the DESIRED safe behavior: they FAIL on the current code (the
|
||||
// break) and will PASS once the self-vote gate also requires every connected beacon to have
|
||||
// REPLIED this round.
|
||||
package chains
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
chainbootstrap "github.com/luxfi/consensus/engine/chain/bootstrap"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/message"
|
||||
"github.com/luxfi/node/network"
|
||||
"github.com/luxfi/node/network/peer"
|
||||
)
|
||||
|
||||
// redSilentNet reports FULL connectivity (every beacon in `connected` is returned by PeerInfo)
|
||||
// but a designated `silent` beacon — though connected — withholds its GetAcceptedFrontier reply
|
||||
// this round. This is the exact capability an on-path adversary has (keep a beacon's
|
||||
// TCP/handshake alive so it shows connected, while dropping/delaying its application-level
|
||||
// frontier response past the 3s window) AND a natural occurrence during a mass co-restart (an
|
||||
// ahead beacon replaying state answers the frontier query slowly).
|
||||
type redSilentNet struct {
|
||||
network.Network
|
||||
bh *blockHandler
|
||||
connected []ids.NodeID // all reported connected (the full set MINUS self)
|
||||
silent set.Set[ids.NodeID] // connected but withhold their frontier reply
|
||||
tipFor map[ids.NodeID]ids.ID // what each VOCAL beacon reports
|
||||
}
|
||||
|
||||
func (n *redSilentNet) PeerInfo(nodeIDs []ids.NodeID) []peer.Info {
|
||||
want := map[ids.NodeID]bool{}
|
||||
for _, id := range nodeIDs {
|
||||
want[id] = true
|
||||
}
|
||||
var out []peer.Info
|
||||
for _, b := range n.connected {
|
||||
if len(nodeIDs) == 0 || want[b] {
|
||||
out = append(out, peer.Info{ID: b, TrackedChains: set.Of(n.bh.networkID)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (n *redSilentNet) Send(msg message.OutboundMessage, nodeIDs set.Set[ids.NodeID], _ ids.ID, _ uint32) set.Set[ids.NodeID] {
|
||||
m, ok := msg.(*bsOutMsg)
|
||||
if !ok || m.op != "frontier" {
|
||||
return nil
|
||||
}
|
||||
for id := range nodeIDs {
|
||||
if n.silent.Contains(id) {
|
||||
continue // CONNECTED, but withholds its frontier reply this round
|
||||
}
|
||||
if tip, ok := n.tipFor[id]; ok {
|
||||
n.bh.deliverBootstrapFrontier(id, tip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestRED_PROBE_ConnectedButSilentAheadBeacon_SelfVoteFalseCompletesAtStale is a TWO-SIDED
|
||||
// CONTRAST: the ONLY difference between the two runs is whether the CONNECTED ahead-beacon B
|
||||
// delivers its frontier reply. B is fully connected in both runs, so fullyConnectedBeacons is
|
||||
// TRUE in both. Yet B-replies → safe (status != FrontierCaughtUp); B-connected-but-silent →
|
||||
// FrontierCaughtUp at the stale height (the break). This falsifies Blue's safety claim ("a
|
||||
// genuinely behind node has an ahead peer in the full set → CaughtUp is false → it keeps
|
||||
// waiting/syncing"): the gate keys on CONNECTIVITY while the caught-up decision keys on REPLIES,
|
||||
// and the two diverge.
|
||||
//
|
||||
// Why K is genuinely finalized-ahead (a real safety break, not a minority fork): a heavy
|
||||
// validator (self=60%) finalizes K WITH a minority (B=33%, so self+B=93% ≥ ⅔), then LOSES K to a
|
||||
// persistence lag (this codebase documents exactly this — the ZAP fire-and-forget Accept /
|
||||
// bsTestVM.frozenLastAccepted) and restarts STALE at M < K. B retained the finalized K. The node
|
||||
// MUST recover K; self-completing at M abandons finalized history and lets it build a conflicting
|
||||
// fork. The node cannot tell "M is the frontier" from "I lost finalized K" — which is precisely
|
||||
// why it must HEAR from connected B before concluding caught-up. self is NOT an independent
|
||||
// witness to its own caught-up-ness; the self-vote lets the node vouch for its own staleness.
|
||||
func TestRED_PROBE_ConnectedButSilentAheadBeacon_SelfVoteFalseCompletesAtStale(t *testing.T) {
|
||||
const N = 10 // K: the finalized-ahead height B retained (self voted it, then lost it)
|
||||
const M = 5 // the node's STALE accepted height after the persistence-lag crash
|
||||
|
||||
run := func(t *testing.T, bSilent bool) chainbootstrap.FrontierStatus {
|
||||
chain, _ := buildBSChain(N, -1)
|
||||
vm := newBSVMAt(chain, M) // node stale at M; it does NOT hold chain[N]=K
|
||||
|
||||
self := ids.GenerateTestNodeID()
|
||||
a := ids.GenerateTestNodeID() // co-stale light beacon, at M
|
||||
b := ids.GenerateTestNodeID() // AHEAD beacon, retained finalized K
|
||||
// HEAVY self (60 of 100): self > total/2 - 1, so peers alone (40) < the 51 stake-majority
|
||||
// floor → the self-vote branch. self+B=93% could finalize K; B=33% retains it.
|
||||
weights := map[ids.NodeID]uint64{self: 60, a: 7, b: 33}
|
||||
|
||||
bh, _ := newBSHandlerWeighted(t, vm, weights)
|
||||
bh.selfNodeID = self
|
||||
bh.msgCreator = bsMsgBuilder{}
|
||||
|
||||
// FULL connectivity in BOTH runs: A and B are connected (B is in `connected` either way).
|
||||
tipFor := map[ids.NodeID]ids.ID{a: chain[M].id} // A reports the stale tip M
|
||||
silent := set.NewSet[ids.NodeID](1)
|
||||
if bSilent {
|
||||
silent.Add(b) // B connected but withholds its frontier reply this round
|
||||
} else {
|
||||
tipFor[b] = chain[N].id // B replies its genuine ahead tip K
|
||||
}
|
||||
bh.net = &redSilentNet{bh: bh, connected: []ids.NodeID{a, b}, silent: silent, tipFor: tipFor}
|
||||
|
||||
bh.bsActive.Store(true)
|
||||
_, status := bh.FrontierTip(context.Background())
|
||||
bh.bsActive.Store(false)
|
||||
return status
|
||||
}
|
||||
|
||||
bReplies := run(t, false)
|
||||
bSilent := run(t, true)
|
||||
t.Logf("B replies its ahead tip → status=%v (3=FrontierConnecting, safe)", bReplies)
|
||||
t.Logf("B connected but SILENT → status=%v (5=FrontierCaughtUp, the BREAK)", bSilent)
|
||||
|
||||
// Sanity: when the ahead beacon REPLIES, the node correctly fails safe (does not conclude caught-up).
|
||||
require.NotEqual(t, chainbootstrap.FrontierCaughtUp, bReplies,
|
||||
"sanity: when the ahead beacon REPLIES, the node correctly does NOT conclude caught-up")
|
||||
|
||||
// THE SECURITY REGRESSION ASSERTION. B is fully CONNECTED in both runs. The node must NOT
|
||||
// self-complete caught-up while a connected beacon's position is unknown — that is a stale
|
||||
// go-live. FAILS today (the break); PASSES once the self-vote gate also requires every connected
|
||||
// beacon to have REPLIED this round (not merely be connected).
|
||||
require.NotEqual(t, chainbootstrap.FrontierCaughtUp, bSilent,
|
||||
"BREAK: suppressing only the CONNECTED ahead-beacon's frontier reply flips the heavy node to "+
|
||||
"FrontierCaughtUp at the STALE height — the self-vote backfills the floor and the "+
|
||||
"full-connectivity gate cannot see the reply suppression")
|
||||
}
|
||||
|
||||
// TestRED_PROBE_EqualStakeNeedsNoSelfVote answers deploy-question #5: 5 EQUAL-stake beacons, node
|
||||
// a beacon, all four peers connected and reporting a common tip — the node concludes caught-up via
|
||||
// the ORDINARY AcceptsFrontier path (peers clear the stake-majority floor: 4·w of 5·w = 80% >
|
||||
// 50%). The self-vote is NEVER needed for equal stake, so the equal-stake devnet hang is NOT this
|
||||
// self-exclusion floor (look at primaryNetworkReady / P-chain bootstrap / beacon connectivity).
|
||||
func TestRED_PROBE_EqualStakeNeedsNoSelfVote(t *testing.T) {
|
||||
chain, byID := buildBSChain(8, -1)
|
||||
vm := newBSVM(chain) // node at genesis (height 0)
|
||||
|
||||
self := ids.GenerateTestNodeID()
|
||||
p1, p2, p3, p4 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
|
||||
weights := map[ids.NodeID]uint64{self: 100, p1: 100, p2: 100, p3: 100, p4: 100}
|
||||
|
||||
bh, chainID := newBSHandlerWeighted(t, vm, weights)
|
||||
bh.selfNodeID = self
|
||||
bh.msgCreator = bsMsgBuilder{}
|
||||
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: []ids.NodeID{p1, p2, p3, p4}, byID: byID, tip: chain[0]}
|
||||
|
||||
bh.bsActive.Store(true)
|
||||
tip, status := bh.FrontierTip(context.Background())
|
||||
bh.bsActive.Store(false)
|
||||
|
||||
t.Logf("equal-stake fresh net: status=%v tip=%v", status, tip)
|
||||
require.Contains(t, []chainbootstrap.FrontierStatus{chainbootstrap.FrontierNamed, chainbootstrap.FrontierCaughtUp}, status,
|
||||
"equal-stake peers clear the stake-majority floor unaided — no self-vote needed")
|
||||
require.Equal(t, chain[0].id, tip, "caught up at genesis")
|
||||
}
|
||||
@@ -68,3 +68,54 @@ Block arrives
|
||||
## Recent Changes
|
||||
|
||||
- 2026-01-04: Created documentation files with Vote terminology
|
||||
|
||||
## consensus/quasar — PQ-finality VERIFY gate (2026-06-28)
|
||||
|
||||
Supersedes the stale "Quasar wrapper / CoronaCoordinator" notes above (that
|
||||
subpackage did not exist in-tree). The current `consensus/quasar` package is the
|
||||
node-side integration of `luxfi/consensus@v1.29.0`'s typed compact-cert finality
|
||||
layer (`protocol/quasar.VerifyConsensusCert`). It wires the VERIFY half only.
|
||||
|
||||
Model: luxd finalizes on classical Snow every block; at CHECKPOINTS (height %
|
||||
interval) a sampled committee's QuasarCert over the finalized digest is VERIFIED.
|
||||
Default posture HYBRID_PQ = Beam(BLS) ∧ Pulsar (ML-DSA-65); STRICT_DUAL_PQ
|
||||
(+Corona) / POLARIS (+Magnetar) configurable.
|
||||
|
||||
THE SAFETY CONTRACT — forward-dated, DORMANT by default:
|
||||
- `Gate.VerifyAccepted` is the accept-path boundary. nil gate / `Activation.Height
|
||||
== 0` / below-height / non-checkpoint => no-op; classical finality UNCHANGED.
|
||||
- Activated at a checkpoint => REQUIRE a valid cert bound to the finalized block;
|
||||
FAIL CLOSED (missing/mismatch/invalid => error from Accept, halts without
|
||||
persisting). Activation is HEIGHT-ONLY (deterministic — no wall clock).
|
||||
- Hooked in `vms/proposervm/post_fork_block.go Accept()` via
|
||||
`vm.verifyQuasarFinality(b)`; the VM's `quasarGate` is nil in production today
|
||||
(set via `SetQuasarGate`). Nothing wires it yet — that is the activation step.
|
||||
|
||||
Files: gate.go (Gate/ActivationConfig/bindCheck), policy.go (HYBRID_PQ default,
|
||||
cert can't pick its own policy), validators.go (ConsensusValidatorSet: BLS+Pulsar
|
||||
keys), store.go (MemCertStore), producer.go (committee-signer interface =
|
||||
scaffolding; nil = verify-only), errors.go. Tests: gate_test.go (13, -race green:
|
||||
dormant no-op, fail-closed, epoch/round/chain/height/block anti-replay, real-
|
||||
verifier delegation, misconfigured-fails-closed).
|
||||
|
||||
REMAINING WORK to reach a live PQ-finality network (all owner-gated):
|
||||
1. Producer service (pulsard): the per-validator committee cert signer. Needs
|
||||
pulsar v1.7.1 (no-reconstruct hyperball signer) AND consensus to EXPORT the
|
||||
currently package-private cert/payload ENCODERS (an external producer cannot
|
||||
assemble a ConsensusCert envelope without them; this also unblocks an end-to-
|
||||
end positive verify test).
|
||||
2. Cert gossip/ingest -> MemCertStore (verify-before-store); MemCertStore needs
|
||||
eviction below last-finalized height before this lands.
|
||||
3. Production per-epoch `ValidatorSetProvider` from the P-Chain validator manager
|
||||
+ KeyEra registry (BLS aggregate + Pulsar/Corona/Magnetar group keys per era).
|
||||
4. Config-flag -> SetQuasarGate wiring (construct a non-nil gate from node config;
|
||||
choose ChainID = sovereign/EVM chain id).
|
||||
5. Cert-unavailability runbook + a bounded grace window (await cert N rounds)
|
||||
before a checkpoint halts — fail-closed-after-decision can brick a chain if
|
||||
gossip is down. REQUIRED before any forward-dated activation.
|
||||
6. A proposervm Accept-path integration test (nil/dormant/activated).
|
||||
|
||||
Mainnet activation order (owner): deploy producer -> verify cert-gossip coverage
|
||||
at checkpoints -> set Activation.Height to a forward-dated height with margin ->
|
||||
roll via `kubectl patch sts luxd` OnDelete, 1 pod at a time. NEVER wipe /data/db,
|
||||
NEVER pkill, NEVER blind-restart.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package quasar
|
||||
|
||||
import "errors"
|
||||
|
||||
// Typed, fail-closed errors. Every one is returned (never swallowed) and, when
|
||||
// surfaced from the accept hook post-activation, halts finalization rather than
|
||||
// accepting a checkpoint without valid post-quantum evidence.
|
||||
var (
|
||||
// ErrFinalityCertMissing — a checkpoint was finalized post-activation but no
|
||||
// QuasarCert is available for it (the producer has not delivered one).
|
||||
ErrFinalityCertMissing = errors.New("quasar: finality cert missing for checkpoint")
|
||||
|
||||
// ErrFinalityCertMismatch — a cert exists but does not bind the finalized
|
||||
// block (chain/height/block/state mismatch). Anti-replay.
|
||||
ErrFinalityCertMismatch = errors.New("quasar: finality cert does not bind the finalized block")
|
||||
|
||||
// ErrFinalityCertInvalid — the cert is bound correctly but failed consensus
|
||||
// verification (policy, validator-set root, or a leg signature).
|
||||
ErrFinalityCertInvalid = errors.New("quasar: finality cert failed verification")
|
||||
|
||||
// ErrValidatorSetUnavailable — no committed validator set for the cert's
|
||||
// epoch (the verifier cannot resolve the per-leg verification keys).
|
||||
ErrValidatorSetUnavailable = errors.New("quasar: validator set unavailable for epoch")
|
||||
|
||||
// ErrPolicyUnavailable — the gate has no configured policy.
|
||||
ErrPolicyUnavailable = errors.New("quasar: policy unavailable")
|
||||
|
||||
// ErrPolicyMismatch — the cert's PolicyID is not the configured policy. A
|
||||
// cert cannot select its own (weaker) posture.
|
||||
ErrPolicyMismatch = errors.New("quasar: cert policy id does not match configured policy")
|
||||
|
||||
// ErrGateMisconfigured — the gate is activated at a checkpoint but has no
|
||||
// cert store or validator provider. Fail closed rather than panic.
|
||||
ErrGateMisconfigured = errors.New("quasar: gate activated but missing store or validator provider")
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package quasar is the node-side integration of the luxfi/consensus Quasar
|
||||
// post-quantum finality-certificate layer.
|
||||
//
|
||||
// luxd finalizes blocks on the classical Snow/Avalanche path (fast, every
|
||||
// block). On top, at CHECKPOINTS (epoch boundaries — NOT every block), a sampled
|
||||
// committee produces a QuasarCert over the finalized digest and validators
|
||||
// VERIFY it. This package wires the VERIFY half: it consumes
|
||||
// github.com/luxfi/consensus/protocol/quasar.VerifyConsensusCert as an OPTIONAL,
|
||||
// FORWARD-DATED, DORMANT-BY-DEFAULT check in the block-accept path.
|
||||
//
|
||||
// # Safety contract
|
||||
//
|
||||
// The forward-dated dormant activation is the whole reason this package has the
|
||||
// shape it does:
|
||||
//
|
||||
// - Pre-activation (the default): VerifyAccepted is a pure no-op. Classical
|
||||
// Snow finality is UNCHANGED. A nil *Gate, a zero Gate, or an unset
|
||||
// activation height all mean "dormant" — zero behavior change.
|
||||
// - Post-activation (owner sets Activation.Height to a real, forward-dated
|
||||
// height): at every checkpoint height the gate REQUIRES a valid QuasarCert
|
||||
// bound to the just-finalized block and FAILS CLOSED — a missing or invalid
|
||||
// cert returns an error from Accept(), halting the chain rather than
|
||||
// finalizing a checkpoint without post-quantum evidence.
|
||||
//
|
||||
// Activation is therefore a deliberate switch the owner flips only AFTER the
|
||||
// cert PRODUCER (the per-validator committee signer) is live and certs flow at
|
||||
// the checkpoint cadence — otherwise every checkpoint would halt. See
|
||||
// producer.go.
|
||||
//
|
||||
// # Default posture
|
||||
//
|
||||
// HYBRID_PQ = Beam(BLS) ∧ Pulsar (standard FIPS-204 threshold ML-DSA), at
|
||||
// checkpoint cadence. Per the measured policy-tier benchmarks, Pulsar verify
|
||||
// (~140µs) is cheaper than BLS itself and the cert is compact (~27KB) — the
|
||||
// right default production finality posture. STRICT_DUAL_PQ (∧ Corona) and the
|
||||
// POLARIS tiers (∧ Magnetar) are configurable for stricter mainnet finality.
|
||||
package quasar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
qcert "github.com/luxfi/consensus/protocol/quasar"
|
||||
)
|
||||
|
||||
// ActivationConfig is the forward-dated activation switch.
|
||||
//
|
||||
// The zero value is DORMANT: Height == 0 means "never activate" and the gate is
|
||||
// a no-op for every block. This mirrors the genesis upgrade discipline (a
|
||||
// far-future / unset activation point cannot affect live finality).
|
||||
//
|
||||
// Activation is by HEIGHT ONLY, deliberately. A block height is agreed by
|
||||
// consensus, so every honest validator enforces PQ finality at exactly the SAME
|
||||
// checkpoints — there is no node-local decision. (A wall-clock gate would split
|
||||
// finalization across validators with skewed clocks: some halting on a missing
|
||||
// cert while others finalize without one. Timestamp-based forward-dating is
|
||||
// expressed by choosing the activation HEIGHT at the target time.)
|
||||
type ActivationConfig struct {
|
||||
// Height is the block height at and above which PQ-finality verification is
|
||||
// enforced at checkpoints. 0 == dormant (never).
|
||||
Height uint64
|
||||
}
|
||||
|
||||
// dormant reports whether the activation is unset (the default — never enforce).
|
||||
func (a ActivationConfig) dormant() bool { return a.Height == 0 }
|
||||
|
||||
// active reports whether enforcement is live for a block at the given height.
|
||||
// Deterministic: height-only, no wall clock. Dormant activation is never active.
|
||||
func (a ActivationConfig) active(height uint64) bool {
|
||||
return a.Height != 0 && height >= a.Height
|
||||
}
|
||||
|
||||
// DefaultCheckpointInterval is the default checkpoint cadence in blocks. PQ
|
||||
// certs ride epoch-boundary checkpoints, never every block (Magnetar sign is
|
||||
// checkpoint-only; even the cheap Pulsar sign is checkpoint cadence). The owner
|
||||
// overrides this to match the producer's cadence at activation time.
|
||||
const DefaultCheckpointInterval uint64 = 256
|
||||
|
||||
// DefaultMode is the default Quasar posture: HYBRID_PQ (Beam ∧ Pulsar).
|
||||
const DefaultMode = qcert.PolicyHybridPQCheckpoint
|
||||
|
||||
// Config is the node-surfaced PQ-finality configuration. Its zero value is
|
||||
// dormant + HYBRID_PQ + default cadence.
|
||||
type Config struct {
|
||||
// ChainID is THIS chain's numeric identifier (the sovereign/EVM chain id),
|
||||
// bound into every cert and checked against it. A per-chain constant sourced
|
||||
// from chain config at gate construction — NOT pulled from a block, because
|
||||
// the proposervm layer carries the 32-byte validator-set id, not the numeric
|
||||
// chain id. Inert while dormant.
|
||||
ChainID uint32
|
||||
|
||||
// Activation is the forward-dated dormant switch. Zero => dormant.
|
||||
Activation ActivationConfig
|
||||
|
||||
// Mode is the Quasar evidence posture. Zero => DefaultMode (HYBRID_PQ).
|
||||
Mode qcert.QuasarEvidenceMode
|
||||
|
||||
// MLDSAParam selects the ML-DSA parameter set for the Pulsar leg. 0 =>
|
||||
// ML-DSA-65 (the consensus default).
|
||||
MLDSAParam uint8
|
||||
|
||||
// Threshold is the BFT quorum floor (minimum aggregate signer weight) every
|
||||
// leg's evidence must establish.
|
||||
Threshold uint64
|
||||
|
||||
// CheckpointInterval is the checkpoint cadence in blocks. 0 =>
|
||||
// DefaultCheckpointInterval.
|
||||
CheckpointInterval uint64
|
||||
}
|
||||
|
||||
// Checkpoint is the finalized-block position the accept hook hands the gate. It
|
||||
// is the binding the cert must match (anti-replay): a valid cert for a DIFFERENT
|
||||
// block must never satisfy THIS checkpoint. The chain id is gate-level config,
|
||||
// not a per-block field.
|
||||
type Checkpoint struct {
|
||||
Epoch uint64
|
||||
Height uint64
|
||||
Round uint32
|
||||
BlockID [32]byte
|
||||
StateRoot [32]byte
|
||||
}
|
||||
|
||||
// Gate enforces (or, dormant, ignores) PQ-finality at checkpoints. It is the
|
||||
// single node-side seam between the classical accept path and the consensus
|
||||
// Quasar verifier.
|
||||
type Gate struct {
|
||||
cfg Config
|
||||
policy *qcert.QuasarEvidencePolicy
|
||||
store CertStore
|
||||
validators ValidatorSetProvider
|
||||
}
|
||||
|
||||
// NewGate constructs a Gate. A Gate is meaningful even with a dormant Config:
|
||||
// VerifyAccepted is a no-op until Activation.Height is set. store and validators
|
||||
// are only consulted post-activation at checkpoints.
|
||||
func NewGate(cfg Config, store CertStore, validators ValidatorSetProvider) *Gate {
|
||||
mode := cfg.Mode
|
||||
if mode == 0 {
|
||||
mode = DefaultMode
|
||||
}
|
||||
if cfg.CheckpointInterval == 0 {
|
||||
cfg.CheckpointInterval = DefaultCheckpointInterval
|
||||
}
|
||||
cfg.Mode = mode
|
||||
return &Gate{
|
||||
cfg: cfg,
|
||||
policy: qcert.NewQuasarEvidencePolicy(mode, cfg.MLDSAParam, cfg.Threshold),
|
||||
store: store,
|
||||
validators: validators,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyAccepted is the accept-path hook and the SAFETY BOUNDARY.
|
||||
//
|
||||
// - g == nil OR dormant activation => returns nil immediately. This is the
|
||||
// default and guarantees classical Snow finality is unchanged.
|
||||
// - height below activation, or activation time not yet reached => nil.
|
||||
// - not a checkpoint height => nil (certs ride checkpoints only).
|
||||
// - checkpoint, activated => REQUIRE a valid cert bound to this block; FAIL
|
||||
// CLOSED. A missing, mis-bound, or invalid cert is an error (the caller
|
||||
// returns it from Accept, halting rather than finalizing without PQ
|
||||
// evidence).
|
||||
//
|
||||
// It is intentionally nil-safe so the proposervm hook can call
|
||||
// vm.quasarGate.VerifyAccepted(...) unconditionally with a nil gate.
|
||||
func (g *Gate) VerifyAccepted(cp Checkpoint) error {
|
||||
if g == nil || g.cfg.Activation.dormant() {
|
||||
return nil
|
||||
}
|
||||
if !g.cfg.Activation.active(cp.Height) {
|
||||
return nil
|
||||
}
|
||||
if !g.isCheckpoint(cp.Height) {
|
||||
return nil
|
||||
}
|
||||
// Activated checkpoint: the gate MUST have its cert store + validator
|
||||
// provider, or it cannot verify. Fail closed with a typed error rather than
|
||||
// panic in the accept hook (a panic would halt the chain uncontrollably).
|
||||
if g.store == nil || g.validators == nil {
|
||||
return fmt.Errorf("%w: chain=%d height=%d", ErrGateMisconfigured, g.cfg.ChainID, cp.Height)
|
||||
}
|
||||
|
||||
cert, ok := g.store.Lookup(g.cfg.ChainID, cp.Height, cp.BlockID)
|
||||
if !ok || cert == nil {
|
||||
return fmt.Errorf("%w: chain=%d height=%d block=%x", ErrFinalityCertMissing, g.cfg.ChainID, cp.Height, cp.BlockID[:8])
|
||||
}
|
||||
if err := bindCheck(cert, g.cfg.ChainID, cp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vs, err := g.validators.ValidatorSet(g.cfg.ChainID, cert.Epoch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: chain=%d epoch=%d: %v", ErrValidatorSetUnavailable, g.cfg.ChainID, cert.Epoch, err)
|
||||
}
|
||||
if err := qcert.VerifyConsensusCert(policyStore{policy: g.policy}, vs, cert); err != nil {
|
||||
return fmt.Errorf("%w: chain=%d height=%d: %v", ErrFinalityCertInvalid, g.cfg.ChainID, cp.Height, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Activated reports whether enforcement is live for a block at the given height
|
||||
// and the current wall clock. Used by the producer-request site to decide
|
||||
// whether a cert is needed at a checkpoint.
|
||||
func (g *Gate) Activated(height uint64) bool {
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
return g.cfg.Activation.active(height)
|
||||
}
|
||||
|
||||
// IsCheckpoint reports whether the given height is a checkpoint under the gate's
|
||||
// configured cadence. Exported so the producer-request site shares ONE cadence
|
||||
// definition with the verify path (no second source of truth).
|
||||
func (g *Gate) IsCheckpoint(height uint64) bool {
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
return g.isCheckpoint(height)
|
||||
}
|
||||
|
||||
func (g *Gate) isCheckpoint(height uint64) bool {
|
||||
iv := g.cfg.CheckpointInterval
|
||||
if iv == 0 {
|
||||
iv = DefaultCheckpointInterval
|
||||
}
|
||||
return height%iv == 0
|
||||
}
|
||||
|
||||
// bindCheck pins the cert to the actual finalized block. Without this, a valid
|
||||
// cert produced for a different (chain, height, block) could be replayed to
|
||||
// satisfy this checkpoint. VerifyConsensusCert checks the cert's INTERNAL
|
||||
// consistency and the validator-set/policy binding; bindCheck adds the external
|
||||
// binding to THIS node's finalized position.
|
||||
func bindCheck(cert *qcert.ConsensusCert, chainID uint32, cp Checkpoint) error {
|
||||
if cert.ChainID != chainID {
|
||||
return fmt.Errorf("%w: cert chain %d != finalized chain %d", ErrFinalityCertMismatch, cert.ChainID, chainID)
|
||||
}
|
||||
// Bind the epoch. The gate resolves the verification keys from the cert's
|
||||
// epoch, so an UNBOUND epoch would let a cert signed under a DIFFERENT
|
||||
// validator-set era (e.g. a compromised RETIRED committee's group key) certify
|
||||
// the current block — nullifying KeyEra rotation as a blast-radius bound. The
|
||||
// honest producer signs over Subject.Epoch == cp.Epoch, so honest certs match.
|
||||
if cert.Epoch != cp.Epoch {
|
||||
return fmt.Errorf("%w: cert epoch %d != finalized epoch %d", ErrFinalityCertMismatch, cert.Epoch, cp.Epoch)
|
||||
}
|
||||
if cert.Round != cp.Round {
|
||||
return fmt.Errorf("%w: cert round %d != finalized round %d", ErrFinalityCertMismatch, cert.Round, cp.Round)
|
||||
}
|
||||
if cert.Height != cp.Height {
|
||||
return fmt.Errorf("%w: cert height %d != finalized height %d", ErrFinalityCertMismatch, cert.Height, cp.Height)
|
||||
}
|
||||
if cert.BlockHash != cp.BlockID {
|
||||
return fmt.Errorf("%w: cert block hash != finalized block id", ErrFinalityCertMismatch)
|
||||
}
|
||||
// StateRoot contract: at the proposervm layer the post-state root is
|
||||
// committed TRANSITIVELY through BlockHash, so cp.StateRoot is zero and the
|
||||
// cert MUST carry a zero StateRoot too. A non-zero cert StateRoot is rejected
|
||||
// (no state to cross-check here) — the producer follow-on MUST emit
|
||||
// StateRoot==0 at this layer; a chain that wants an explicit state binding
|
||||
// plumbs cp.StateRoot AND signs it, and this check then enforces equality.
|
||||
var zero [32]byte
|
||||
if cert.StateRoot != zero && cert.StateRoot != cp.StateRoot {
|
||||
return fmt.Errorf("%w: cert state root != finalized state root", ErrFinalityCertMismatch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package quasar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
qcert "github.com/luxfi/consensus/protocol/quasar"
|
||||
)
|
||||
|
||||
// vsRoot is a fixed committed-validator-set root used across the tests.
|
||||
var vsRoot = [48]byte{0x11, 0x22, 0x33, 0x44}
|
||||
|
||||
// testValidators is a ValidatorSet whose Root matches vsRoot, with non-empty
|
||||
// (placeholder) HYBRID_PQ keys. The delegation test never reaches signature
|
||||
// math, so the key bytes need only be non-empty.
|
||||
func testValidators() *ValidatorSet {
|
||||
return NewValidatorSet(vsRoot, 1, []byte("bls-agg-key"), []byte("pulsar-group-key"))
|
||||
}
|
||||
|
||||
// newGate builds a gate with a tight cadence (checkpoint every 10 blocks) and
|
||||
// the given forward-dated activation height. interval 10 keeps the heights in
|
||||
// the tests readable.
|
||||
func newGate(activationHeight uint64, store CertStore) *Gate {
|
||||
return NewGate(Config{
|
||||
ChainID: 1337,
|
||||
Activation: ActivationConfig{Height: activationHeight},
|
||||
Mode: DefaultMode, // HYBRID_PQ
|
||||
Threshold: 100,
|
||||
CheckpointInterval: 10,
|
||||
}, store, StaticValidatorSetProvider{Set: testValidators()})
|
||||
}
|
||||
|
||||
func checkpointAt(height uint64) Checkpoint {
|
||||
return Checkpoint{
|
||||
Epoch: 1,
|
||||
Height: height,
|
||||
BlockID: [32]byte{0xab, 0xcd, 0xef},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDormantIsNoop — the default (Activation.Height == 0) is a pure no-op even
|
||||
// at a checkpoint height with a poisoned store. This is the core safety
|
||||
// property: pre-activation, classical Snow finality is unchanged.
|
||||
func TestDormantIsNoop(t *testing.T) {
|
||||
store := NewMemCertStore()
|
||||
g := NewGate(Config{CheckpointInterval: 10}, store, StaticValidatorSetProvider{Set: testValidators()})
|
||||
// height 10 is a checkpoint; no cert exists; yet dormant => nil.
|
||||
if err := g.VerifyAccepted(checkpointAt(10)); err != nil {
|
||||
t.Fatalf("dormant gate must be a no-op, got %v", err)
|
||||
}
|
||||
if g.Activated(10) {
|
||||
t.Fatal("dormant gate must never report Activated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNilGateIsNoop — a nil *Gate is the wire-it-but-leave-it-off default; the
|
||||
// proposervm hook calls VerifyAccepted on a possibly-nil gate.
|
||||
func TestNilGateIsNoop(t *testing.T) {
|
||||
var g *Gate
|
||||
if err := g.VerifyAccepted(checkpointAt(10)); err != nil {
|
||||
t.Fatalf("nil gate must be a no-op, got %v", err)
|
||||
}
|
||||
if g.Activated(10) || g.IsCheckpoint(10) {
|
||||
t.Fatal("nil gate must report neither Activated nor IsCheckpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBelowActivationIsNoop — activated at height 100 but the block is at 10:
|
||||
// below the forward-dated height => nil, even at a checkpoint with no cert.
|
||||
func TestBelowActivationIsNoop(t *testing.T) {
|
||||
g := newGate(100, NewMemCertStore())
|
||||
if err := g.VerifyAccepted(checkpointAt(10)); err != nil {
|
||||
t.Fatalf("below activation must be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonCheckpointIsNoop — activated and at/above activation height, but the
|
||||
// height is not a checkpoint => nil (certs ride checkpoints only).
|
||||
func TestNonCheckpointIsNoop(t *testing.T) {
|
||||
g := newGate(10, NewMemCertStore())
|
||||
// height 15 is activated (>=10) but not a checkpoint (15 % 10 != 0).
|
||||
if err := g.VerifyAccepted(checkpointAt(15)); err != nil {
|
||||
t.Fatalf("non-checkpoint must be a no-op, got %v", err)
|
||||
}
|
||||
if !g.Activated(15) {
|
||||
t.Fatal("height 15 should be activated")
|
||||
}
|
||||
if g.IsCheckpoint(15) {
|
||||
t.Fatal("height 15 must not be a checkpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingCertFailsClosed — activated checkpoint with no cert in the store =>
|
||||
// ErrFinalityCertMissing. Post-activation a checkpoint without PQ evidence must
|
||||
// NOT finalize.
|
||||
func TestMissingCertFailsClosed(t *testing.T) {
|
||||
g := newGate(10, NewMemCertStore())
|
||||
err := g.VerifyAccepted(checkpointAt(20))
|
||||
if !errors.Is(err, ErrFinalityCertMissing) {
|
||||
t.Fatalf("want ErrFinalityCertMissing, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMismatchedCertRejected — a cert that does not bind the finalized block
|
||||
// (wrong block id / height / chain) is rejected by bindCheck before any crypto.
|
||||
// Anti-replay: a valid cert for a different block must not satisfy this one.
|
||||
func TestMismatchedCertRejected(t *testing.T) {
|
||||
// cp = checkpointAt(20) has Epoch 1, Round 0. Each case sets every binding
|
||||
// field correctly EXCEPT the one named, so it fails at that field.
|
||||
cases := []struct {
|
||||
name string
|
||||
cert *qcert.ConsensusCert
|
||||
}{
|
||||
{"wrong block", &qcert.ConsensusCert{ChainID: 1337, Epoch: 1, Height: 20, BlockHash: [32]byte{0x99}}},
|
||||
{"wrong height", &qcert.ConsensusCert{ChainID: 1337, Epoch: 1, Height: 21, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
|
||||
{"wrong chain", &qcert.ConsensusCert{ChainID: 7, Epoch: 1, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
|
||||
{"wrong epoch", &qcert.ConsensusCert{ChainID: 1337, Epoch: 2, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
|
||||
{"wrong round", &qcert.ConsensusCert{ChainID: 1337, Epoch: 1, Round: 1, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
|
||||
{"wrong state root", &qcert.ConsensusCert{ChainID: 1337, Epoch: 1, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}, StateRoot: [32]byte{0x55}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := NewMemCertStore()
|
||||
// Index it at the checkpoint's lookup key so Lookup returns it and
|
||||
// bindCheck (not Lookup) does the rejecting.
|
||||
store.certs[certKey{chainID: 1337, height: 20, blockID: [32]byte{0xab, 0xcd, 0xef}}] = tc.cert
|
||||
g := newGate(10, store)
|
||||
err := g.VerifyAccepted(checkpointAt(20))
|
||||
if !errors.Is(err, ErrFinalityCertMismatch) {
|
||||
t.Fatalf("want ErrFinalityCertMismatch, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegatesToVerifier — a cert that BINDS correctly and passes the full
|
||||
// ConsensusCert header path (version, policy load, required-legs root, validator
|
||||
// -set root) but carries no signature evidence is rejected by the REAL
|
||||
// consensus verifier, and the gate surfaces it as ErrFinalityCertInvalid. This
|
||||
// proves the whole delegation chain is wired: policyStore + ValidatorSet +
|
||||
// quasar.VerifyConsensusCert are reached with matching commitments — everything
|
||||
// up to (but not including) the leg signature crypto, which needs the producer.
|
||||
func TestDelegatesToVerifier(t *testing.T) {
|
||||
cp := checkpointAt(20)
|
||||
|
||||
// Mirror the gate's posture to compute the header commitments the verifier
|
||||
// pins (policy id + required-legs root). policyID and required legs derive
|
||||
// from the mode + ML-DSA param, which match the gate's config.
|
||||
pol := qcert.NewQuasarEvidencePolicy(DefaultMode, 0, 100)
|
||||
cert := &qcert.ConsensusCert{
|
||||
Version: 1,
|
||||
ChainID: 1337, // must equal the gate's configured ChainID
|
||||
Epoch: cp.Epoch,
|
||||
Height: cp.Height,
|
||||
BlockHash: cp.BlockID,
|
||||
PolicyID: pol.EvidencePolicyID(),
|
||||
RequiredLegsRoot: qcert.HashRequiredLegs(pol.RequiredLegs()),
|
||||
ValidatorSetRoot: vsRoot,
|
||||
// Evidence intentionally empty: the verifier must reject a required leg
|
||||
// with no evidence (deepest deterministic failure without real crypto).
|
||||
}
|
||||
store := NewMemCertStore()
|
||||
store.Put(cert)
|
||||
g := newGate(10, store)
|
||||
|
||||
err := g.VerifyAccepted(cp)
|
||||
if !errors.Is(err, ErrFinalityCertInvalid) {
|
||||
t.Fatalf("want ErrFinalityCertInvalid (delegated), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatorSetUnavailable — a bound cert at an activated checkpoint, but the
|
||||
// provider has no set for the epoch => ErrValidatorSetUnavailable (fail closed).
|
||||
func TestValidatorSetUnavailable(t *testing.T) {
|
||||
cp := checkpointAt(20)
|
||||
// Bind correctly (epoch included) so the cert passes bindCheck and the
|
||||
// failure is specifically the unavailable validator set.
|
||||
cert := &qcert.ConsensusCert{Version: 1, ChainID: 1337, Epoch: cp.Epoch, Height: cp.Height, BlockHash: cp.BlockID}
|
||||
store := NewMemCertStore()
|
||||
store.Put(cert)
|
||||
g := NewGate(Config{
|
||||
ChainID: 1337,
|
||||
Activation: ActivationConfig{Height: 10},
|
||||
CheckpointInterval: 10,
|
||||
}, store, StaticValidatorSetProvider{Set: nil}) // provider present, no set
|
||||
err := g.VerifyAccepted(cp)
|
||||
if !errors.Is(err, ErrValidatorSetUnavailable) {
|
||||
t.Fatalf("want ErrValidatorSetUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGateMisconfiguredFailsClosed — an activated gate at a checkpoint with no
|
||||
// cert store (or no validator provider) fails closed with a typed error rather
|
||||
// than panicking in the accept hook.
|
||||
func TestGateMisconfiguredFailsClosed(t *testing.T) {
|
||||
g := NewGate(Config{
|
||||
ChainID: 1337,
|
||||
Activation: ActivationConfig{Height: 10},
|
||||
CheckpointInterval: 10,
|
||||
}, nil, nil) // no store, no validators
|
||||
if err := g.VerifyAccepted(checkpointAt(20)); !errors.Is(err, ErrGateMisconfigured) {
|
||||
t.Fatalf("want ErrGateMisconfigured, got %v", err)
|
||||
}
|
||||
// dormant misconfigured gate is still a no-op (guard is post-activation).
|
||||
gd := NewGate(Config{ChainID: 1337, CheckpointInterval: 10}, nil, nil)
|
||||
if err := gd.VerifyAccepted(checkpointAt(20)); err != nil {
|
||||
t.Fatalf("dormant gate must be a no-op even if misconfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- producer scaffolding ---
|
||||
|
||||
type stubProducer struct {
|
||||
cert *qcert.ConsensusCert
|
||||
hits int
|
||||
}
|
||||
|
||||
func (s *stubProducer) Produce(_ context.Context, _ Subject) (*qcert.ConsensusCert, error) {
|
||||
s.hits++
|
||||
return s.cert, nil
|
||||
}
|
||||
|
||||
// TestMaybeProduceVerifyOnlyByDefault — a nil producer is the verify-only
|
||||
// default: MaybeProduce short-circuits to (nil, nil), never panics.
|
||||
func TestMaybeProduceVerifyOnlyByDefault(t *testing.T) {
|
||||
g := newGate(10, NewMemCertStore())
|
||||
cert, err := g.MaybeProduce(context.Background(), nil, checkpointAt(20))
|
||||
if err != nil || cert != nil {
|
||||
t.Fatalf("nil producer must yield (nil,nil), got cert=%v err=%v", cert, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeProduceDormant — even with a producer wired, a dormant gate produces
|
||||
// nothing (the producer is brought up before activation is forward-dated).
|
||||
func TestMaybeProduceDormant(t *testing.T) {
|
||||
store := NewMemCertStore()
|
||||
g := NewGate(Config{CheckpointInterval: 10}, store, StaticValidatorSetProvider{Set: testValidators()})
|
||||
p := &stubProducer{cert: &qcert.ConsensusCert{}}
|
||||
cert, err := g.MaybeProduce(context.Background(), p, checkpointAt(20))
|
||||
if err != nil || cert != nil {
|
||||
t.Fatalf("dormant gate must not produce, got cert=%v err=%v", cert, err)
|
||||
}
|
||||
if p.hits != 0 {
|
||||
t.Fatalf("producer must not be called while dormant, hits=%d", p.hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeProduceActiveCheckpoint — wired producer + activated checkpoint =>
|
||||
// the producer is asked for the cert.
|
||||
func TestMaybeProduceActiveCheckpoint(t *testing.T) {
|
||||
g := newGate(10, NewMemCertStore())
|
||||
want := &qcert.ConsensusCert{ChainID: 1337, Height: 20}
|
||||
p := &stubProducer{cert: want}
|
||||
got, err := g.MaybeProduce(context.Background(), p, checkpointAt(20))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err %v", err)
|
||||
}
|
||||
if got != want || p.hits != 1 {
|
||||
t.Fatalf("producer not invoked as expected: got=%v hits=%d", got, p.hits)
|
||||
}
|
||||
// non-checkpoint height must not invoke the producer
|
||||
p2 := &stubProducer{cert: want}
|
||||
if _, _ = g.MaybeProduce(context.Background(), p2, checkpointAt(15)); p2.hits != 0 {
|
||||
t.Fatalf("producer invoked at non-checkpoint, hits=%d", p2.hits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package quasar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
qcert "github.com/luxfi/consensus/protocol/quasar"
|
||||
)
|
||||
|
||||
// policyStore adapts the single configured QuasarEvidencePolicy to the consensus
|
||||
// ConsensusCertPolicyStore interface. The verifier loads the required-leg set
|
||||
// and the (kind, mode, param) permissions from HERE — never from the cert
|
||||
// (invariants I1/I2). A cert that names a different PolicyID than the node's
|
||||
// configured posture is rejected: a cert cannot pick its own weaker policy.
|
||||
type policyStore struct{ policy *qcert.QuasarEvidencePolicy }
|
||||
|
||||
func (s policyStore) Policy(_ uint32, _ uint64, policyID uint32) (qcert.ConsensusCertPolicy, error) {
|
||||
if s.policy == nil {
|
||||
return nil, ErrPolicyUnavailable
|
||||
}
|
||||
if policyID != s.policy.EvidencePolicyID() {
|
||||
return nil, fmt.Errorf("%w: cert policy %d != configured %d", ErrPolicyMismatch, policyID, s.policy.EvidencePolicyID())
|
||||
}
|
||||
return s.policy, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package quasar
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
qcert "github.com/luxfi/consensus/protocol/quasar"
|
||||
)
|
||||
|
||||
// Subject is the finalized-block position a cert must certify — the producer's
|
||||
// input at a checkpoint. Mirrors Checkpoint (the verify side) so producer and
|
||||
// verifier bind the SAME tuple.
|
||||
type Subject struct {
|
||||
ChainID uint32
|
||||
Epoch uint64
|
||||
Height uint64
|
||||
Round uint32
|
||||
BlockID [32]byte
|
||||
StateRoot [32]byte
|
||||
}
|
||||
|
||||
// subjectFrom derives the producer Subject from this gate's chain id and a
|
||||
// finalized Checkpoint, so producer and verifier bind the SAME tuple.
|
||||
func (g *Gate) subjectFrom(cp Checkpoint) Subject {
|
||||
return Subject{
|
||||
ChainID: g.cfg.ChainID,
|
||||
Epoch: cp.Epoch,
|
||||
Height: cp.Height,
|
||||
Round: cp.Round,
|
||||
BlockID: cp.BlockID,
|
||||
StateRoot: cp.StateRoot,
|
||||
}
|
||||
}
|
||||
|
||||
// Producer is the committee cert-signing service contract (the per-validator
|
||||
// "pulsard" committee). At a checkpoint, a producing validator calls Produce to
|
||||
// obtain the QuasarCert over the finalized subject, then gossips it so peers can
|
||||
// verify and store it (via a CertStore).
|
||||
//
|
||||
// SCAFFOLDING — this is the seam, not the service. This milestone wires the
|
||||
// VERIFY half (gate.go) and this interface. luxd ships with a nil Producer
|
||||
// (verify-only): a node VERIFIES certs it receives but does not itself produce
|
||||
// them. A nil Producer is the correct default — most of the rollout window is
|
||||
// verify-only, and the producer is brought up before activation is forward-dated.
|
||||
//
|
||||
// Implementation path for the follow-on:
|
||||
//
|
||||
// - github.com/luxfi/consensus/protocol/quasar already defines the
|
||||
// producer-side abstractions: PWitnessProducer / QWitnessProducer /
|
||||
// ZWitnessProducer + NewWitnessSet, and ComposeDualPQEvidence. The concrete
|
||||
// committee signer implements Producer over those.
|
||||
// - The signer needs the live Pulsar key share + nonce pool + offline
|
||||
// preprocessing + one-round sign + verify-before-gossip + nonce-erase (the
|
||||
// no-reconstruct hyperball signer), which lands with pulsar v1.7.1.
|
||||
// - REQUIRED CONSENSUS EXPORT: the ConsensusCert envelope + per-leg payload
|
||||
// ENCODERS are package-private in consensus v1.29.0 (only the verifiers are
|
||||
// exported). An external producer — and any end-to-end "valid cert verifies
|
||||
// through the gate" test — needs those encoders exported (a small, additive
|
||||
// consensus change). The verify path here needs no such export: it consumes
|
||||
// a fully-formed *ConsensusCert.
|
||||
type Producer interface {
|
||||
Produce(ctx context.Context, subject Subject) (*qcert.ConsensusCert, error)
|
||||
}
|
||||
|
||||
// MaybeProduce is the checkpoint producer-request site. It is nil-safe and
|
||||
// activation-aware so the accept hook can call it unconditionally: a nil gate,
|
||||
// dormant activation, a non-checkpoint height, or a nil producer all short-
|
||||
// circuit to (nil, nil) — the verify-only default. When a producer IS wired and
|
||||
// the checkpoint is live, it requests the cert; the caller gossips/stores it.
|
||||
//
|
||||
// This keeps producer cadence and verify cadence on ONE definition (g.IsCheckpoint),
|
||||
// so producer and verifier can never disagree on which heights carry certs.
|
||||
func (g *Gate) MaybeProduce(ctx context.Context, producer Producer, cp Checkpoint) (*qcert.ConsensusCert, error) {
|
||||
if g == nil || producer == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !g.Activated(cp.Height) || !g.IsCheckpoint(cp.Height) {
|
||||
return nil, nil
|
||||
}
|
||||
return producer.Produce(ctx, g.subjectFrom(cp))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package quasar
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
qcert "github.com/luxfi/consensus/protocol/quasar"
|
||||
)
|
||||
|
||||
// CertStore resolves the QuasarCert that certifies a finalized block. In
|
||||
// production it is filled by the cert gossip/ingest path (the producer
|
||||
// follow-on, producer.go); the verify gate only READS from it. Keyed by the
|
||||
// finalized position (chainID, height, blockID) so a cert can never be returned
|
||||
// for the wrong block.
|
||||
type CertStore interface {
|
||||
Lookup(chainID uint32, height uint64, blockID [32]byte) (*qcert.ConsensusCert, bool)
|
||||
}
|
||||
|
||||
type certKey struct {
|
||||
chainID uint32
|
||||
height uint64
|
||||
blockID [32]byte
|
||||
}
|
||||
|
||||
// MemCertStore is an in-memory CertStore keyed by (chainID, height, blockID). It
|
||||
// is the ingest sink the cert-gossip handler writes into (Put) and the gate
|
||||
// reads from (Lookup). Safe for concurrent use.
|
||||
type MemCertStore struct {
|
||||
mu sync.RWMutex
|
||||
certs map[certKey]*qcert.ConsensusCert
|
||||
}
|
||||
|
||||
// NewMemCertStore returns an empty in-memory cert store.
|
||||
func NewMemCertStore() *MemCertStore {
|
||||
return &MemCertStore{certs: make(map[certKey]*qcert.ConsensusCert)}
|
||||
}
|
||||
|
||||
// Put indexes a cert by its own (ChainID, Height, BlockHash). The ingest path
|
||||
// MUST verify a cert before Put (verify-before-store), exactly as the gossip
|
||||
// layer verifies before re-gossip; the gate re-verifies at the checkpoint so a
|
||||
// store poisoned by an unverified Put still cannot finalize an invalid cert.
|
||||
func (m *MemCertStore) Put(cert *qcert.ConsensusCert) {
|
||||
if cert == nil {
|
||||
return
|
||||
}
|
||||
k := certKey{chainID: cert.ChainID, height: cert.Height, blockID: cert.BlockHash}
|
||||
m.mu.Lock()
|
||||
m.certs[k] = cert
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Lookup returns the cert for the finalized position, or (nil, false).
|
||||
func (m *MemCertStore) Lookup(chainID uint32, height uint64, blockID [32]byte) (*qcert.ConsensusCert, bool) {
|
||||
k := certKey{chainID: chainID, height: height, blockID: blockID}
|
||||
m.mu.RLock()
|
||||
c, ok := m.certs[k]
|
||||
m.mu.RUnlock()
|
||||
return c, ok
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package quasar
|
||||
|
||||
import (
|
||||
qcert "github.com/luxfi/consensus/protocol/quasar"
|
||||
)
|
||||
|
||||
// ValidatorSetProvider resolves the committed validator set the verifier pins a
|
||||
// cert against, for a (chain, epoch). Post-activation the gate calls this once
|
||||
// per verified checkpoint.
|
||||
type ValidatorSetProvider interface {
|
||||
ValidatorSet(chainID uint32, epoch uint64) (qcert.ConsensusValidatorSet, error)
|
||||
}
|
||||
|
||||
// ValidatorSet is a concrete ConsensusValidatorSet for one epoch: the committed
|
||||
// weighted-validator-set root plus the per-leg verification keys the cert legs
|
||||
// verify against (the classical BLS aggregate key for the Beam leg and the
|
||||
// Pulsar ML-DSA threshold group key for the Pulsar leg — the HYBRID_PQ pair).
|
||||
//
|
||||
// Production wiring (the activation seam): in production these fields are
|
||||
// populated from the P-Chain-pinned validator set (Root, Epoch) and the active
|
||||
// KeyEra group keys for the epoch. That population is era/rotation-coupled and
|
||||
// lands with the producer + KeyEra-registry wiring (see producer.go). Corona
|
||||
// (STRICT_DUAL_PQ) and Magnetar/P3Q (POLARIS / RECOVERY) group keys + the
|
||||
// weighted-sig-set config are populated by that same follow-on; until then this
|
||||
// set serves the HYBRID_PQ pair and reports "no key" for the other lanes.
|
||||
type ValidatorSet struct {
|
||||
root [48]byte
|
||||
epoch uint64
|
||||
blsAggKey []byte // classical BLS-12-381 aggregate pubkey (Beam leg)
|
||||
pulsarGroup []byte // Pulsar ML-DSA threshold group pubkey (Pulsar leg)
|
||||
}
|
||||
|
||||
var _ qcert.ConsensusValidatorSet = (*ValidatorSet)(nil)
|
||||
|
||||
// NewValidatorSet builds a committed validator set for one epoch from its root
|
||||
// and the HYBRID_PQ verification keys.
|
||||
func NewValidatorSet(root [48]byte, epoch uint64, blsAggKey, pulsarGroup []byte) *ValidatorSet {
|
||||
return &ValidatorSet{root: root, epoch: epoch, blsAggKey: blsAggKey, pulsarGroup: pulsarGroup}
|
||||
}
|
||||
|
||||
// Root returns the 48-byte weighted-validator-set commitment.
|
||||
func (v *ValidatorSet) Root() [48]byte { return v.root }
|
||||
|
||||
// Epoch returns the epoch this set was committed under.
|
||||
func (v *ValidatorSet) Epoch() uint64 { return v.epoch }
|
||||
|
||||
// WeightedConfig returns the QuorumVerifierConfig for the WeightedSigSet
|
||||
// evidence mode. HYBRID_PQ does not use weighted-sig-set legs; the zero config
|
||||
// is correct here and is populated by the POLARIS / RECOVERY follow-on.
|
||||
func (v *ValidatorSet) WeightedConfig() qcert.QuorumVerifierConfig {
|
||||
return qcert.QuorumVerifierConfig{}
|
||||
}
|
||||
|
||||
// WeightedEnvelope returns the round-digest posture axes for the inner
|
||||
// WeightedQuorumCert. Zero for HYBRID_PQ (no weighted-sig-set leg); populated by
|
||||
// the POLARIS / RECOVERY follow-on.
|
||||
func (v *ValidatorSet) WeightedEnvelope() qcert.QuorumMessageEnvelope {
|
||||
return qcert.QuorumMessageEnvelope{}
|
||||
}
|
||||
|
||||
// ThresholdGroupKey returns the threshold-signature group public key for a leg
|
||||
// kind. Serves the Pulsar (ML-DSA) lane; reports (zero, false) for the others
|
||||
// until their group keys are wired by the follow-on.
|
||||
func (v *ValidatorSet) ThresholdGroupKey(kind qcert.LegKind) (qcert.ThresholdGroupKey, bool) {
|
||||
if kind == qcert.LegPulsarMLDSA && len(v.pulsarGroup) > 0 {
|
||||
return qcert.ThresholdGroupKey{Kind: qcert.LegPulsarMLDSA, PulsarGroupKey: v.pulsarGroup}, true
|
||||
}
|
||||
return qcert.ThresholdGroupKey{}, false
|
||||
}
|
||||
|
||||
// ClassicalAggregateKey returns the classical aggregate verification key for a
|
||||
// scheme. Serves the BLS-12-381 Beam leg.
|
||||
func (v *ValidatorSet) ClassicalAggregateKey(scheme qcert.ClassicalScheme) ([]byte, bool) {
|
||||
if scheme == qcert.ClassicalSchemeBLS12381 && len(v.blsAggKey) > 0 {
|
||||
return v.blsAggKey, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// StaticValidatorSetProvider returns the same committed set for every (chain,
|
||||
// epoch). It is the single-era / test provider; the production provider resolves
|
||||
// per-epoch sets from the P-Chain validator manager + KeyEra registry.
|
||||
type StaticValidatorSetProvider struct{ Set qcert.ConsensusValidatorSet }
|
||||
|
||||
// ValidatorSet implements ValidatorSetProvider.
|
||||
func (p StaticValidatorSetProvider) ValidatorSet(_ uint32, _ uint64) (qcert.ConsensusValidatorSet, error) {
|
||||
if p.Set == nil {
|
||||
return nil, ErrValidatorSetUnavailable
|
||||
}
|
||||
return p.Set, nil
|
||||
}
|
||||
@@ -26,7 +26,7 @@ require (
|
||||
github.com/huin/goupnp v1.3.0
|
||||
github.com/jackpal/gateway v1.1.1
|
||||
github.com/jackpal/go-nat-pmp v1.0.2
|
||||
github.com/luxfi/consensus v1.25.36
|
||||
github.com/luxfi/consensus v1.31.1
|
||||
github.com/luxfi/crypto v1.19.26
|
||||
github.com/luxfi/database v1.20.4
|
||||
github.com/luxfi/ids v1.2.15
|
||||
@@ -190,18 +190,20 @@ require (
|
||||
github.com/hanzos3/go-sdk v1.0.2 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/luxfi/age v1.5.0 // indirect
|
||||
github.com/luxfi/corona v0.7.9 // indirect
|
||||
github.com/luxfi/corona v0.10.3 // indirect
|
||||
github.com/luxfi/crypto/ipa v1.2.4 // indirect
|
||||
github.com/luxfi/dkg v0.3.5 // indirect
|
||||
github.com/luxfi/kms v1.11.7 // indirect
|
||||
github.com/luxfi/lattice/v7 v7.1.4 // indirect
|
||||
github.com/luxfi/lens v0.1.4 // indirect
|
||||
github.com/luxfi/magnetar v1.2.3 // indirect
|
||||
github.com/luxfi/mdns v0.1.1 // indirect
|
||||
github.com/luxfi/mlwe v0.2.1 // indirect
|
||||
github.com/luxfi/pq v1.0.3 // indirect
|
||||
github.com/luxfi/precompile v0.16.0 // indirect
|
||||
github.com/luxfi/pulsar v1.1.5 // indirect
|
||||
github.com/luxfi/pulsar v1.8.0 // indirect
|
||||
github.com/luxfi/staking v1.5.1 // indirect
|
||||
github.com/luxfi/threshold v1.9.9 // indirect
|
||||
github.com/luxfi/threshold v1.10.2 // indirect
|
||||
github.com/luxfi/trace v1.1.0 // indirect
|
||||
github.com/luxfi/zapcodec v1.0.1 // indirect
|
||||
github.com/luxfi/zapdb v1.10.1 // indirect
|
||||
|
||||
@@ -314,20 +314,24 @@ github.com/luxfi/compress v0.0.5 h1:4tEUHw5MK1bu5UOjfYCt4OKMiH7yykIgmGPRA/BfJTM=
|
||||
github.com/luxfi/compress v0.0.5/go.mod h1:Cc1yxD2pfzrvpO32W2GDwLKff+CylHEvzZh2Ko8RSIU=
|
||||
github.com/luxfi/concurrent v0.0.3 h1:eJyv1fhaC0jMLMw6+QS774cUmp7GK+ouMgvLCqnC7cc=
|
||||
github.com/luxfi/concurrent v0.0.3/go.mod h1:Aj/FR5NpM0cB2P4Nt3+tz9+dV6V+LUW4HuMgSjwq5hw=
|
||||
github.com/luxfi/consensus v1.25.36 h1:tErdCx7FBY/JsoG2hY7HgPkpYGzDEZtgi7Vm56tTRd4=
|
||||
github.com/luxfi/consensus v1.25.36/go.mod h1:cerfisfzmUJv8gbMcjcQUSdxlLpfn/+LdLY+4D95Nw0=
|
||||
github.com/luxfi/consensus v1.31.1 h1:t9C1DjBgqvCc1QCqdaCnRG/vpm4EZKnO5UhShho3fVw=
|
||||
github.com/luxfi/consensus v1.31.1/go.mod h1:ji0BcHcryUxHQAv3JSwZRzXKj4v+zEqd92lkvsme27o=
|
||||
github.com/luxfi/constants v1.5.8 h1:iNP9AWNUcM4Tps7jYnx49CwtCWAC9mYRxJfGou2za0g=
|
||||
github.com/luxfi/constants v1.5.8/go.mod h1:Pu5jWHdnUtQRbWC43yTUjU/pbIIKMDOd2a2yroSfo48=
|
||||
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
|
||||
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
|
||||
github.com/luxfi/corona v0.7.9 h1:NQe9V/80CdKLvbaVRE2uepxvxg9KHbWfcGRKWrzLSHc=
|
||||
github.com/luxfi/corona v0.7.9/go.mod h1:SfS7xo/k4uoteEYwYy+QCMPzTU8EIEbLnbWKx5ENVCw=
|
||||
github.com/luxfi/corona v0.10.2 h1:pIS5uJq2lHnk6NFjoaBN8CvjPp9wwFHek91rV0u3jHk=
|
||||
github.com/luxfi/corona v0.10.2/go.mod h1:gx0nrRq4TB/scQm1kNU10lyRDQhKLMAYK1fIrfCrZ9s=
|
||||
github.com/luxfi/corona v0.10.3 h1:Yi1oAkW0HEsf5fvst/tUN0AjRVg6DoNHB/IC0qrFWZE=
|
||||
github.com/luxfi/corona v0.10.3/go.mod h1:xe5qRir0p+FA6eETpyGDv4LjYySg1zVB13kmHpy9x94=
|
||||
github.com/luxfi/crypto v1.19.26 h1:+aHn/L479ak2ih7s/DkBZojjuhcyHBLqu3nYT81vcrU=
|
||||
github.com/luxfi/crypto v1.19.26/go.mod h1:0DCU62kX8+zhYU2qeM07A4pifJyPkPujnUOfgc8TOFQ=
|
||||
github.com/luxfi/crypto/ipa v1.2.4 h1:6xfwhI9/HrcDkF3Ti5/NxsNQIWbwYDJmRSNIHRQ/xfU=
|
||||
github.com/luxfi/crypto/ipa v1.2.4/go.mod h1:43J6f6rcfUMrZt4cQectMOZb6Ps+fAEj8ZTPC3Kk+gE=
|
||||
github.com/luxfi/database v1.20.4 h1:WOt2GIGJxf8AFpg49odMz8DZ8RFSLDrozGhZtmorN70=
|
||||
github.com/luxfi/database v1.20.4/go.mod h1:S/LvmfzNYWVNslcEcZwDrntqUO2ksaL8ql1nRmLUA/Q=
|
||||
github.com/luxfi/dkg v0.3.5 h1:s2L2mMQaz+n9m0b0ghvoV5VZNxiwb2z4WrGugvK0udY=
|
||||
github.com/luxfi/dkg v0.3.5/go.mod h1:M+WH7GFRN+YUD851Rlnumdp0Md98kplNN8pVx65U8I8=
|
||||
github.com/luxfi/filesystem v0.0.1 h1:VZ6xMFKaAPBW/ddlMsDnI2G0VU1lV5rYaVcW5d+KwEY=
|
||||
github.com/luxfi/filesystem v0.0.1/go.mod h1:OQVSU6XNwqrr1AI+MqkID2taHUclx7NYmmr3svgttec=
|
||||
github.com/luxfi/formatting v1.0.1 h1:ZnE1rAdEUds9yAegdVdGDOBGN6hLMPOv6E03Fp8IEYo=
|
||||
@@ -368,20 +372,24 @@ github.com/luxfi/mdns v0.1.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
|
||||
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
|
||||
github.com/luxfi/metric v1.5.9 h1:UAgXMNZf5oN/XJwwuKorf8iMaCj3nyP6thHPCwkUwY4=
|
||||
github.com/luxfi/metric v1.5.9/go.mod h1:ux+w3RZQCfF1zM8MO0wAWyNj/CsDlPd2mwTGshB9vY0=
|
||||
github.com/luxfi/mlwe v0.2.1 h1:pRwTjNUUtzUxRIlMbUPpeh9DE2/NdqfS17hfdogazp4=
|
||||
github.com/luxfi/mlwe v0.2.1/go.mod h1:DD9EHTeiyh/y0KGGeqL+q9S4n8raeGiGdaG/BQPAvT0=
|
||||
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
|
||||
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
|
||||
github.com/luxfi/net v0.0.5 h1:F1lD3NsIioV0wr2V5jWc4TtMyiE/Ffo1LoeblFv3TrI=
|
||||
github.com/luxfi/net v0.0.5/go.mod h1:BEQR1HEVmkjii/F1R6vJrNUVE7wr55b4eMq9Iz5wjUw=
|
||||
github.com/luxfi/p2p v1.21.1 h1:gmz1JMDhzHIL3dQlhwIDvR4OlFuhNVfnWUl/ipYhAIo=
|
||||
github.com/luxfi/p2p v1.21.1/go.mod h1:SsNPR5fPGWWNem9plGWhSmRqyDoysJ3kPAN0zG0g3iw=
|
||||
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
|
||||
github.com/luxfi/pq v1.0.3 h1:ksw1dmfTR0dqqNMRS7BjGcprCO2Fhc+3Iiq2/NMuONw=
|
||||
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
|
||||
github.com/luxfi/precompile v0.16.0 h1:lMdKapbApcbehtAc0mkRqkHFdTTITRTo3e3ivdI63RY=
|
||||
github.com/luxfi/precompile v0.16.0/go.mod h1:nIO7c4arFTqCl3nR0BoumPn1etYY32EYExJxqwu23VA=
|
||||
github.com/luxfi/proto v1.3.5 h1:AW11rnu5xyvB7beyowoiY9uIffLOF3+eMR/a3EkK2c8=
|
||||
github.com/luxfi/proto v1.3.5/go.mod h1:ixTofGpdW1rTYr+wgTuBhAsgBv8GnWYHMLWbPNEdm7M=
|
||||
github.com/luxfi/pulsar v1.1.5 h1:v6z88L31ut5PbbFUQXzmoHoJZvXJgbM8+5ZoOk25So8=
|
||||
github.com/luxfi/pulsar v1.1.5/go.mod h1:GPm+Q9ZdX604GE687vkBQhWNA1FOBvRbhYwhbbfdi2w=
|
||||
github.com/luxfi/pulsar v1.7.1 h1:q/TVn0TWeMImm67EJZ3CMmBAGQCW+bhIX7YQGL3aYK0=
|
||||
github.com/luxfi/pulsar v1.7.1/go.mod h1:wW+V2mUVOqppN68+n5u9Qtlfcscd0vOz4acvubcSOKI=
|
||||
github.com/luxfi/pulsar v1.8.0 h1:ZWOwVQ8HcEJzWljj2blkNIRKwktngPJc05knkZtRjVM=
|
||||
github.com/luxfi/pulsar v1.8.0/go.mod h1:wW+V2mUVOqppN68+n5u9Qtlfcscd0vOz4acvubcSOKI=
|
||||
github.com/luxfi/resource v0.0.1 h1:mTh+ICWSy548GTUSSyx7V/X5dV18oEwxZeQEYGJQhD4=
|
||||
github.com/luxfi/resource v0.0.1/go.mod h1:wWpZktciYwIi6RNqA+fHwzmPrUJa7PRX7urfwT+spRE=
|
||||
github.com/luxfi/rpc v1.1.0 h1:B/PJbK399th1mHRDSufhCpVbAciZqId3LsaWhIGNWH4=
|
||||
@@ -396,8 +404,8 @@ github.com/luxfi/staking v1.5.1 h1:f9MaGnRm0xc02crDm5Qs1T2r88d3KzNkHZypAvsmAlU=
|
||||
github.com/luxfi/staking v1.5.1/go.mod h1:lT7KLaiTpdq3lg78H0gp2qSEfX9LaK1vs7w73XV/9nw=
|
||||
github.com/luxfi/sys v0.1.0 h1:M7RYOt8W4Wws7cxxsyOHe50UKMYTzIu7HYknqW4xt0Y=
|
||||
github.com/luxfi/sys v0.1.0/go.mod h1:GT8vGdYTfoqRy9/11blmRuqPPypzwrudCTHZXT+ru9M=
|
||||
github.com/luxfi/threshold v1.9.9 h1:zsEuMASTbyiLi7DkbjXBw3hsKIcqvpQt3Xu/MpZA5RQ=
|
||||
github.com/luxfi/threshold v1.9.9/go.mod h1:8zO1a2f3UMMsM1TkOVUoUbCR9h1sPhWH/ibblf13+h4=
|
||||
github.com/luxfi/threshold v1.10.2 h1:FFj6H+wd3DI/Y5J1Jb3bClCAtFi/ueItWtBoNBIdIo4=
|
||||
github.com/luxfi/threshold v1.10.2/go.mod h1:GETt6Px9vrQ19J9Mnpf5LgMXAdvJu8ijyByOp+QtD48=
|
||||
github.com/luxfi/timer v1.0.2 h1:g/odi0VQJIsrzdklJUG1thHZ/sGNnbIiVGcU6LctJm0=
|
||||
github.com/luxfi/timer v1.0.2/go.mod h1:SoaZwntYigUE3H6z1GV32YwP8QaSiAT0UiEv7iPugXg=
|
||||
github.com/luxfi/tls v1.0.3 h1:rK3nxSAxrUOOSHOZnKChwV4f6UJ+cfOl8KWJXAQx/SI=
|
||||
|
||||
@@ -578,6 +578,14 @@ func (n *Node) initNetworking(reg metric.Registerer) error {
|
||||
if !ok {
|
||||
return errInvalidTLSKey
|
||||
}
|
||||
// Publish the staking TLS private key as the node's block signer. The chain
|
||||
// manager passes this to proposervm as StakingLeafSigner so the elected
|
||||
// proposer can SIGN post-fork blocks (block.Build → key.Sign). It was
|
||||
// declared but never assigned, so proposervm received a nil signer and
|
||||
// panicked (nil pointer in key.Sign) the moment it built the first signed
|
||||
// post-fork block — mirrors avalanchego setting StakingTLSSigner from the
|
||||
// cert's private key.
|
||||
n.StakingTLSSigner = tlsKey
|
||||
|
||||
if n.Config.NetworkConfig.TLSKeyLogFile != "" {
|
||||
n.tlsKeyLogWriterCloser, err = perms.Create(n.Config.NetworkConfig.TLSKeyLogFile, perms.ReadWrite)
|
||||
|
||||
@@ -35,10 +35,17 @@ func (b *postForkBlock) Height() uint64 {
|
||||
}
|
||||
|
||||
// Accept:
|
||||
// 0) OPTIONAL post-quantum finality gate (dormant by default; see
|
||||
// consensus/quasar). Runs BEFORE the accept commits so a checkpoint that
|
||||
// cannot be PQ-certified post-activation halts WITHOUT persisting — fail
|
||||
// closed. A nil/dormant gate, or a non-checkpoint height, is a no-op.
|
||||
// 1) Sets this blocks status to Accepted.
|
||||
// 2) Persists this block in storage
|
||||
// 3) Calls Reject() on siblings of this block and their descendants.
|
||||
func (b *postForkBlock) Accept(ctx context.Context) error {
|
||||
if err := b.vm.verifyQuasarFinality(b); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.acceptOuterBlk(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+49
-1
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/luxfi/node/cache"
|
||||
"github.com/luxfi/node/cache/lru"
|
||||
"github.com/luxfi/node/cache/metercacher"
|
||||
pqfinality "github.com/luxfi/node/consensus/quasar"
|
||||
"github.com/luxfi/node/vms"
|
||||
"github.com/luxfi/runtime"
|
||||
"github.com/luxfi/timer/mockable"
|
||||
@@ -111,6 +112,41 @@ type VM struct {
|
||||
// lastAcceptedTimestampGaugeVec reports timestamps for the last-accepted
|
||||
// [postForkBlock] and its inner block.
|
||||
lastAcceptedTimestampGaugeVec metric.GaugeVec
|
||||
|
||||
// quasarGate is the OPTIONAL post-quantum finality-cert gate. nil (the
|
||||
// default) means PQ-finality verification is OFF — the accept path is
|
||||
// unchanged classical Snow. When set AND forward-dated activation is reached,
|
||||
// it requires a valid QuasarCert at every checkpoint and fails closed. See
|
||||
// consensus/quasar.
|
||||
quasarGate *pqfinality.Gate
|
||||
}
|
||||
|
||||
// SetQuasarGate installs the post-quantum finality gate. Called once at chain
|
||||
// wiring time when PQ-finality config is present; left unset (nil) otherwise so
|
||||
// the accept path stays classical. Idempotent, set before consensus starts.
|
||||
func (vm *VM) SetQuasarGate(g *pqfinality.Gate) { vm.quasarGate = g }
|
||||
|
||||
// verifyQuasarFinality is the accept-path hook for one finalized post-fork
|
||||
// block. It is nil-safe and dormant-by-default: with no gate, or pre-activation,
|
||||
// or off a checkpoint height, it returns nil and the block finalizes on the
|
||||
// classical path unchanged. Post-activation at a checkpoint it requires a valid
|
||||
// QuasarCert bound to this block and returns the verification error otherwise
|
||||
// (fail closed — the caller surfaces it from Accept).
|
||||
//
|
||||
// BlockID binds the proposervm block id (the accepted block at this layer);
|
||||
// StateRoot is left zero here (committed transitively through the block id), so
|
||||
// the cert's StateRoot binding is not cross-checked at this layer.
|
||||
func (vm *VM) verifyQuasarFinality(b *postForkBlock) error {
|
||||
// Fast path: no gate (the default) => zero cost, no block-accessor calls, no
|
||||
// checkpoint build. The classical accept path is untouched.
|
||||
if vm.quasarGate == nil {
|
||||
return nil
|
||||
}
|
||||
return vm.quasarGate.VerifyAccepted(pqfinality.Checkpoint{
|
||||
Epoch: b.PChainEpoch().Number,
|
||||
Height: b.Height(),
|
||||
BlockID: [32]byte(b.ID()),
|
||||
})
|
||||
}
|
||||
|
||||
// New performs best when [minBlkDelay] is whole seconds. This is because block
|
||||
@@ -591,7 +627,19 @@ func (vm *VM) repairAcceptedChainByHeight(ctx context.Context) error {
|
||||
}
|
||||
innerLastAccepted, err := vm.ChainVM.GetBlock(ctx, innerLastAcceptedID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get inner last accepted block: %w", err)
|
||||
// A fresh / not-yet-committed inner chain can report a last-accepted ID
|
||||
// whose block is not retrievable — e.g. the brand/feature VMs (Q/A/G/K...)
|
||||
// whose genesis references an empty parent, so GetBlock returns
|
||||
// "block 111...LpoYY: not found" even though innerLastAcceptedID is not
|
||||
// itself ids.Empty (so the guard above does not catch it). There is no
|
||||
// accepted chain to roll the proposervm height index back against, so
|
||||
// there is nothing to repair. Without this, wrapping such a chain crashes
|
||||
// the WHOLE node at init ("error creating required chain" → exit 1).
|
||||
vm.logger.Warn("proposervm: inner last-accepted block not retrievable at init; nothing to repair",
|
||||
log.Stringer("innerLastAcceptedID", innerLastAcceptedID),
|
||||
log.Err(err),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
proLastAcceptedID, err := vm.State.GetLastAccepted()
|
||||
if err == database.ErrNotFound {
|
||||
|
||||
Reference in New Issue
Block a user