mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
feat(consensus/quasar): wire Quasar PQ-finality VERIFY path (dormant, forward-dated)
Node-side integration of luxfi/consensus v1.29.0 Quasar compact-cert finality.
luxd finalizes on classical Snow every block; at CHECKPOINTS a sampled committee
QuasarCert over the finalized digest is VERIFIED. This wires the verify half as
an OPTIONAL, FORWARD-DATED, DORMANT-BY-DEFAULT check in the block-accept path.
consensus/quasar (new package):
- Gate.VerifyAccepted: the accept-path safety boundary. nil gate / zero
activation / below-activation-height / non-checkpoint => no-op (classical
Snow unchanged). Post-activation at a checkpoint => REQUIRE a valid cert
bound to the finalized block; fail closed (missing/mismatch/invalid -> error).
- ActivationConfig: forward-dated dormant switch (Height==0 => never; optional
wall-clock Time gate). The safety contract: this branch changes NOTHING about
live finality until the owner sets a real activation height.
- bindCheck: anti-replay binding (cert chain/height/block/state == finalized).
- policyStore: default posture HYBRID_PQ = Beam(BLS) ^ Pulsar (Pulsar verify
~140us < BLS, cert ~27KB); STRICT_DUAL_PQ / POLARIS configurable. The cert
can never select its own (weaker) policy (I1/I2 enforced by consensus).
- ValidatorSet/MemCertStore: the per-leg key + cert-lookup seams (HYBRID_PQ
BLS+Pulsar keys now; Corona/Magnetar/weighted-set + per-epoch resolution are
the activation-wiring follow-on).
- Producer (scaffolding): the committee-sign service contract + MaybeProduce
request site, sharing ONE checkpoint cadence with verify. nil producer =
verify-only (the default). Concrete pulsard signer is the follow-on; it needs
consensus to export the (currently package-private) cert/payload ENCODERS.
proposervm: post_fork_block.Accept calls vm.verifyQuasarFinality(b) BEFORE the
accept commits, so an un-PQ-certified checkpoint halts without persisting. nil
gate (default) => unchanged classical accept. SetQuasarGate installs it.
Tests: 12 gate tests green (dormant no-op, nil-safe, below-activation, time-gate,
non-checkpoint, missing-fails-closed, 4x mismatch/anti-replay, real-verifier
delegation, validator-set-unavailable, producer nil-safety/dormant/active). Full
proposervm suite green; go build ./... exit 0.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
// 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")
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
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-timestamp discipline
|
||||
// (a far-future / unset activation point cannot affect live finality).
|
||||
type ActivationConfig struct {
|
||||
// Height is the block height at and above which PQ-finality verification is
|
||||
// enforced at checkpoints. 0 == dormant (never).
|
||||
Height uint64
|
||||
|
||||
// Time, when non-zero, ADDITIONALLY requires the wall-clock activation
|
||||
// moment to have passed before enforcement begins (defence in depth on the
|
||||
// forward-dating). Zero == height-only gating.
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// 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 and
|
||||
// the given wall-clock now. Dormant activation is never active.
|
||||
func (a ActivationConfig) active(height uint64, now time.Time) bool {
|
||||
if a.Height == 0 {
|
||||
return false
|
||||
}
|
||||
if height < a.Height {
|
||||
return false
|
||||
}
|
||||
if !a.Time.IsZero() && now.Before(a.Time) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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, time.Now()) {
|
||||
return nil
|
||||
}
|
||||
if !g.isCheckpoint(cp.Height) {
|
||||
return nil
|
||||
}
|
||||
|
||||
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, time.Now())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
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 binds only when the cert commits it. A zero cert StateRoot means
|
||||
// "committed transitively through BlockHash" (the envelope spec), so it is
|
||||
// not cross-checked.
|
||||
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,258 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package quasar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivationTimeNotReached — height is past the activation height but the
|
||||
// forward-dated wall-clock moment has not arrived => still dormant.
|
||||
func TestActivationTimeNotReached(t *testing.T) {
|
||||
g := NewGate(Config{
|
||||
Activation: ActivationConfig{Height: 10, Time: time.Now().Add(time.Hour)},
|
||||
CheckpointInterval: 10,
|
||||
}, NewMemCertStore(), StaticValidatorSetProvider{Set: testValidators()})
|
||||
if err := g.VerifyAccepted(checkpointAt(10)); err != nil {
|
||||
t.Fatalf("pre-activation-time 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) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cert *qcert.ConsensusCert
|
||||
}{
|
||||
{"wrong block", &qcert.ConsensusCert{ChainID: 1337, Height: 20, BlockHash: [32]byte{0x99}}},
|
||||
{"wrong height", &qcert.ConsensusCert{ChainID: 1337, Height: 21, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
|
||||
{"wrong chain", &qcert.ConsensusCert{ChainID: 7, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
|
||||
{"wrong state root", &qcert.ConsensusCert{ChainID: 1337, 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)
|
||||
cert := &qcert.ConsensusCert{Version: 1, ChainID: 1337, 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}) // no set
|
||||
err := g.VerifyAccepted(cp)
|
||||
if !errors.Is(err, ErrValidatorSetUnavailable) {
|
||||
t.Fatalf("want ErrValidatorSetUnavailable, 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
|
||||
}
|
||||
Reference in New Issue
Block a user