consensus: add 19 inversion tests for slashing, sampling, FPC

- Slashing: forged evidence, duplicate, exponential jail, concurrency
- StakeWeightedCut: chi-squared 50K samples, dominant validator, overflow
- FPC: seed determinism, epoch derivation, threshold range bounds
This commit is contained in:
2026-03-02 23:59:00 -08:00
parent 838c8d5eef
commit 2e16529017
4 changed files with 503 additions and 0 deletions
+204
View File
@@ -1,6 +1,7 @@
package slashing
import (
"sync"
"testing"
"time"
@@ -384,3 +385,206 @@ func TestPopcount(t *testing.T) {
}
}
}
// --- Inversion tests: evidence must be rejected when invalid ---
// TestDB_DuplicateEvidence_NoDoubleSlash verifies that recording evidence
// for the same validator at the same height twice increments the slash count
// each time (the DB does not deduplicate -- that is the caller's responsibility
// via the Detector). This documents the current behavior: the DB is append-only.
// If deduplication is added later, this test should be updated to verify it.
func TestDB_DuplicateEvidence_NoDoubleSlash(t *testing.T) {
db := NewDB(1 * time.Hour)
v := nodeID(1)
ev := Evidence{
Type: DoubleVote,
ValidatorID: v,
Height: 100,
Timestamp: time.Now(),
Proof: []byte("proof-1"),
}
rec1 := db.RecordEvidence(ev)
if rec1.SlashCount != 1 {
t.Fatalf("expected slash count 1, got %d", rec1.SlashCount)
}
// Same evidence again -- DB currently allows it (caller must dedup).
rec2 := db.RecordEvidence(ev)
if rec2.SlashCount != 2 {
t.Fatalf("expected slash count 2 (DB does not dedup), got %d", rec2.SlashCount)
}
if len(rec2.Evidence) != 2 {
t.Fatalf("expected 2 evidence entries, got %d", len(rec2.Evidence))
}
}
// TestDB_NonExistentValidator verifies that querying a validator with no
// slashing history returns nil/false.
func TestDB_NonExistentValidator(t *testing.T) {
db := NewDB(1 * time.Hour)
unknown := nodeID(42)
if db.IsJailed(unknown) {
t.Fatal("non-existent validator must not be jailed")
}
rec := db.GetRecord(unknown)
if rec != nil {
t.Fatal("non-existent validator must have nil record")
}
}
// TestDB_JailDurationExponential verifies jail duration doubles with each
// successive slash: base, 2*base, 4*base, 8*base.
func TestDB_JailDurationExponential(t *testing.T) {
base := 1 * time.Hour
db := NewDB(base)
v := nodeID(1)
now := time.Now()
for slash := uint32(1); slash <= 4; slash++ {
ev := Evidence{
Type: DoubleVote,
ValidatorID: v,
Height: uint64(slash * 100),
Timestamp: now,
Proof: []byte{byte(slash)},
}
rec := db.RecordEvidence(ev)
if rec.SlashCount != slash {
t.Fatalf("slash %d: expected count %d, got %d", slash, slash, rec.SlashCount)
}
// Expected jail: base * 2^(slash-1)
expectedDuration := base
for i := uint32(1); i < slash; i++ {
expectedDuration *= 2
}
actualJail := rec.JailedUntil.Sub(now)
// Allow 1 second drift from time.Now() inside RecordEvidence
tolerance := 1 * time.Second
if actualJail < expectedDuration-tolerance || actualJail > expectedDuration+tolerance {
t.Fatalf("slash %d: expected jail ~%v, got %v", slash, expectedDuration, actualJail)
}
}
}
// TestDB_UnjailedValidatorCanBeReslashed verifies a validator whose jail
// expired can be slashed again, incrementing the slash count.
func TestDB_UnjailedValidatorCanBeReslashed(t *testing.T) {
db := NewDB(1 * time.Millisecond) // very short base jail
v := nodeID(1)
ev1 := Evidence{Type: DoubleVote, ValidatorID: v, Height: 100, Timestamp: time.Now(), Proof: []byte("1")}
db.RecordEvidence(ev1)
if !db.IsJailed(v) {
t.Fatal("should be jailed after first slash")
}
// Wait for jail to expire
time.Sleep(5 * time.Millisecond)
if db.IsJailed(v) {
t.Fatal("jail should have expired")
}
// Re-slash
ev2 := Evidence{Type: DoubleSign, ValidatorID: v, Height: 200, Timestamp: time.Now(), Proof: []byte("2")}
rec := db.RecordEvidence(ev2)
if rec.SlashCount != 2 {
t.Fatalf("expected slash count 2 after re-slash, got %d", rec.SlashCount)
}
if !db.IsJailed(v) {
t.Fatal("should be jailed again after re-slash")
}
}
// TestDB_ConcurrentEvidenceSubmission verifies that concurrent evidence
// submissions do not corrupt the DB state (race safety).
func TestDB_ConcurrentEvidenceSubmission(t *testing.T) {
db := NewDB(1 * time.Hour)
v := nodeID(1)
const goroutines = 50
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func(idx int) {
defer wg.Done()
ev := Evidence{
Type: DoubleVote,
ValidatorID: v,
Height: uint64(idx),
Timestamp: time.Now(),
Proof: []byte{byte(idx)},
}
db.RecordEvidence(ev)
}(i)
}
wg.Wait()
rec := db.GetRecord(v)
if rec == nil {
t.Fatal("expected non-nil record after concurrent submissions")
}
if rec.SlashCount != goroutines {
t.Fatalf("expected %d slashes, got %d", goroutines, rec.SlashCount)
}
if len(rec.Evidence) != goroutines {
t.Fatalf("expected %d evidence entries, got %d", goroutines, len(rec.Evidence))
}
}
// TestDetector_ConcurrentCheckVote verifies the Detector is race-safe
// under concurrent vote checking.
func TestDetector_ConcurrentCheckVote(t *testing.T) {
d := NewDetector(64, 0.5)
const goroutines = 50
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func(idx int) {
defer wg.Done()
v := nodeID(byte(idx))
b := blockID(byte(idx))
d.CheckVote(v, 100, b, true)
}(i)
}
wg.Wait()
// Verify state is consistent: each validator should have exactly one
// vote recorded at height 100.
d.mu.Lock()
defer d.mu.Unlock()
if len(d.votes[100]) != goroutines {
t.Fatalf("expected %d validators in votes map, got %d", goroutines, len(d.votes[100]))
}
}
// TestCheckVote_ForgedEvidence_WrongValidator ensures that evidence produced
// by one validator cannot be "attributed" to another validator. The detector
// associates evidence with the validatorID passed to CheckVote, so an attacker
// must actually observe the victim voting.
func TestCheckVote_ForgedEvidence_WrongValidator(t *testing.T) {
d := NewDetector(64, 0.5)
// Honest validator votes for block A
honest := nodeID(1)
d.CheckVote(honest, 100, blockID(0xAA), true)
// Attacker is a different validator
attacker := nodeID(2)
ev := d.CheckVote(attacker, 100, blockID(0xBB), true)
if ev != nil {
t.Fatal("voting by different validators must not produce evidence")
}
// The honest validator did NOT double-vote
ev = d.CheckVote(honest, 100, blockID(0xAA), true)
if ev != nil {
t.Fatal("re-submitting same vote must not produce evidence")
}
}
+171
View File
@@ -4,6 +4,7 @@
package prism
import (
"math"
"testing"
"github.com/luxfi/ids"
@@ -111,6 +112,176 @@ func TestStakeWeightedCutStakeDistribution(t *testing.T) {
require.Greater(ratio, 0.90, "heavy node should be selected proportional to weight, got ratio=%f", ratio)
}
// TestStakeWeightedCut1000ValidatorsChiSquared verifies that the sampling
// distribution across 1000 validators statistically matches their stake
// weights using a chi-squared goodness-of-fit test.
func TestStakeWeightedCut1000ValidatorsChiSquared(t *testing.T) {
require := require.New(t)
const numValidators = 1000
validators := make([]Validator, numValidators)
var totalWeight uint64
for i := range validators {
w := uint64(i + 1) // weights 1..1000
validators[i] = Validator{
ID: ids.GenerateTestNodeID(),
Weight: w,
}
totalWeight += w
}
cut, err := NewStakeWeightedCut(validators)
require.NoError(err)
// Run many single-sample trials.
const trials = 50000
counts := make(map[ids.NodeID]int, numValidators)
for i := 0; i < trials; i++ {
nodes := cut.Sample(1)
require.Len(nodes, 1)
counts[nodes[0]]++
}
// Chi-squared: sum of (observed - expected)^2 / expected
var chiSq float64
for _, v := range validators {
expected := float64(trials) * float64(v.Weight) / float64(totalWeight)
observed := float64(counts[v.ID])
chiSq += (observed - expected) * (observed - expected) / expected
}
// Degrees of freedom = numValidators - 1 = 999.
// At alpha=0.001, critical value for df=999 is ~1119.
// This is a very lenient test -- we just want to catch gross errors.
criticalValue := 1200.0
require.Less(chiSq, criticalValue,
"chi-squared %.2f exceeds critical value %.2f -- sampling is not proportional to weight",
chiSq, criticalValue)
}
// TestStakeWeightedCutDominantValidator verifies that a validator with 99%
// of the total stake is sampled ~99% of the time in single-sample draws.
func TestStakeWeightedCutDominantValidator(t *testing.T) {
require := require.New(t)
dominant := Validator{ID: ids.GenerateTestNodeID(), Weight: 99_000}
validators := []Validator{dominant}
for i := 0; i < 100; i++ {
validators = append(validators, Validator{
ID: ids.GenerateTestNodeID(),
Weight: 10, // 1000 total from others = 1%
})
}
cut, err := NewStakeWeightedCut(validators)
require.NoError(err)
hits := 0
const trials = 20000
for i := 0; i < trials; i++ {
nodes := cut.Sample(1)
require.Len(nodes, 1)
if nodes[0] == dominant.ID {
hits++
}
}
ratio := float64(hits) / float64(trials)
require.Greater(ratio, 0.95, "dominant validator (99%% stake) sampled only %.2f%% of the time", ratio*100)
}
// TestStakeWeightedCutKExceedsN verifies that requesting more samples than
// validators returns all validators exactly once.
func TestStakeWeightedCutKExceedsN(t *testing.T) {
require := require.New(t)
validators := make([]Validator, 5)
idSet := make(map[ids.NodeID]bool)
for i := range validators {
validators[i] = Validator{ID: ids.GenerateTestNodeID(), Weight: uint64(10 * (i + 1))}
idSet[validators[i].ID] = true
}
cut, err := NewStakeWeightedCut(validators)
require.NoError(err)
// k=10 > n=5 -- should get all 5
nodes := cut.Sample(10)
require.Len(nodes, 5)
// k=100 > n=5 -- still all 5
nodes = cut.Sample(100)
require.Len(nodes, 5)
// Verify all returned nodes are valid
for _, n := range nodes {
require.True(idSet[n], "returned unknown node %s", n)
}
}
// TestStakeWeightedCutEqualWeight verifies that equal-weight validators
// are sampled approximately uniformly.
func TestStakeWeightedCutEqualWeight(t *testing.T) {
require := require.New(t)
const n = 20
validators := make([]Validator, n)
for i := range validators {
validators[i] = Validator{ID: ids.GenerateTestNodeID(), Weight: 100}
}
cut, err := NewStakeWeightedCut(validators)
require.NoError(err)
const trials = 40000
counts := make(map[ids.NodeID]int, n)
for i := 0; i < trials; i++ {
nodes := cut.Sample(1)
require.Len(nodes, 1)
counts[nodes[0]]++
}
expected := float64(trials) / float64(n) // 2000 per validator
for _, v := range validators {
observed := float64(counts[v.ID])
// Each should be within 40% of expected (generous for randomness).
require.InDelta(expected, observed, expected*0.4,
"validator %s: expected ~%.0f samples, got %.0f", v.ID, expected, observed)
}
}
// TestStakeWeightedCutMaxUint64Weight verifies no overflow when a validator
// has weight near uint64 max.
func TestStakeWeightedCutMaxUint64Weight(t *testing.T) {
require := require.New(t)
// Two validators: one near max, one with weight 1.
// Total weight would overflow naive uint64 addition, but our implementation
// adds sequentially -- let's verify it doesn't panic or loop forever.
largeWeight := uint64(math.MaxUint64 / 2)
v1 := Validator{ID: ids.GenerateTestNodeID(), Weight: largeWeight}
v2 := Validator{ID: ids.GenerateTestNodeID(), Weight: 1}
cut, err := NewStakeWeightedCut([]Validator{v1, v2})
require.NoError(err)
// Sample should not panic or infinite loop
for i := 0; i < 100; i++ {
nodes := cut.Sample(1)
require.Len(nodes, 1)
}
// v1 should dominate
hits := 0
for i := 0; i < 1000; i++ {
nodes := cut.Sample(1)
if nodes[0] == v1.ID {
hits++
}
}
require.Greater(hits, 900, "large-weight validator should be selected most of the time")
}
func TestStakeWeightedCutLuminance(t *testing.T) {
require := require.New(t)
+128
View File
@@ -205,6 +205,134 @@ func TestPhaseCoverage(t *testing.T) {
}
}
// --- Additional inversion and edge-case tests ---
// TestDifferentSeedsProduceDifferentThresholdSequences verifies that two
// selectors built from different seeds produce different threshold sequences
// across many phases. It is statistically near-impossible for two PRFs with
// different keys to match across 100 phases.
func TestDifferentSeedsProduceDifferentThresholdSequences(t *testing.T) {
s1, err := NewSelector(0.5, 0.8, []byte("alpha-seed"))
if err != nil {
t.Fatal(err)
}
s2, err := NewSelector(0.5, 0.8, []byte("beta-seed"))
if err != nil {
t.Fatal(err)
}
k := 200
matches := 0
const phases = 100
for phase := uint64(0); phase < phases; phase++ {
if s1.SelectThreshold(phase, k) == s2.SelectThreshold(phase, k) {
matches++
}
}
// With a 30% range ([0.5,0.8]*200 = [100,160]), about 60 distinct values.
// P(match per phase) ~ 1/60. Expected matches ~ 1.67 in 100 phases.
// If >20 match, something is wrong.
if matches > 20 {
t.Errorf("different seeds produced %d/%d matching thresholds -- PRF may be broken", matches, phases)
}
}
// TestSameSeedDeterministicSequence verifies that the same seed produces
// the exact same threshold sequence on every invocation (reproducibility).
func TestSameSeedDeterministicSequence(t *testing.T) {
seed := []byte("deterministic-seed-42")
k := 150
s1, err := NewSelector(0.4, 0.9, seed)
if err != nil {
t.Fatal(err)
}
s2, err := NewSelector(0.4, 0.9, seed)
if err != nil {
t.Fatal(err)
}
for phase := uint64(0); phase < 500; phase++ {
t1 := s1.SelectThreshold(phase, k)
t2 := s2.SelectThreshold(phase, k)
if t1 != t2 {
t.Fatalf("phase %d: s1=%d, s2=%d -- same seed must be deterministic", phase, t1, t2)
}
}
}
// TestDeriveEpochSeedDifferentEpochs verifies that every distinct epoch
// number produces a distinct seed (for the same chain).
func TestDeriveEpochSeedDifferentEpochs(t *testing.T) {
chain := []byte("lux-mainnet")
seen := make(map[string]uint64) // seed hex -> epoch
for epoch := uint64(0); epoch < 1000; epoch++ {
seed := DeriveEpochSeed(epoch, chain)
key := string(seed)
if prev, exists := seen[key]; exists {
t.Fatalf("epoch %d and %d produced the same seed", prev, epoch)
}
seen[key] = epoch
}
}
// TestThresholdAlwaysInRange verifies the critical safety property that
// for all phases, all k values, the returned threshold is in
// [ceil(thetaMin*k), ceil(thetaMax*k)].
func TestThresholdAlwaysInRange(t *testing.T) {
thetaMin, thetaMax := 0.5, 0.8
seed := DeriveEpochSeed(1, []byte("range-test"))
s, err := NewSelector(thetaMin, thetaMax, seed)
if err != nil {
t.Fatal(err)
}
kValues := []int{1, 2, 3, 10, 50, 100, 1000, 10000}
for _, k := range kValues {
lo := int(thetaMin * float64(k)) // floor
hi := int(thetaMax*float64(k)) + 1 // ceiling with margin
for phase := uint64(0); phase < 200; phase++ {
threshold := s.SelectThreshold(phase, k)
if threshold < lo || threshold > hi {
t.Fatalf("k=%d phase=%d: threshold %d outside [%d, %d]",
k, phase, threshold, lo, hi)
}
}
}
}
// TestSelectThresholdKZero verifies behavior when k=0.
func TestSelectThresholdKZero(t *testing.T) {
s, err := NewSelector(0.5, 0.8, []byte("zero-k"))
if err != nil {
t.Fatal(err)
}
threshold := s.SelectThreshold(1, 0)
if threshold != 0 {
t.Errorf("expected threshold 0 for k=0, got %d", threshold)
}
}
// TestSelectThresholdKOne verifies behavior when k=1.
// ceil(theta * 1) should be 1 for any theta > 0.
func TestSelectThresholdKOne(t *testing.T) {
s, err := NewSelector(0.5, 0.8, []byte("one-k"))
if err != nil {
t.Fatal(err)
}
for phase := uint64(0); phase < 100; phase++ {
threshold := s.SelectThreshold(phase, 1)
if threshold != 1 {
t.Fatalf("phase %d: expected threshold 1 for k=1, got %d", phase, threshold)
}
}
}
func BenchmarkSelectThreshold(b *testing.B) {
s, _ := NewSelector(0.5, 0.8, []byte("bench-seed"))
k := 100
Executable
BIN
View File
Binary file not shown.