mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
Red adversarial review of the PQ-finality gate. Dormant default verified a true no-op; these harden the ACTIVATED path before any forward-dated activation: H1 (HIGH) — bindCheck now binds cert.Epoch == cp.Epoch (and cert.Round). The gate resolves verification keys from the cert's epoch; an unbound epoch let a cert signed under a DIFFERENT validator-set era (e.g. a compromised RETIRED committee key) certify the current block, nullifying KeyEra rotation as a blast-radius bound. Honest certs match (producer signs Subject.Epoch==cp.Epoch). + wrong-epoch / wrong-round anti-replay test cases. M2 (MED) — activation is now HEIGHT-ONLY, deterministic. Removed time.Now() (and the ActivationConfig.Time field) from the accept path: wall-clock gating split finalization across validators with skewed clocks (some halting on a missing cert, others finalizing without one). A height is consensus-agreed; timestamp forward-dating is expressed by choosing the activation height. L5 (LOW) — an activated gate with a nil store/validator provider now fails closed with ErrGateMisconfigured instead of panicking in the accept hook (a panic would halt the chain uncontrollably). Off the dormant path. + test. M3 (doc) — made the StateRoot=0 transitive-through-BlockHash contract explicit in bindCheck (non-zero cert StateRoot already rejected + tested) so the producer follow-on cannot silently ship state-committing certs that self-halt. proposervm: verifyQuasarFinality short-circuits on nil gate BEFORE building the Checkpoint, so the default path makes zero block-accessor calls. 13 gate tests green under -race; full proposervm suite green; go build ./... exit 0. Deferred to follow-ons (gate is dormant + unwired in production): M4 cert- unavailability halt runbook + grace window, L6 MemCertStore eviction (no ingest caller yet), proposervm Accept-path integration test, positive valid-cert test (needs consensus to export cert encoders).
271 lines
11 KiB
Go
271 lines
11 KiB
Go
// 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)
|
|
}
|
|
}
|