mirror of
https://github.com/luxfi/proofs.git
synced 2026-10-30 15:21:05 +00:00
451 lines
15 KiB
Go
451 lines
15 KiB
Go
// Copyright (C) 2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Property-based tests for Quasar Consensus invariants.
|
|
//
|
|
// These tests verify core safety and correctness properties of the
|
|
// three-layer (BLS + Corona + ML-DSA) consensus without importing
|
|
// the full consensus package. We re-derive the critical invariants
|
|
// from first principles using only the standard library.
|
|
//
|
|
// Source specifications:
|
|
// - TLA+: ~/work/lux/proofs/tla/Quasar.tla
|
|
// - Lean4: ~/work/lux/proofs/lean/Consensus/Quasar.lean
|
|
// - Go: ~/work/lux/consensus/protocol/quasar/quasar.go
|
|
package property
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"math"
|
|
"math/rand"
|
|
"testing"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Quorum model (mirrors core.go addVoteLocked and grouped_threshold.go)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// quorumCheck returns true when signerWeight meets the quorum threshold.
|
|
// Uses cross-multiplication to avoid integer division truncation:
|
|
//
|
|
// signerWeight * quorumDen >= totalWeight * quorumNum
|
|
//
|
|
// This is the exact check from quasar core.go (addVoteLocked) and
|
|
// grouped_threshold.go (GroupQuorum / VerifyGroupedSignature).
|
|
func quorumCheck(signerWeight, totalWeight, quorumNum, quorumDen uint64) bool {
|
|
return signerWeight*quorumDen >= totalWeight*quorumNum
|
|
}
|
|
|
|
// quorumCheckDiv returns the naive division-based check for comparison:
|
|
//
|
|
// signerWeight / totalWeight >= quorumNum / quorumDen
|
|
//
|
|
// This truncates and can produce wrong answers. We test that the
|
|
// cross-multiply version is always at least as strict.
|
|
func quorumCheckDiv(signerWeight, totalWeight, quorumNum, quorumDen uint64) bool {
|
|
if totalWeight == 0 || quorumDen == 0 {
|
|
return false
|
|
}
|
|
return signerWeight/totalWeight >= quorumNum/quorumDen
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layer model (mirrors signer.IsQuantumMode / IsDualThresholdMode)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type layerSet struct {
|
|
bls bool
|
|
rt bool
|
|
mldsa bool
|
|
}
|
|
|
|
func (l layerSet) isQuantum() bool { return l.bls && l.rt && l.mldsa }
|
|
func (l layerSet) isDual() bool { return l.bls && (l.rt || l.mldsa) }
|
|
func (l layerSet) isBLSOnly() bool {
|
|
return l.bls && !l.rt && !l.mldsa
|
|
}
|
|
|
|
// allLayersQuorum checks that every enabled layer independently has quorum.
|
|
// Maps to AllLayersQuorum in Quasar.tla.
|
|
func allLayersQuorum(layers layerSet, blsW, rtW, mldsaW, total, qNum, qDen uint64) bool {
|
|
if layers.bls && !quorumCheck(blsW, total, qNum, qDen) {
|
|
return false
|
|
}
|
|
if layers.rt && !quorumCheck(rtW, total, qNum, qDen) {
|
|
return false
|
|
}
|
|
if layers.mldsa && !quorumCheck(mldsaW, total, qNum, qDen) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FPC model (mirrors protocol/wave/fpc/fpc.go Selector)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// fpcTheta computes the FPC threshold theta for a given phase, seed,
|
|
// thetaMin, and thetaMax. Deterministic PRF: SHA-256(seed || phase).
|
|
func fpcTheta(seed []byte, phase uint64, thetaMin, thetaMax float64) float64 {
|
|
h := sha256.New()
|
|
h.Write(seed)
|
|
var buf [8]byte
|
|
binary.BigEndian.PutUint64(buf[:], phase)
|
|
h.Write(buf[:])
|
|
digest := h.Sum(nil)
|
|
hashUint := binary.BigEndian.Uint64(digest[:8])
|
|
normalized := float64(hashUint) / float64(^uint64(0))
|
|
return thetaMin + normalized*(thetaMax-thetaMin)
|
|
}
|
|
|
|
// fpcAlpha computes alpha = ceil(theta * k) for a given phase.
|
|
func fpcAlpha(seed []byte, phase uint64, thetaMin, thetaMax float64, k int) int {
|
|
theta := fpcTheta(seed, phase, thetaMin, thetaMax)
|
|
return int(math.Ceil(theta * float64(k)))
|
|
}
|
|
|
|
// ===========================================================================
|
|
// TEST: BLS-Only Quorum Safety
|
|
// ===========================================================================
|
|
|
|
// TestBLSOnly_QuorumSafety verifies that in BLS-only mode, a quorum of
|
|
// f < n/3 Byzantine nodes alone can never satisfy the 2/3 quorum check.
|
|
// For all n in [4, 200], verifies that f = floor((n-1)/3) Byzantine
|
|
// nodes are insufficient.
|
|
func TestBLSOnly_QuorumSafety(t *testing.T) {
|
|
layers := layerSet{bls: true}
|
|
if !layers.isBLSOnly() {
|
|
t.Fatal("expected BLS-only mode")
|
|
}
|
|
|
|
for n := uint64(4); n <= 200; n++ {
|
|
f := (n - 1) / 3 // max Byzantine
|
|
|
|
// Byzantine alone must NOT reach quorum
|
|
if quorumCheck(f, n, 2, 3) {
|
|
t.Fatalf("n=%d f=%d: Byzantine alone reached quorum", n, f)
|
|
}
|
|
|
|
// Honest majority (n - f) must reach quorum
|
|
honest := n - f
|
|
if !quorumCheck(honest, n, 2, 3) {
|
|
t.Fatalf("n=%d f=%d honest=%d: honest majority failed quorum", n, f, honest)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// TEST: BLS + ML-DSA Dual Safety
|
|
// ===========================================================================
|
|
|
|
// TestBLSPlusMLDSA_DualSafety verifies that dual mode requires BOTH BLS
|
|
// and ML-DSA signatures to reach quorum. If either layer is missing
|
|
// quorum, finalization must fail.
|
|
func TestBLSPlusMLDSA_DualSafety(t *testing.T) {
|
|
layers := layerSet{bls: true, mldsa: true}
|
|
if !layers.isDual() {
|
|
t.Fatal("expected dual mode")
|
|
}
|
|
|
|
rng := rand.New(rand.NewSource(42))
|
|
|
|
for trial := 0; trial < 10000; trial++ {
|
|
n := uint64(rng.Intn(97)) + 4 // n in [4, 100]
|
|
f := (n - 1) / 3
|
|
|
|
// Random BLS and MLDSA signer counts
|
|
blsW := uint64(rng.Intn(int(n) + 1))
|
|
mldsaW := uint64(rng.Intn(int(n) + 1))
|
|
|
|
result := allLayersQuorum(layers, blsW, 0, mldsaW, n, 2, 3)
|
|
|
|
blsOK := quorumCheck(blsW, n, 2, 3)
|
|
mldsaOK := quorumCheck(mldsaW, n, 2, 3)
|
|
|
|
expected := blsOK && mldsaOK
|
|
if result != expected {
|
|
t.Fatalf("trial=%d n=%d blsW=%d mldsaW=%d: got %v want %v",
|
|
trial, n, blsW, mldsaW, result, expected)
|
|
}
|
|
|
|
// If Byzantine alone sign both layers, must NOT reach quorum
|
|
if allLayersQuorum(layers, f, 0, f, n, 2, 3) {
|
|
t.Fatalf("trial=%d n=%d f=%d: Byzantine alone reached dual quorum", trial, n, f)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// TEST: Quantum Mode Requires All Three
|
|
// ===========================================================================
|
|
|
|
// TestQuantum_AllThreeRequired verifies that quantum mode requires BLS AND
|
|
// Corona AND ML-DSA to all independently reach quorum. Missing any one
|
|
// layer blocks finalization.
|
|
func TestQuantum_AllThreeRequired(t *testing.T) {
|
|
layers := layerSet{bls: true, rt: true, mldsa: true}
|
|
if !layers.isQuantum() {
|
|
t.Fatal("expected quantum mode")
|
|
}
|
|
|
|
rng := rand.New(rand.NewSource(99))
|
|
|
|
for trial := 0; trial < 10000; trial++ {
|
|
n := uint64(rng.Intn(47)) + 4
|
|
blsW := uint64(rng.Intn(int(n) + 1))
|
|
rtW := uint64(rng.Intn(int(n) + 1))
|
|
mldsaW := uint64(rng.Intn(int(n) + 1))
|
|
|
|
result := allLayersQuorum(layers, blsW, rtW, mldsaW, n, 2, 3)
|
|
|
|
blsOK := quorumCheck(blsW, n, 2, 3)
|
|
rtOK := quorumCheck(rtW, n, 2, 3)
|
|
mldsaOK := quorumCheck(mldsaW, n, 2, 3)
|
|
|
|
expected := blsOK && rtOK && mldsaOK
|
|
if result != expected {
|
|
t.Fatalf("trial=%d n=%d bls=%d rt=%d mldsa=%d: got %v want %v",
|
|
trial, n, blsW, rtW, mldsaW, result, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// TEST: Single Layer Compromise Insufficient
|
|
// ===========================================================================
|
|
|
|
// TestQuantum_SingleCompromiseInsufficient verifies that compromising any
|
|
// single cryptographic layer does not allow the adversary to finalize a
|
|
// forged value. With f < n/3 Byzantine nodes and the adversary controlling
|
|
// one layer completely, the remaining two layers block forgery.
|
|
func TestQuantum_SingleCompromiseInsufficient(t *testing.T) {
|
|
for n := uint64(4); n <= 100; n++ {
|
|
f := (n - 1) / 3
|
|
|
|
// Scenario 1: BLS compromised (adversary can forge BLS sigs)
|
|
// Adversary gets full BLS quorum (n) but only f on RT and MLDSA
|
|
if allLayersQuorum(layerSet{bls: true, rt: true, mldsa: true},
|
|
n, f, f, n, 2, 3) {
|
|
t.Fatalf("n=%d: BLS compromised but forgery succeeded", n)
|
|
}
|
|
|
|
// Scenario 2: Corona compromised
|
|
if allLayersQuorum(layerSet{bls: true, rt: true, mldsa: true},
|
|
f, n, f, n, 2, 3) {
|
|
t.Fatalf("n=%d: RT compromised but forgery succeeded", n)
|
|
}
|
|
|
|
// Scenario 3: ML-DSA compromised
|
|
if allLayersQuorum(layerSet{bls: true, rt: true, mldsa: true},
|
|
f, f, n, n, 2, 3) {
|
|
t.Fatalf("n=%d: MLDSA compromised but forgery succeeded", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// TEST: Dual Layer Compromise Insufficient
|
|
// ===========================================================================
|
|
|
|
// TestQuantum_DualCompromiseInsufficient verifies that compromising any
|
|
// TWO cryptographic layers still does not allow forgery, because the
|
|
// third intact layer requires honest majority.
|
|
func TestQuantum_DualCompromiseInsufficient(t *testing.T) {
|
|
for n := uint64(4); n <= 100; n++ {
|
|
f := (n - 1) / 3
|
|
|
|
// BLS + RT compromised, MLDSA intact
|
|
if allLayersQuorum(layerSet{bls: true, rt: true, mldsa: true},
|
|
n, n, f, n, 2, 3) {
|
|
t.Fatalf("n=%d: BLS+RT compromised but forgery succeeded", n)
|
|
}
|
|
|
|
// BLS + MLDSA compromised, RT intact
|
|
if allLayersQuorum(layerSet{bls: true, rt: true, mldsa: true},
|
|
n, f, n, n, 2, 3) {
|
|
t.Fatalf("n=%d: BLS+MLDSA compromised but forgery succeeded", n)
|
|
}
|
|
|
|
// RT + MLDSA compromised, BLS intact
|
|
if allLayersQuorum(layerSet{bls: true, rt: true, mldsa: true},
|
|
f, n, n, n, 2, 3) {
|
|
t.Fatalf("n=%d: RT+MLDSA compromised but forgery succeeded", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// TEST: Cross-Multiply Never Truncates
|
|
// ===========================================================================
|
|
|
|
// TestQuorumCrossMult_NeverTruncates verifies that the cross-multiply
|
|
// quorum check is at least as strict as the naive integer-division check
|
|
// for all n in [1, 1000]. The cross-multiply form avoids truncation bugs
|
|
// that could weaken the quorum threshold.
|
|
func TestQuorumCrossMult_NeverTruncates(t *testing.T) {
|
|
for n := uint64(1); n <= 1000; n++ {
|
|
for w := uint64(0); w <= n; w++ {
|
|
crossMult := quorumCheck(w, n, 2, 3)
|
|
divBased := quorumCheckDiv(w, n, 2, 3)
|
|
|
|
// Cross-multiply must never be LESS strict than division.
|
|
// If division says no but cross-mult says yes, that is a bug.
|
|
// The reverse (division says yes, cross-mult says no) is fine;
|
|
// it means cross-mult is stricter, which is safer.
|
|
if !crossMult && divBased {
|
|
// This would mean division ACCEPTS but cross-mult REJECTS.
|
|
// Actually this is the safe direction (cross-mult is stricter).
|
|
// The dangerous case is the opposite.
|
|
continue
|
|
}
|
|
if crossMult && !divBased {
|
|
// Cross-mult accepts, division rejects.
|
|
// Verify cross-mult is correct by checking exact rational:
|
|
// w/n >= 2/3 <=> 3w >= 2n
|
|
exactOK := 3*w >= 2*n
|
|
if !exactOK {
|
|
t.Fatalf("n=%d w=%d: cross-mult accepted but 3w=%d < 2n=%d",
|
|
n, w, 3*w, 2*n)
|
|
}
|
|
}
|
|
|
|
// Verify cross-mult matches exact rational arithmetic
|
|
exact := 3*w >= 2*n
|
|
if crossMult != exact {
|
|
t.Fatalf("n=%d w=%d: crossMult=%v exact=%v", n, w, crossMult, exact)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// TEST: Epoch Pruning Memory Bounded
|
|
// ===========================================================================
|
|
|
|
// TestEpochPruning_MemoryBounded simulates 100K epochs with a retention
|
|
// window and verifies that the number of retained epochs never exceeds
|
|
// the configured bound. Maps to finalizedEpochRetention in core.go.
|
|
func TestEpochPruning_MemoryBounded(t *testing.T) {
|
|
const (
|
|
totalEpochs = 100_000
|
|
retention = uint64(6) // matches finalizedEpochRetention in core.go
|
|
)
|
|
|
|
// Simulate the epoch pruning logic from core.go finalizeQuantumEpoch
|
|
store := make(map[uint64]bool) // epoch -> present
|
|
|
|
var maxStoreSize int
|
|
|
|
for epoch := uint64(0); epoch < totalEpochs; epoch++ {
|
|
store[epoch] = true
|
|
|
|
// Prune (mirrors core.go logic)
|
|
if epoch > retention {
|
|
cutoff := epoch - retention
|
|
for e := range store {
|
|
if e < cutoff {
|
|
delete(store, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(store) > maxStoreSize {
|
|
maxStoreSize = len(store)
|
|
}
|
|
|
|
// Invariant: store size never exceeds retention + 1
|
|
if uint64(len(store)) > retention+1 {
|
|
t.Fatalf("epoch=%d: store has %d entries, exceeds bound %d",
|
|
epoch, len(store), retention+1)
|
|
}
|
|
}
|
|
|
|
// Final check: at most retention+1 epochs retained
|
|
if uint64(len(store)) > retention+1 {
|
|
t.Fatalf("final store has %d entries, exceeds bound %d",
|
|
len(store), retention+1)
|
|
}
|
|
|
|
t.Logf("100K epochs simulated, max store size: %d (bound: %d)", maxStoreSize, retention+1)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// TEST: FPC Threshold Deterministic
|
|
// ===========================================================================
|
|
|
|
// TestFPCThreshold_Deterministic verifies that the FPC threshold selector
|
|
// produces identical results for the same (seed, phase) pair across
|
|
// multiple invocations. Maps to fpc.Selector.SelectThreshold.
|
|
func TestFPCThreshold_Deterministic(t *testing.T) {
|
|
seed := []byte("deterministic-test-seed-2026")
|
|
thetaMin := 0.5
|
|
thetaMax := 0.8
|
|
k := 20
|
|
|
|
for phase := uint64(0); phase < 1000; phase++ {
|
|
a1 := fpcAlpha(seed, phase, thetaMin, thetaMax, k)
|
|
a2 := fpcAlpha(seed, phase, thetaMin, thetaMax, k)
|
|
if a1 != a2 {
|
|
t.Fatalf("phase=%d: non-deterministic alpha %d != %d", phase, a1, a2)
|
|
}
|
|
}
|
|
|
|
// Different seeds must produce different sequences (with high probability)
|
|
seedA := []byte("seed-alpha")
|
|
seedB := []byte("seed-beta")
|
|
matches := 0
|
|
for phase := uint64(0); phase < 100; phase++ {
|
|
if fpcAlpha(seedA, phase, thetaMin, thetaMax, k) ==
|
|
fpcAlpha(seedB, phase, thetaMin, thetaMax, k) {
|
|
matches++
|
|
}
|
|
}
|
|
// With k=20 and theta in [0.5,0.8], alpha ranges over ~7 values.
|
|
// Random collision rate ~ 1/7 ~ 14%. Allow up to 40% collisions.
|
|
if matches > 40 {
|
|
t.Fatalf("different seeds produced %d/100 matching alphas (too many)", matches)
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// TEST: FPC Threshold In Range
|
|
// ===========================================================================
|
|
|
|
// TestFPCThreshold_InRange verifies that theta is always within
|
|
// [theta_min, theta_max] for all phases, and alpha = ceil(theta * k)
|
|
// is within [ceil(theta_min * k), ceil(theta_max * k)].
|
|
func TestFPCThreshold_InRange(t *testing.T) {
|
|
seeds := [][]byte{
|
|
[]byte("test-seed-1"),
|
|
[]byte("test-seed-2"),
|
|
[]byte("a-very-long-seed-for-extra-entropy"),
|
|
{0x00, 0x01, 0x02, 0x03},
|
|
}
|
|
|
|
thetaMin := 0.5
|
|
thetaMax := 0.8
|
|
|
|
for _, seed := range seeds {
|
|
for phase := uint64(0); phase < 10000; phase++ {
|
|
theta := fpcTheta(seed, phase, thetaMin, thetaMax)
|
|
|
|
if theta < thetaMin || theta > thetaMax {
|
|
t.Fatalf("seed=%x phase=%d: theta=%f outside [%f, %f]",
|
|
seed, phase, theta, thetaMin, thetaMax)
|
|
}
|
|
|
|
for _, k := range []int{3, 5, 10, 20, 50, 100} {
|
|
alpha := fpcAlpha(seed, phase, thetaMin, thetaMax, k)
|
|
alphaMin := int(math.Ceil(thetaMin * float64(k)))
|
|
alphaMax := int(math.Ceil(thetaMax * float64(k)))
|
|
|
|
if alpha < alphaMin || alpha > alphaMax {
|
|
t.Fatalf("seed=%x phase=%d k=%d: alpha=%d outside [%d, %d]",
|
|
seed, phase, k, alpha, alphaMin, alphaMax)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|