Merge branch 'fix/consensus-v1.31.1-downleader'

This commit is contained in:
zeekay
2026-06-30 13:38:32 -07:00
17 changed files with 1331 additions and 19 deletions
+95 -3
View File
@@ -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/21. 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)),
+73
View File
@@ -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
View File
@@ -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
+172
View File
@@ -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")
}
+51
View File
@@ -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.
+38
View File
@@ -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")
)
+268
View File
@@ -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
}
+270
View File
@@ -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)
}
}
+27
View File
@@ -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
}
+83
View File
@@ -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))
}
+61
View File
@@ -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
}
+94
View File
@@ -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
}
+6 -4
View File
@@ -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
+17 -9
View File
@@ -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=
+8
View File
@@ -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)
+7
View File
@@ -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
View File
@@ -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 {