Files
zeekayandHanzo Dev a664c82534 engine/chain: v1.36 two-tier accept — Nova (majority) accept vs Quasar (⅔-stake) export
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]>
2026-07-10 03:58:20 -07:00

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")
}
}