mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
Binary file not shown.
Reference in New Issue
Block a user