mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
Split the single ⅔-stake accept threshold into two orthogonal finality-ladder tiers so a
3/5-live foundation keeps producing while certification pauses (the "survive 3/5, majority
is enough" mandate). Before, VM.Accept was gated on one ⅔ cert (bftAlpha = ⌊2n/3⌋+1); at
3/5 (60% < ⅔) that cert can never assemble and the fleet froze the instant a 2nd node dropped.
Nova = LOCAL ACCEPT. NovaQuorum(n)=⌊n/2⌋+1 distinct signers; drives VM.Accept.
Crash-fault-safe by majority intersection (stake-INDEPENDENT — do not add a
stake gate or 3/5 liveness breaks when the absent minority holds the stake).
NOT Byzantine-safe: a lone equivocator can fork a bare majority, so two Nova
certs are possible (expected, non-exportable) and equivocation HALTS fail-closed
via the ledger per-height gate (ErrHeightAlreadyFinalized) — never a silent EVM reorg.
Quasar = EXPORT. strict >⅔ of stake by distinct signers (config.TwoThirdsStakeFloor).
Byzantine-fork-safe (2α−n>f). The ONLY tier bridges / DEX 0x9999 / cross-chain /
validator-set transitions may consume.
Decomplected — one threshold per place:
- QuorumCert carries Tier (Nova|Quasar); VerifyWeighted selects the threshold by tier
(majority-of-ValidatorCount for Nova, ⅔-by-stake for Quasar). The tier is NOT bound into
the signed vote (votes are tier-agnostic), so it cannot be forged upward — VerifyWeighted
re-derives the real threshold from the authoritative set. Fails closed on an unresolved set
(n<1) so NovaQuorum(0)=1 can never let a lone node self-accept (the 1085013 fork class).
- assembleCertLocked assembles a NOVA cert (NovaQuorum) for the accept path; the zero-cert
refusal (acceptWithCertCore) and the lone-finalizer resolved-set guard are preserved.
- A trailing QuasarAttestor (post-accept, engine/chain/attestation.go) assembles the ⅔-stake
export cert from the SAME accept votes. Because the Nova cert freezes at the bare majority,
the ⅔-th stake vote necessarily follows the accept, so finalized-block votes are routed to
the attestor via a re-verified late-attestation path (handleVote / HandleIncomingVote).
- GetCertifiedTip → GetQuasarTip (highest ⅔-cert block, NEVER the Nova tip); GetNovaTip exposes
the accepted tip. PromoteQuasar (single writer of the export frontier) validates each promotion
against the Nova ledger, inheriting its single-canonical-per-height gate → no two conflicting
Quasars. FinalityStatus surfaces nova/quasar/horizon heights + responsiveStakePct +
certificateAvailable + degraded; a Nova height is never reported as certified.
- Incoming-cert threshold pre-check made tier-aware so gossiped Nova certs are not rejected.
Tests: nova_quasar_matrix_test.go covers the adversarial matrix (n∈{1..5}+weighted,
3/5-Nova-no-Quasar, Quasar-only-at-⅔, 3/5-partition, equivocation → two Nova possible but
never two Quasar, behind-node rejoin non-event, degraded RPC, export gates on Quasar). ~17
pre-existing RED safety tests re-framed from the accept tier to the export tier (a sub-⅔
coalition may Nova-accept locally but can never reach Quasar/export). Single-engine matrix is
-race clean; the in-process multinode convergence tests skip under -race (timing/FIPS-ed25519
concurrency, per the existing race_flag discipline).
Astrophysics ontology only — no new tendermint/prevote/precommit/viewchange/snowman terms.
Co-authored-by: Hanzo Dev <[email protected]>
247 lines
8.5 KiB
Go
247 lines
8.5 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// quasar_test.go — proves the engine→quasar bridge is REAL: an
|
|
// engine QuorumCert maps to a protocol/quasar.WeightedQuorumCert that quasar's
|
|
// OWN Verify accepts, AND that the bridge fails closed when the node has not
|
|
// supplied the weighted PQ validator-set material.
|
|
package chain
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/rand"
|
|
"testing"
|
|
|
|
"github.com/luxfi/consensus/config"
|
|
"github.com/luxfi/consensus/protocol/quasar"
|
|
"github.com/luxfi/crypto/mldsa"
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
// mldsaWitnessSource is a faithful CryptoWitnessSource: it owns a real weighted
|
|
// validator set with ML-DSA-65 keys, and for each engine voter it produces a
|
|
// quasar signer record (leaf fields + Merkle path + a REAL ML-DSA signature
|
|
// over the quasar consensus message). This is what the node layer implements
|
|
// once its PQ validator set is plumbed — here with real FIPS-204 keys so
|
|
// quasar.Verify exercises the genuine predicate.
|
|
type mldsaWitnessSource struct {
|
|
epoch uint64
|
|
set *quasar.WeightedValidatorSet
|
|
envelope quasar.QuorumMessageEnvelope
|
|
threshold uint64
|
|
// byNode maps an engine NodeID → its leaf index + ML-DSA private key.
|
|
idx map[ids.NodeID]int
|
|
priv map[ids.NodeID]*mldsa.PrivateKey
|
|
pub map[ids.NodeID][]byte
|
|
}
|
|
|
|
func newMLDSAWitnessSource(t *testing.T, nodeIDs []ids.NodeID, epoch uint64) *mldsaWitnessSource {
|
|
t.Helper()
|
|
src := &mldsaWitnessSource{
|
|
epoch: epoch,
|
|
threshold: uint64(len(nodeIDs)), // require all (test sets weight=1 each)
|
|
idx: make(map[ids.NodeID]int),
|
|
priv: make(map[ids.NodeID]*mldsa.PrivateKey),
|
|
pub: make(map[ids.NodeID][]byte),
|
|
}
|
|
leaves := make([]quasar.WeightedValidatorLeaf, 0, len(nodeIDs))
|
|
for i, nodeID := range nodeIDs {
|
|
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
|
|
if err != nil {
|
|
t.Fatalf("mldsa keygen: %v", err)
|
|
}
|
|
pubBytes := priv.Public().(*mldsa.PublicKey).Bytes()
|
|
var vid [32]byte
|
|
copy(vid[:], nodeID[:])
|
|
leaves = append(leaves, quasar.WeightedValidatorLeaf{
|
|
ValidatorID: vid,
|
|
PublicKey: pubBytes,
|
|
VotingWeight: 1,
|
|
ParameterSetID: uint8(quasar.QuorumSchemeMLDSA65),
|
|
KeyVersion: 0,
|
|
})
|
|
src.idx[nodeID] = i
|
|
src.priv[nodeID] = priv
|
|
src.pub[nodeID] = pubBytes
|
|
}
|
|
set, err := quasar.BuildWeightedValidatorSet(epoch, leaves)
|
|
if err != nil {
|
|
t.Fatalf("build weighted set: %v", err)
|
|
}
|
|
src.set = set
|
|
// The set sorts leaves by ValidatorID internally; remap each nodeID to its
|
|
// actual leaf index in the SORTED set so inclusion proofs are for the right
|
|
// position.
|
|
sortedLeaves := set.Leaves()
|
|
for nodeID := range src.idx {
|
|
var vid [32]byte
|
|
copy(vid[:], nodeID[:])
|
|
for li := range sortedLeaves {
|
|
if sortedLeaves[li].ValidatorID == vid {
|
|
src.idx[nodeID] = li
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// Minimal envelope: pin the direct weighted-quorum backend + ML-DSA scheme.
|
|
src.envelope = quasar.QuorumMessageEnvelope{
|
|
ProfileID: 0xC57E0001,
|
|
HashSuite: config.HashSuiteSHA3NIST,
|
|
IdentityScheme: config.IdentitySchemeMLDSA65,
|
|
FinalityScheme: config.SigSchemePulsar65,
|
|
ProofPolicy: config.ProofPolicySTARKFRISHA3PQ,
|
|
ProofBackend: config.ProofBackendDirectWeightedQuorum,
|
|
ProofFormat: config.ProofFormatDirectWeightedQuorumV1,
|
|
VerifierID: config.VerifierDirectWeightedQuorumPQ,
|
|
EffectivePolicy: 1,
|
|
NetworkID: 0xC0DE0001,
|
|
}
|
|
return src
|
|
}
|
|
|
|
func (s *mldsaWitnessSource) Epoch(uint64) uint64 { return s.epoch }
|
|
|
|
func (s *mldsaWitnessSource) ValidatorSetRoot(epoch uint64) ([48]byte, bool) {
|
|
if epoch != s.epoch {
|
|
return [48]byte{}, false
|
|
}
|
|
return s.set.Root(), true
|
|
}
|
|
|
|
func (s *mldsaWitnessSource) QuorumThreshold(epoch uint64) (uint64, bool) {
|
|
if epoch != s.epoch {
|
|
return 0, false
|
|
}
|
|
return s.threshold, true
|
|
}
|
|
|
|
func (s *mldsaWitnessSource) SignerRecord(nodeID ids.NodeID, _ []byte, _ []byte) (quasar.QuorumSignerRecord, bool) {
|
|
i, ok := s.idx[nodeID]
|
|
if !ok {
|
|
return quasar.QuorumSignerRecord{}, false
|
|
}
|
|
proof, err := s.set.InclusionProof(i)
|
|
if err != nil {
|
|
return quasar.QuorumSignerRecord{}, false
|
|
}
|
|
var vid [32]byte
|
|
copy(vid[:], nodeID[:])
|
|
return quasar.QuorumSignerRecord{
|
|
ValidatorID: vid,
|
|
PublicKey: s.pub[nodeID],
|
|
VotingWeight: 1,
|
|
Scheme: quasar.QuorumSchemeMLDSA65,
|
|
ParamSetID: uint8(quasar.QuorumSchemeMLDSA65),
|
|
KeyVersion: 0,
|
|
MerklePath: proof,
|
|
// Signature filled by signQuasar (needs the cert position → message).
|
|
}, true
|
|
}
|
|
|
|
// signQuasarRecords fills each record's Signature with a REAL ML-DSA signature
|
|
// over the quasar consensus message for the assembled cert. In production the
|
|
// node does this when the validator participates; the test does it explicitly
|
|
// so quasar.Verify sees genuine FIPS signatures.
|
|
func (s *mldsaWitnessSource) signQuasarRecords(t *testing.T, cert *quasar.WeightedQuorumCert) {
|
|
t.Helper()
|
|
msg, err := quasar.QuorumMessageForCert(s.envelope, cert)
|
|
if err != nil {
|
|
t.Fatalf("quasar message: %v", err)
|
|
}
|
|
for i := range cert.Signers {
|
|
var nodeID ids.NodeID
|
|
copy(nodeID[:], cert.Signers[i].ValidatorID[:ids.NodeIDLen])
|
|
priv := s.priv[nodeID]
|
|
if priv == nil {
|
|
t.Fatalf("no key for signer %d", i)
|
|
}
|
|
sig, err := priv.Sign(rand.Reader, msg, crypto.Hash(0))
|
|
if err != nil {
|
|
t.Fatalf("mldsa sign: %v", err)
|
|
}
|
|
cert.Signers[i].Signature = sig
|
|
}
|
|
// Re-stamp the signer commitment is NOT needed (sigs are not committed); the
|
|
// commitment binds ids+positions which are unchanged.
|
|
}
|
|
|
|
// TestQuasarBridge_RealCertVerifies proves the wiring end to end: build an
|
|
// engine QuorumCert, upgrade it to a quasar.WeightedQuorumCert via the bridge,
|
|
// fill real ML-DSA signatures, and verify with quasar's OWN Verify.
|
|
func TestQuasarBridge_RealCertVerifies(t *testing.T) {
|
|
vs := newTestValidatorSet(3) // 3 engine voters
|
|
nodeIDs := []ids.NodeID{vs.nodeID(0), vs.nodeID(1), vs.nodeID(2)}
|
|
src := newMLDSAWitnessSource(t, nodeIDs, 7)
|
|
|
|
chainID := ids.GenerateTestID()
|
|
blockID := ids.GenerateTestID()
|
|
pos := VotePosition{ChainID: chainID, Height: 100, Round: 0, BlockID: blockID, ParentID: ids.Empty}
|
|
|
|
// Engine cert with 3 distinct signed (engine-auth) accept votes.
|
|
engineCert, err := AssembleQuorumCert(pos, Quasar, 3, []SignedVote{
|
|
{NodeID: nodeIDs[0], Accept: true, Signature: vs.sign(0, pos)},
|
|
{NodeID: nodeIDs[1], Accept: true, Signature: vs.sign(1, pos)},
|
|
{NodeID: nodeIDs[2], Accept: true, Signature: vs.sign(2, pos)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("assemble engine cert: %v", err)
|
|
}
|
|
if err := engineCert.Verify(vs, engineCert.Position.Height); err != nil {
|
|
t.Fatalf("engine cert must verify: %v", err)
|
|
}
|
|
|
|
// Bridge → quasar cert.
|
|
qcert, err := engineCert.ToQuasarCert(src)
|
|
if err != nil {
|
|
t.Fatalf("ToQuasarCert: %v", err)
|
|
}
|
|
|
|
// Position mapping must be faithful.
|
|
if qcert.Height != pos.Height || qcert.Round != pos.Round {
|
|
t.Fatalf("quasar cert position mismatch: height=%d round=%d", qcert.Height, qcert.Round)
|
|
}
|
|
if qcert.ValueHash != idTo32(blockID) {
|
|
t.Fatal("quasar cert value hash must equal the engine block id")
|
|
}
|
|
if qcert.QuorumThreshold != src.threshold {
|
|
t.Fatalf("quasar cert threshold mismatch: %d", qcert.QuorumThreshold)
|
|
}
|
|
if uint32(len(qcert.Signers)) != qcert.SignerCount || qcert.SignerCount != 3 {
|
|
t.Fatalf("quasar cert must carry 3 signer records, got %d", qcert.SignerCount)
|
|
}
|
|
|
|
// Fill real ML-DSA signatures and verify with quasar's own predicate.
|
|
src.signQuasarRecords(t, qcert)
|
|
cfg := quasar.QuorumVerifierConfig{
|
|
AllowedSchemes: map[quasar.QuorumSchemeID]bool{quasar.QuorumSchemeMLDSA65: true},
|
|
MinThreshold: src.threshold,
|
|
}
|
|
if err := qcert.Verify(src.envelope, cfg); err != nil {
|
|
t.Fatalf("BRIDGE BROKEN: quasar.Verify rejected the bridged cert: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestQuasarBridge_FailsClosedWithoutMaterial proves the bridge NEVER fabricates
|
|
// a cert: nil source, missing root/threshold, and out-of-set voters all error.
|
|
func TestQuasarBridge_FailsClosedWithoutMaterial(t *testing.T) {
|
|
vs := newTestValidatorSet(1)
|
|
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
|
|
cert, err := AssembleQuorumCert(pos, Quasar, 1, []SignedVote{
|
|
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("assemble: %v", err)
|
|
}
|
|
|
|
// (a) nil source → fail closed.
|
|
if _, err := cert.ToQuasarCert(nil); err == nil {
|
|
t.Fatal("ToQuasarCert(nil) must fail closed")
|
|
}
|
|
|
|
// (b) source missing this voter (out-of-set) → fail closed, no cert.
|
|
other := newMLDSAWitnessSource(t, []ids.NodeID{ids.GenerateTestNodeID()}, 1)
|
|
if _, err := cert.ToQuasarCert(other); err == nil {
|
|
t.Fatal("ToQuasarCert with an out-of-set voter must fail closed")
|
|
}
|
|
}
|