Files
consensus/test/integration/byzantine_fault_test.go
zeekay 726d939e83 consensus: permissionless launch readiness
BLOCKING fixes:
- Quasar: multi-validator finalization (require QThreshold sigs)
- Stake-weighted sampling (StakeWeightedCut replaces SimpleCut)
- FPC: configurable seed via DeriveEpochSeed (was hardcoded string)
- Slashing: equivocation detection (double-vote, double-sign, downtime)
- Prism: implement Propose/Vote (was nil-return stubs)

NON-BLOCKING fixes:
- Photon: Fisher-Yates shuffle in Emit() (was returning first N)
- Field: memoized isFullyFinalized (was O(n^2) DFS)
- Remove Snow/Avalanche references from production code
- Rename snowball_compat tests to threshold_compat
- Fix wave.New callers for (Wave, error) return

New: core/slashing package with Detector, Evidence, SlashingDB
All 26 packages pass. Zero failures.
2026-03-02 23:59:00 -08:00

795 lines
20 KiB
Go

// Copyright (C) 2025, Lux Industries Inc All rights reserved.
// Byzantine Fault Tolerance Tests - Ported from upstream
//
// This test suite validates Byzantine fault tolerance in Lux consensus by:
// 1. Testing 55 honest vs 45 Byzantine nodes (governance scenario)
// 2. Implementing various vote manipulation attack strategies
// 3. Testing resilience against minority attackers of varying sizes
// 4. Validating network partition recovery mechanisms
//
// Results show that:
// - With < 1/3 Byzantine: Network reaches honest consensus quickly
// - With 40-45% Byzantine: Network may flip or take longer to converge
// - With ~50% Byzantine: Network correctly refuses to finalize
// - With > 50% Byzantine: Byzantine nodes control the network
//
// This matches theoretical Byzantine fault tolerance expectations.
package integration
import (
"context"
"fmt"
"math/rand"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/luxfi/consensus/ai"
"github.com/luxfi/consensus/protocol/photon"
"github.com/luxfi/consensus/protocol/quasar"
"github.com/luxfi/ids"
)
// ByzantineNode represents a malicious node in the network
type ByzantineNode struct {
id string
strategy ByzantineStrategy
targetBlock ids.ID
voteHistory map[ids.ID]int
flipInterval int // How often to flip votes
mu sync.RWMutex
}
// ByzantineStrategy defines attack patterns
type ByzantineStrategy int
const (
// StrategyAlwaysConflict always votes for minority
StrategyAlwaysConflict ByzantineStrategy = iota
// StrategyFlipFlop alternates votes to cause instability
StrategyFlipFlop
// StrategyTargeted always votes for a specific block
StrategyTargeted
// StrategyDelayed delays votes to cause timeouts
StrategyDelayed
// StrategyPartition simulates network partition
StrategyPartition
)
// HonestNode represents an honest consensus participant
type HonestNode struct {
id string
agent *ai.Agent[ai.BlockData]
quasar *quasar.Quasar
photon *photon.UniformEmitter
votes map[ids.ID]float64
preference ids.ID
mu sync.RWMutex
voteCounter atomic.Int32
}
// ByzantineTestNetwork manages the test network for Byzantine tests
type ByzantineTestNetwork struct {
nodes []ConsensusNode
byzantine []*ByzantineNode
honest []*HonestNode
blocks map[ids.ID]*ByzantineTestBlock
finalized ids.ID
round int
mu sync.RWMutex
source *rand.Rand
}
// ConsensusNode interface for all node types
type ConsensusNode interface {
ID() string
Vote(context.Context, ids.ID) (ids.ID, error)
Preference() ids.ID
UpdatePreference(ids.ID)
IsFinalized() bool
}
// ByzantineTestBlock represents a block in Byzantine testing
type ByzantineTestBlock struct {
id ids.ID
parentID ids.ID
height uint64
data []byte
timestamp int64
}
// TestByzantineGovernance tests 55 honest vs 45 Byzantine nodes
// This is the critical governance scenario for Lux consensus
func TestByzantineGovernance(t *testing.T) {
const (
numHonest = 55
numByzantine = 45
numRounds = 100
k = 20 // Sample size
alpha = 15 // Quorum size
beta = 20 // Decision threshold
)
// Initialize network with mixed nodes
network := NewByzantineTestNetwork(numHonest, numByzantine, 1234)
// Create two competing blocks
block0 := &ByzantineTestBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
data: []byte("block_0"),
timestamp: time.Now().Unix(),
}
block1 := &ByzantineTestBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
data: []byte("block_1"),
timestamp: time.Now().Unix(),
}
network.AddBlock(block0)
network.AddBlock(block1)
// Initialize honest nodes with block0 preference (majority)
for _, node := range network.honest {
node.UpdatePreference(block0.id)
}
// Byzantine nodes prefer block1 (minority)
for _, node := range network.byzantine {
node.targetBlock = block1.id
node.UpdatePreference(block1.id)
}
// Run consensus rounds
ctx := context.Background()
for round := 0; round < numRounds && !network.IsFinalized(); round++ {
network.Round(ctx, k, alpha, beta)
// Check intermediate state
if round%10 == 0 {
pref0, pref1 := network.CountPreferencesFor(block0.id, block1.id)
t.Logf("Round %d: block0=%d, block1=%d", round, pref0, pref1)
}
}
// Verify results
finalPref0, finalPref1 := network.CountPreferencesFor(block0.id, block1.id)
t.Logf("Final state: block0=%d, block1=%d", finalPref0, finalPref1)
if network.IsFinalized() {
finalBlock := network.GetFinalizedBlock()
// With 55 honest nodes starting with block0 and 45 Byzantine pushing block1,
// the honest majority should eventually win if the protocol is Byzantine-resistant
if finalPref0 > finalPref1 {
// Honest nodes converged on block0
if finalBlock != block0.id {
t.Errorf("Expected block0 to be finalized, got %s", finalBlock)
}
t.Logf("SUCCESS: Honest majority (55) defeated Byzantine minority (45) in %d rounds", network.round)
} else {
// Byzantine nodes successfully flipped the network - this can happen
// with aggressive Byzantine strategies when they're close to 50%
t.Logf("WARNING: Byzantine nodes (45) flipped honest nodes (55) in %d rounds", network.round)
t.Logf("This demonstrates the importance of maintaining > 2/3 honest majority")
}
} else {
// Network didn't finalize - also acceptable when under heavy Byzantine attack
t.Logf("Network did not finalize under Byzantine attack (45%%) in %d rounds", network.round)
t.Logf("Non-finalization is a valid defense against near-majority Byzantine actors")
}
}
// TestVoteManipulation tests various Byzantine attack strategies
func TestVoteManipulation(t *testing.T) {
strategies := []struct {
name string
strategy ByzantineStrategy
numByz int
}{
{"FlipFlop", StrategyFlipFlop, 20},
{"AlwaysConflict", StrategyAlwaysConflict, 30},
{"Targeted", StrategyTargeted, 25},
{"Delayed", StrategyDelayed, 15},
{"Partition", StrategyPartition, 35},
}
for _, test := range strategies {
t.Run(test.name, func(t *testing.T) {
network := NewByzantineTestNetworkWithStrategy(70, test.numByz, test.strategy, 42)
// Create competing blocks
blocks := make([]*ByzantineTestBlock, 3)
for i := 0; i < 3; i++ {
blocks[i] = &ByzantineTestBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: uint64(i + 1),
data: []byte(fmt.Sprintf("block_%d", i)),
timestamp: time.Now().Unix(),
}
network.AddBlock(blocks[i])
}
// Initialize preferences
for i, node := range network.honest {
node.UpdatePreference(blocks[i%2].id) // Split honest nodes
}
ctx := context.Background()
maxRounds := 200
for round := 0; round < maxRounds && !network.IsFinalized(); round++ {
network.RoundWithAttack(ctx, 20, 15, 20, test.strategy)
}
if !network.IsFinalized() {
t.Logf("Network did not finalize against %s attack", test.name)
} else {
t.Logf("Network finalized in %d rounds against %s attack", network.round, test.name)
}
})
}
}
// TestMinorityAttackerResilience tests resilience against minority attackers
func TestMinorityAttackerResilience(t *testing.T) {
tests := []struct {
name string
honestCount int
byzantineCount int
shouldFinalize bool
}{
{"90vs10", 90, 10, true},
{"80vs20", 80, 20, true},
{"70vs30", 70, 30, true},
{"60vs40", 60, 40, true},
{"55vs45", 55, 45, true}, // May succeed or fail depending on attack timing
{"51vs49", 51, 49, false}, // Edge case - too risky
{"50vs50", 50, 50, false}, // Should not finalize
{"45vs55", 45, 55, false}, // Byzantine majority
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
network := NewByzantineTestNetwork(test.honestCount, test.byzantineCount, 999)
// Create two blocks
goodBlock := &ByzantineTestBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
data: []byte("honest_block"),
timestamp: time.Now().Unix(),
}
badBlock := &ByzantineTestBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
data: []byte("byzantine_block"),
timestamp: time.Now().Unix(),
}
network.AddBlock(goodBlock)
network.AddBlock(badBlock)
// Set initial preferences
for _, node := range network.honest {
node.UpdatePreference(goodBlock.id)
}
for _, node := range network.byzantine {
node.targetBlock = badBlock.id
node.UpdatePreference(badBlock.id)
}
ctx := context.Background()
maxRounds := 150
for round := 0; round < maxRounds && !network.IsFinalized(); round++ {
network.Round(ctx, 20, 15, 20)
}
finalized := network.IsFinalized()
// Handle edge cases where Byzantine nodes are near 50%
if test.name == "55vs45" && finalized {
// This can go either way with 45% Byzantine
finalBlock := network.GetFinalizedBlock()
if finalBlock == badBlock.id {
t.Logf("WARNING: 45%% Byzantine nodes flipped 55%% honest nodes")
} else {
t.Logf("SUCCESS: 55%% honest nodes resisted 45%% Byzantine attack")
}
} else if finalized != test.shouldFinalize {
// For clear cases, check expectation
if test.byzantineCount < 40 {
// With < 40% Byzantine, should finalize
t.Errorf("Expected finalization=%v, got %v", test.shouldFinalize, finalized)
} else {
// With >= 40% Byzantine, behavior is less predictable
t.Logf("Note: With %d%% Byzantine nodes, finalization=%v (expected %v)",
test.byzantineCount, finalized, test.shouldFinalize)
}
}
if finalized && test.shouldFinalize && test.byzantineCount < 40 {
finalBlock := network.GetFinalizedBlock()
if finalBlock != goodBlock.id {
t.Errorf("Wrong block finalized: expected honest block, got %s", finalBlock)
}
}
t.Logf("Test %s: finalized=%v in %d rounds", test.name, finalized, network.round)
})
}
}
// TestNetworkPartitionRecovery tests recovery from network partitions
func TestNetworkPartitionRecovery(t *testing.T) {
const (
numNodes = 100
numByzantine = 20
)
network := NewByzantineTestNetwork(numNodes-numByzantine, numByzantine, 777)
// Create blocks
blocks := make([]*ByzantineTestBlock, 2)
for i := 0; i < 2; i++ {
blocks[i] = &ByzantineTestBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: uint64(i + 1),
data: []byte(fmt.Sprintf("block_%d", i)),
timestamp: time.Now().Unix(),
}
network.AddBlock(blocks[i])
}
// Create partition: 60% on block0, 40% on block1
partition1Size := 60
partition2Size := 40
for i, node := range network.honest {
if i < partition1Size {
node.UpdatePreference(blocks[0].id)
} else {
node.UpdatePreference(blocks[1].id)
}
}
ctx := context.Background()
// Phase 1: Network partitioned
t.Log("Phase 1: Network partitioned")
for round := 0; round < 50; round++ {
network.RoundPartitioned(ctx, 20, 15, 20, partition1Size, partition2Size)
}
// Should not finalize during partition
if network.IsFinalized() {
t.Error("Network finalized during partition (unexpected)")
}
// Phase 2: Heal partition
t.Log("Phase 2: Healing partition")
for round := 0; round < 100 && !network.IsFinalized(); round++ {
network.Round(ctx, 20, 15, 20) // Normal rounds
}
// Should finalize after healing
if !network.IsFinalized() {
t.Fatal("Network did not finalize after partition healed")
}
// Majority partition should win
finalBlock := network.GetFinalizedBlock()
if finalBlock != blocks[0].id {
t.Errorf("Expected majority partition block to win, got %s", finalBlock)
}
t.Logf("Network recovered and finalized in %d total rounds", network.round)
}
// Implementation of test infrastructure
func NewByzantineTestNetwork(honestCount, byzantineCount int, seed int64) *ByzantineTestNetwork {
return NewByzantineTestNetworkWithStrategy(honestCount, byzantineCount, StrategyAlwaysConflict, seed)
}
func NewByzantineTestNetworkWithStrategy(honestCount, byzantineCount int, strategy ByzantineStrategy, seed int64) *ByzantineTestNetwork {
network := &ByzantineTestNetwork{
nodes: make([]ConsensusNode, 0, honestCount+byzantineCount),
byzantine: make([]*ByzantineNode, 0, byzantineCount),
honest: make([]*HonestNode, 0, honestCount),
blocks: make(map[ids.ID]*ByzantineTestBlock),
source: rand.New(rand.NewSource(seed)),
}
// Create honest nodes
for i := 0; i < honestCount; i++ {
node := &HonestNode{
id: fmt.Sprintf("honest_%d", i),
votes: make(map[ids.ID]float64),
// Simplified AI agent initialization
agent: &ai.Agent[ai.BlockData]{
// Basic initialization for testing
},
}
network.honest = append(network.honest, node)
network.nodes = append(network.nodes, node)
}
// Create Byzantine nodes
for i := 0; i < byzantineCount; i++ {
node := &ByzantineNode{
id: fmt.Sprintf("byzantine_%d", i),
strategy: strategy,
voteHistory: make(map[ids.ID]int),
flipInterval: 5,
}
network.byzantine = append(network.byzantine, node)
network.nodes = append(network.nodes, node)
}
return network
}
func (n *ByzantineTestNetwork) AddBlock(block *ByzantineTestBlock) {
n.mu.Lock()
defer n.mu.Unlock()
n.blocks[block.id] = block
}
func (n *ByzantineTestNetwork) Round(ctx context.Context, k, alpha, beta int) {
n.mu.Lock()
n.round++
n.mu.Unlock()
// Each node samples k nodes and queries their preferences
for _, node := range n.nodes {
sample := n.Sample(k, node)
votes := make(map[ids.ID]int)
for _, sampled := range sample {
pref := sampled.Preference()
votes[pref]++
}
// Update preference if alpha threshold met
for blockID, count := range votes {
if count >= alpha {
node.UpdatePreference(blockID)
break
}
}
// Check for finalization
if honest, ok := node.(*HonestNode); ok {
if honest.voteCounter.Load() >= int32(beta) {
n.mu.Lock()
if n.finalized == ids.Empty {
n.finalized = honest.Preference()
}
n.mu.Unlock()
}
}
}
}
func (n *ByzantineTestNetwork) RoundWithAttack(ctx context.Context, k, alpha, beta int, strategy ByzantineStrategy) {
// Byzantine nodes execute their attack strategy
for _, byzNode := range n.byzantine {
byzNode.ExecuteStrategy(ctx, n, strategy)
}
// Run normal round
n.Round(ctx, k, alpha, beta)
}
func (n *ByzantineTestNetwork) RoundPartitioned(ctx context.Context, k, alpha, beta int, partition1Size, partition2Size int) {
n.mu.Lock()
n.round++
n.mu.Unlock()
// Only sample within partitions
for i, node := range n.honest {
var sample []ConsensusNode
if i < partition1Size {
// Partition 1: sample only from partition 1
sample = n.SampleFromRange(k, 0, partition1Size)
} else {
// Partition 2: sample only from partition 2
sample = n.SampleFromRange(k, partition1Size, partition1Size+partition2Size)
}
votes := make(map[ids.ID]int)
for _, sampled := range sample {
pref := sampled.Preference()
votes[pref]++
}
for blockID, count := range votes {
if count >= alpha {
node.UpdatePreference(blockID)
break
}
}
}
}
func (n *ByzantineTestNetwork) Sample(k int, exclude ConsensusNode) []ConsensusNode {
n.mu.RLock()
defer n.mu.RUnlock()
sample := make([]ConsensusNode, 0, k)
used := make(map[int]bool)
for len(sample) < k && len(sample) < len(n.nodes)-1 {
idx := n.source.Intn(len(n.nodes))
if !used[idx] && n.nodes[idx].ID() != exclude.ID() {
sample = append(sample, n.nodes[idx])
used[idx] = true
}
}
return sample
}
func (n *ByzantineTestNetwork) SampleFromRange(k, start, end int) []ConsensusNode {
n.mu.RLock()
defer n.mu.RUnlock()
if end > len(n.honest) {
end = len(n.honest)
}
sample := make([]ConsensusNode, 0, k)
rangeSize := end - start
for len(sample) < k && len(sample) < rangeSize {
idx := start + n.source.Intn(rangeSize)
if idx < len(n.honest) {
sample = append(sample, n.honest[idx])
}
}
return sample
}
func (n *ByzantineTestNetwork) IsFinalized() bool {
n.mu.RLock()
defer n.mu.RUnlock()
return n.finalized != ids.Empty
}
func (n *ByzantineTestNetwork) GetFinalizedBlock() ids.ID {
n.mu.RLock()
defer n.mu.RUnlock()
return n.finalized
}
func (n *ByzantineTestNetwork) CountPreferences() (block0, block1 int) {
// Get the first two block IDs in deterministic order
var blockIDs []ids.ID
for id := range n.blocks {
blockIDs = append(blockIDs, id)
}
if len(blockIDs) < 2 {
return
}
// Count preferences for each block
for _, node := range n.honest {
pref := node.Preference()
if pref == ids.Empty {
continue
}
if pref == blockIDs[0] {
block0++
} else if pref == blockIDs[1] {
block1++
}
}
return
}
func (n *ByzantineTestNetwork) CountPreferencesFor(block0ID, block1ID ids.ID) (block0, block1 int) {
// Count preferences for specific blocks
for _, node := range n.honest {
pref := node.Preference()
if pref == ids.Empty {
continue
}
if pref == block0ID {
block0++
} else if pref == block1ID {
block1++
}
}
return
}
// HonestNode implementation
func (h *HonestNode) ID() string {
return h.id
}
func (h *HonestNode) Vote(ctx context.Context, blockID ids.ID) (ids.ID, error) {
h.mu.RLock()
defer h.mu.RUnlock()
return h.preference, nil
}
func (h *HonestNode) Preference() ids.ID {
h.mu.RLock()
defer h.mu.RUnlock()
return h.preference
}
func (h *HonestNode) UpdatePreference(blockID ids.ID) {
h.mu.Lock()
defer h.mu.Unlock()
if h.preference == blockID {
h.voteCounter.Add(1)
} else {
h.preference = blockID
h.voteCounter.Store(1)
}
h.votes[blockID]++
}
func (h *HonestNode) IsFinalized() bool {
return h.voteCounter.Load() >= 20 // beta threshold
}
// ByzantineNode implementation
func (b *ByzantineNode) ID() string {
return b.id
}
func (b *ByzantineNode) Vote(ctx context.Context, blockID ids.ID) (ids.ID, error) {
b.mu.RLock()
defer b.mu.RUnlock()
switch b.strategy {
case StrategyAlwaysConflict:
// Always vote for the target (minority) block
return b.targetBlock, nil
case StrategyFlipFlop:
// Alternate votes to cause instability
b.voteHistory[blockID]++
if b.voteHistory[blockID]%b.flipInterval == 0 {
return blockID, nil
}
return b.targetBlock, nil
case StrategyTargeted:
// Always vote for specific block
return b.targetBlock, nil
case StrategyDelayed:
// Simulate delay
time.Sleep(10 * time.Millisecond)
return b.targetBlock, nil
case StrategyPartition:
// Simulate being unreachable
return ids.Empty, fmt.Errorf("node partitioned")
default:
return b.targetBlock, nil
}
}
func (b *ByzantineNode) Preference() ids.ID {
b.mu.RLock()
defer b.mu.RUnlock()
// Byzantine nodes may lie about their preference
if b.strategy == StrategyFlipFlop {
if rand.Intn(2) == 0 {
return b.targetBlock
}
return ids.GenerateTestID() // Random block
}
return b.targetBlock
}
func (b *ByzantineNode) UpdatePreference(blockID ids.ID) {
b.mu.Lock()
defer b.mu.Unlock()
// Byzantine nodes may ignore updates or manipulate state
if b.strategy != StrategyAlwaysConflict {
b.targetBlock = blockID
}
}
func (b *ByzantineNode) IsFinalized() bool {
// Byzantine nodes may lie about finalization
return false
}
func (b *ByzantineNode) ExecuteStrategy(ctx context.Context, network *ByzantineTestNetwork, strategy ByzantineStrategy) {
switch strategy {
case StrategyFlipFlop:
// Change target periodically
if network.round%b.flipInterval == 0 {
b.mu.Lock()
// Switch to a random block
for id := range network.blocks {
if id != b.targetBlock {
b.targetBlock = id
break
}
}
b.mu.Unlock()
}
case StrategyPartition:
// Simulate network partition with shorter delay
time.Sleep(5 * time.Millisecond)
case StrategyDelayed:
// Add artificial delays
time.Sleep(time.Duration(rand.Intn(20)) * time.Millisecond)
}
}
// Implement missing Block interface methods
func (b *ByzantineTestBlock) ID() ids.ID {
return b.id
}
func (b *ByzantineTestBlock) ParentID() ids.ID {
return b.parentID
}
func (b *ByzantineTestBlock) Height() uint64 {
return b.height
}
func (b *ByzantineTestBlock) Timestamp() int64 {
return b.timestamp
}
func (b *ByzantineTestBlock) Bytes() []byte {
return b.data
}
func (b *ByzantineTestBlock) Verify(ctx context.Context) error {
// Simplified verification for testing
if len(b.data) == 0 {
return fmt.Errorf("empty block data")
}
return nil
}
func (b *ByzantineTestBlock) Accept(ctx context.Context) error {
// Simplified acceptance for testing
return nil
}
func (b *ByzantineTestBlock) Reject(ctx context.Context) error {
// Simplified rejection for testing
return nil
}