mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
591 lines
15 KiB
Go
591 lines
15 KiB
Go
package slashing
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
func nodeID(b byte) ids.NodeID {
|
|
var id ids.NodeID
|
|
id[0] = b
|
|
return id
|
|
}
|
|
|
|
func blockID(b byte) ids.ID {
|
|
var id ids.ID
|
|
id[0] = b
|
|
return id
|
|
}
|
|
|
|
// --- Detector: DoubleVote ---
|
|
|
|
func TestCheckVote_NoDuplicate(t *testing.T) {
|
|
d := NewDetector(64, 0.5)
|
|
v := nodeID(1)
|
|
b := blockID(0xAA)
|
|
|
|
ev := d.CheckVote(v, 100, b, true)
|
|
if ev != nil {
|
|
t.Fatal("first vote should not produce evidence")
|
|
}
|
|
}
|
|
|
|
func TestCheckVote_SameBlockNotEquivocation(t *testing.T) {
|
|
d := NewDetector(64, 0.5)
|
|
v := nodeID(1)
|
|
b := blockID(0xAA)
|
|
|
|
d.CheckVote(v, 100, b, true)
|
|
ev := d.CheckVote(v, 100, b, true)
|
|
if ev != nil {
|
|
t.Fatal("duplicate vote for same block should not produce evidence")
|
|
}
|
|
}
|
|
|
|
func TestCheckVote_DifferentBlockEquivocation(t *testing.T) {
|
|
d := NewDetector(64, 0.5)
|
|
v := nodeID(1)
|
|
b1 := blockID(0xAA)
|
|
b2 := blockID(0xBB)
|
|
|
|
d.CheckVote(v, 100, b1, true)
|
|
ev := d.CheckVote(v, 100, b2, true)
|
|
if ev == nil {
|
|
t.Fatal("voting for different blocks at same height must produce evidence")
|
|
}
|
|
if ev.Type != DoubleVote {
|
|
t.Fatalf("expected DoubleVote, got %s", ev.Type)
|
|
}
|
|
if ev.ValidatorID != v {
|
|
t.Fatalf("wrong validator in evidence")
|
|
}
|
|
if ev.Height != 100 {
|
|
t.Fatalf("wrong height in evidence: %d", ev.Height)
|
|
}
|
|
if len(ev.Proof) == 0 {
|
|
t.Fatal("proof must not be empty")
|
|
}
|
|
}
|
|
|
|
func TestCheckVote_DifferentHeightsOK(t *testing.T) {
|
|
d := NewDetector(64, 0.5)
|
|
v := nodeID(1)
|
|
b1 := blockID(0xAA)
|
|
b2 := blockID(0xBB)
|
|
|
|
d.CheckVote(v, 100, b1, true)
|
|
ev := d.CheckVote(v, 101, b2, true) // different height is fine
|
|
if ev != nil {
|
|
t.Fatal("votes at different heights should not produce evidence")
|
|
}
|
|
}
|
|
|
|
func TestCheckVote_DifferentValidatorsOK(t *testing.T) {
|
|
d := NewDetector(64, 0.5)
|
|
v1 := nodeID(1)
|
|
v2 := nodeID(2)
|
|
b1 := blockID(0xAA)
|
|
b2 := blockID(0xBB)
|
|
|
|
d.CheckVote(v1, 100, b1, true)
|
|
ev := d.CheckVote(v2, 100, b2, true) // different validator is fine
|
|
if ev != nil {
|
|
t.Fatal("different validators voting for different blocks is not equivocation")
|
|
}
|
|
}
|
|
|
|
// --- Detector: DoubleSign ---
|
|
|
|
func TestCheckBlock_NoDuplicate(t *testing.T) {
|
|
d := NewDetector(64, 0.5)
|
|
v := nodeID(1)
|
|
b := blockID(0xAA)
|
|
|
|
ev := d.CheckBlock(v, 100, b, []byte("data"))
|
|
if ev != nil {
|
|
t.Fatal("first block should not produce evidence")
|
|
}
|
|
}
|
|
|
|
func TestCheckBlock_SameBlockNotEquivocation(t *testing.T) {
|
|
d := NewDetector(64, 0.5)
|
|
v := nodeID(1)
|
|
b := blockID(0xAA)
|
|
|
|
d.CheckBlock(v, 100, b, []byte("data"))
|
|
ev := d.CheckBlock(v, 100, b, []byte("data"))
|
|
if ev != nil {
|
|
t.Fatal("same block at same height should not produce evidence")
|
|
}
|
|
}
|
|
|
|
func TestCheckBlock_DifferentBlockEquivocation(t *testing.T) {
|
|
d := NewDetector(64, 0.5)
|
|
v := nodeID(1)
|
|
b1 := blockID(0xAA)
|
|
b2 := blockID(0xBB)
|
|
|
|
d.CheckBlock(v, 100, b1, []byte("data1"))
|
|
ev := d.CheckBlock(v, 100, b2, []byte("data2"))
|
|
if ev == nil {
|
|
t.Fatal("signing different blocks at same height must produce evidence")
|
|
}
|
|
if ev.Type != DoubleSign {
|
|
t.Fatalf("expected DoubleSign, got %s", ev.Type)
|
|
}
|
|
if ev.ValidatorID != v {
|
|
t.Fatal("wrong validator in evidence")
|
|
}
|
|
if ev.Height != 100 {
|
|
t.Fatalf("wrong height: %d", ev.Height)
|
|
}
|
|
}
|
|
|
|
// --- Detector: Downtime ---
|
|
|
|
func TestRecordParticipation_AllPresent(t *testing.T) {
|
|
d := NewDetector(10, 0.5)
|
|
v := nodeID(1)
|
|
|
|
// Fill the window with participation
|
|
for i := 0; i < 10; i++ {
|
|
ev := d.RecordParticipation(v, true)
|
|
if ev != nil {
|
|
t.Fatalf("full participation should not produce evidence at slot %d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecordParticipation_AllMissed(t *testing.T) {
|
|
d := NewDetector(10, 0.5)
|
|
v := nodeID(1)
|
|
|
|
// Miss every slot. Window needs to fill (10 slots) before evidence triggers.
|
|
// Slots 0-8: warmup, no evidence. Slot 9: window full, 10/10 missed > 50%.
|
|
for i := 0; i < 9; i++ {
|
|
ev := d.RecordParticipation(v, false)
|
|
if ev != nil {
|
|
t.Fatalf("should not trigger during warmup at slot %d", i)
|
|
}
|
|
}
|
|
ev := d.RecordParticipation(v, false) // slot 10, window full
|
|
if ev == nil {
|
|
t.Fatal("missing all slots must produce downtime evidence once window is full")
|
|
}
|
|
if ev.Type != Downtime {
|
|
t.Fatalf("expected Downtime, got %s", ev.Type)
|
|
}
|
|
}
|
|
|
|
func TestRecordParticipation_ThresholdBoundary(t *testing.T) {
|
|
// Window=10, threshold=0.5 means >5 missed triggers evidence.
|
|
// Window must be full before evaluation.
|
|
|
|
// Case 1: 5 present then 5 missed = exactly 50%, should NOT trigger (> not >=)
|
|
d := NewDetector(10, 0.5)
|
|
v := nodeID(1)
|
|
for i := 0; i < 5; i++ {
|
|
d.RecordParticipation(v, true)
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
ev := d.RecordParticipation(v, false)
|
|
if ev != nil {
|
|
t.Fatal("exactly at threshold should not trigger evidence")
|
|
}
|
|
}
|
|
|
|
// Case 2: 4 present then 6 missed = 60% missed > 50%
|
|
d2 := NewDetector(10, 0.5)
|
|
for i := 0; i < 4; i++ {
|
|
d2.RecordParticipation(v, true)
|
|
}
|
|
var gotEvidence bool
|
|
for i := 0; i < 6; i++ {
|
|
ev := d2.RecordParticipation(v, false)
|
|
if ev != nil {
|
|
gotEvidence = true
|
|
}
|
|
}
|
|
if !gotEvidence {
|
|
t.Fatal("6/10 missed (60%) should exceed 50% threshold")
|
|
}
|
|
}
|
|
|
|
// --- Detector: Prune ---
|
|
|
|
func TestPruneBelow(t *testing.T) {
|
|
d := NewDetector(64, 0.5)
|
|
v := nodeID(1)
|
|
|
|
d.CheckVote(v, 100, blockID(0xAA), true)
|
|
d.CheckVote(v, 200, blockID(0xBB), true)
|
|
d.CheckBlock(v, 100, blockID(0xCC), []byte("x"))
|
|
d.CheckBlock(v, 200, blockID(0xDD), []byte("y"))
|
|
|
|
d.PruneBelow(150)
|
|
|
|
// Height 100 should be pruned
|
|
d.mu.Lock()
|
|
if _, exists := d.votes[100]; exists {
|
|
t.Error("height 100 votes should be pruned")
|
|
}
|
|
if _, exists := d.blocks[100]; exists {
|
|
t.Error("height 100 blocks should be pruned")
|
|
}
|
|
// Height 200 should remain
|
|
if _, exists := d.votes[200]; !exists {
|
|
t.Error("height 200 votes should remain")
|
|
}
|
|
if _, exists := d.blocks[200]; !exists {
|
|
t.Error("height 200 blocks should remain")
|
|
}
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// --- DB ---
|
|
|
|
func TestDB_RecordEvidence(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"),
|
|
}
|
|
|
|
rec := db.RecordEvidence(ev)
|
|
if rec.SlashCount != 1 {
|
|
t.Fatalf("expected slash count 1, got %d", rec.SlashCount)
|
|
}
|
|
if rec.JailedUntil.IsZero() {
|
|
t.Fatal("validator should be jailed after slashing")
|
|
}
|
|
if !rec.IsJailed(time.Now()) {
|
|
t.Fatal("validator should be jailed now")
|
|
}
|
|
if len(rec.Evidence) != 1 {
|
|
t.Fatalf("expected 1 evidence, got %d", len(rec.Evidence))
|
|
}
|
|
}
|
|
|
|
func TestDB_JailDurationDoubles(t *testing.T) {
|
|
db := NewDB(1 * time.Hour)
|
|
v := nodeID(1)
|
|
|
|
now := time.Now()
|
|
|
|
ev := Evidence{Type: DoubleVote, ValidatorID: v, Height: 100, Timestamp: now, Proof: []byte("1")}
|
|
rec1 := db.RecordEvidence(ev)
|
|
jail1 := rec1.JailedUntil.Sub(now)
|
|
|
|
ev2 := Evidence{Type: DoubleVote, ValidatorID: v, Height: 200, Timestamp: now, Proof: []byte("2")}
|
|
rec2 := db.RecordEvidence(ev2)
|
|
jail2 := rec2.JailedUntil.Sub(now)
|
|
|
|
if rec2.SlashCount != 2 {
|
|
t.Fatalf("expected slash count 2, got %d", rec2.SlashCount)
|
|
}
|
|
|
|
// Second jail should be roughly 2x the first (modulo time.Now() drift)
|
|
// jail1 ~= 1h, jail2 ~= 2h from now
|
|
if jail2 < jail1 {
|
|
t.Fatalf("jail duration should increase: first=%v second=%v", jail1, jail2)
|
|
}
|
|
}
|
|
|
|
func TestDB_IsJailed(t *testing.T) {
|
|
db := NewDB(1 * time.Millisecond) // very short jail for testing
|
|
v := nodeID(1)
|
|
|
|
if db.IsJailed(v) {
|
|
t.Fatal("clean validator should not be jailed")
|
|
}
|
|
|
|
ev := Evidence{Type: DoubleSign, ValidatorID: v, Height: 50, Timestamp: time.Now(), Proof: []byte("x")}
|
|
db.RecordEvidence(ev)
|
|
|
|
if !db.IsJailed(v) {
|
|
t.Fatal("slashed validator should be jailed")
|
|
}
|
|
|
|
// Wait for jail to expire
|
|
time.Sleep(5 * time.Millisecond)
|
|
if db.IsJailed(v) {
|
|
t.Fatal("jail should have expired")
|
|
}
|
|
}
|
|
|
|
func TestDB_GetRecord_Clean(t *testing.T) {
|
|
db := NewDB(1 * time.Hour)
|
|
rec := db.GetRecord(nodeID(99))
|
|
if rec != nil {
|
|
t.Fatal("clean validator should have nil record")
|
|
}
|
|
}
|
|
|
|
func TestDB_GetAllRecords(t *testing.T) {
|
|
db := NewDB(1 * time.Hour)
|
|
v1 := nodeID(1)
|
|
v2 := nodeID(2)
|
|
|
|
db.RecordEvidence(Evidence{Type: DoubleVote, ValidatorID: v1, Proof: []byte("a")})
|
|
db.RecordEvidence(Evidence{Type: DoubleSign, ValidatorID: v2, Proof: []byte("b")})
|
|
|
|
recs := db.GetAllRecords()
|
|
if len(recs) != 2 {
|
|
t.Fatalf("expected 2 records, got %d", len(recs))
|
|
}
|
|
}
|
|
|
|
// --- EvidenceType String ---
|
|
|
|
func TestEvidenceType_String(t *testing.T) {
|
|
tests := []struct {
|
|
t EvidenceType
|
|
want string
|
|
}{
|
|
{DoubleVote, "double_vote"},
|
|
{DoubleSign, "double_sign"},
|
|
{Downtime, "downtime"},
|
|
{EvidenceType(99), "unknown"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := tt.t.String(); got != tt.want {
|
|
t.Errorf("EvidenceType(%d).String() = %q, want %q", tt.t, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- popcount ---
|
|
|
|
func TestPopcount(t *testing.T) {
|
|
tests := []struct {
|
|
v uint64
|
|
window int
|
|
want int
|
|
}{
|
|
{0b1111, 4, 4},
|
|
{0b1010, 4, 2},
|
|
{0b0000, 4, 0},
|
|
{0b11111111, 8, 8},
|
|
{0xFF, 4, 4}, // only lower 4 bits: 0b1111
|
|
{0xFFFF, 10, 10}, // lower 10 bits all set
|
|
{0b1010101010, 10, 5},
|
|
}
|
|
for _, tt := range tests {
|
|
got := popcount(tt.v, tt.window)
|
|
if got != tt.want {
|
|
t.Errorf("popcount(0b%b, %d) = %d, want %d", tt.v, tt.window, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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")
|
|
}
|
|
}
|