Files
consensus/engine/chain/engine_test.go
T
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

1397 lines
37 KiB
Go

package chain
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/core/slashing"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
)
// mockBlock implements block.Block for testing
type mockBlock struct {
id ids.ID
parentID ids.ID
height uint64
timestamp time.Time
status uint8
bytes []byte
}
func (b *mockBlock) ID() ids.ID { return b.id }
func (b *mockBlock) Parent() ids.ID { return b.parentID }
func (b *mockBlock) ParentID() ids.ID { return b.parentID }
func (b *mockBlock) Height() uint64 { return b.height }
func (b *mockBlock) Timestamp() time.Time { return b.timestamp }
func (b *mockBlock) Status() uint8 { return b.status }
func (b *mockBlock) Verify(context.Context) error { return nil }
func (b *mockBlock) Accept(context.Context) error { return nil }
func (b *mockBlock) Reject(context.Context) error { return nil }
func (b *mockBlock) Bytes() []byte { return b.bytes }
func TestNew(t *testing.T) {
engine := New()
if engine == nil {
t.Fatal("New() returned nil")
}
if engine.IsBootstrapped() {
t.Error("Engine should not be bootstrapped initially")
}
}
func TestStart(t *testing.T) {
engine := New()
ctx := context.Background()
err := engine.Start(ctx, true)
if err != nil {
t.Fatalf("Start failed: %v", err)
}
if !engine.IsBootstrapped() {
t.Error("Engine should be bootstrapped after Start")
}
}
func TestStop(t *testing.T) {
engine := New()
ctx := context.Background()
_ = engine.Start(ctx, true)
err := engine.Stop(ctx)
if err != nil {
t.Fatalf("Stop failed: %v", err)
}
}
func TestHealthCheck(t *testing.T) {
engine := New()
ctx := context.Background()
health, err := engine.HealthCheck(ctx)
if err != nil {
t.Fatalf("HealthCheck failed: %v", err)
}
if health == nil {
t.Error("HealthCheck returned nil")
}
// Check it returns a map with consensus stats
if m, ok := health.(map[string]interface{}); ok {
// Real consensus returns detailed stats
if _, exists := m["total_blocks"]; !exists {
t.Error("HealthCheck should include total_blocks stat")
}
if _, exists := m["bootstrapped"]; !exists {
t.Error("HealthCheck should include bootstrapped stat")
}
} else {
t.Error("HealthCheck should return a map[string]interface{}")
}
}
func TestGetBlock(t *testing.T) {
engine := New()
ctx := context.Background()
// GetBlock should return nil (no-op for now)
// Using empty IDs for test
nodeID := ids.EmptyNodeID
blockID := ids.Empty
err := engine.GetBlock(ctx, nodeID, 1, blockID)
if err != nil {
t.Errorf("GetBlock should not error: %v", err)
}
}
func TestChainWorkflow(t *testing.T) {
engine := New()
ctx := context.Background()
// Start engine
err := engine.Start(ctx, true)
if err != nil {
t.Fatalf("Start failed: %v", err)
}
// Check bootstrapped
if !engine.IsBootstrapped() {
t.Error("Should be bootstrapped")
}
// Health check
health, err := engine.HealthCheck(ctx)
if err != nil {
t.Fatalf("HealthCheck failed: %v", err)
}
if health == nil {
t.Error("Health should not be nil")
}
// Stop engine
err = engine.Stop(ctx)
if err != nil {
t.Fatalf("Stop failed: %v", err)
}
}
// mockVM implements BlockBuilder for testing
type mockVM struct {
buildBlockCalls int
buildBlockErr error
lastBuiltBlock *mockBlock
blocks map[ids.ID]*mockBlock
lastAcceptedID ids.ID
}
func (m *mockVM) BuildBlock(ctx context.Context) (block.Block, error) {
m.buildBlockCalls++
if m.buildBlockErr != nil {
return nil, m.buildBlockErr
}
blk := &mockBlock{
id: ids.GenerateTestID(),
height: uint64(m.buildBlockCalls),
}
m.lastBuiltBlock = blk
if m.blocks == nil {
m.blocks = make(map[ids.ID]*mockBlock)
}
m.blocks[blk.id] = blk
return blk, nil
}
func (m *mockVM) GetBlock(ctx context.Context, id ids.ID) (block.Block, error) {
if m.blocks != nil {
if blk, ok := m.blocks[id]; ok {
return blk, nil
}
}
return nil, errors.New("block not found")
}
func (m *mockVM) ParseBlock(ctx context.Context, bytes []byte) (block.Block, error) {
return &mockBlock{bytes: bytes}, nil
}
func (m *mockVM) LastAccepted(ctx context.Context) (ids.ID, error) {
return m.lastAcceptedID, nil
}
func (m *mockVM) SetPreference(ctx context.Context, id ids.ID) error {
// Mock implementation - just record the preferred block
m.lastAcceptedID = id
return nil
}
// TestNotifyPendingTxsTriggersBuildBlock verifies that Notify(PendingTxs) triggers BuildBlock
func TestNotifyPendingTxsTriggersBuildBlock(t *testing.T) {
engine := New()
ctx := context.Background()
// Start engine
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Set up mock VM
vm := &mockVM{}
engine.SetVM(vm)
// Initial state: no pending builds, no BuildBlock calls
if engine.PendingBuildBlocks() != 0 {
t.Error("Should have 0 pending blocks initially")
}
if vm.buildBlockCalls != 0 {
t.Error("Should have 0 BuildBlock calls initially")
}
// Send PendingTxs notification
err := engine.Notify(ctx, Message{Type: PendingTxs})
if err != nil {
t.Fatalf("Notify failed: %v", err)
}
// Verify BuildBlock was called
if vm.buildBlockCalls != 1 {
t.Errorf("Expected 1 BuildBlock call, got %d", vm.buildBlockCalls)
}
// Pending blocks should be consumed (0 remaining)
if engine.PendingBuildBlocks() != 0 {
t.Errorf("Expected 0 pending blocks after build, got %d", engine.PendingBuildBlocks())
}
// Send multiple notifications rapidly
for i := 0; i < 5; i++ {
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify %d failed: %v", i, err)
}
}
// All 5 notifications should have triggered BuildBlock
if vm.buildBlockCalls != 6 { // 1 + 5
t.Errorf("Expected 6 total BuildBlock calls, got %d", vm.buildBlockCalls)
}
}
// TestNotifyStateSyncDoneDoesNotBuild verifies that StateSyncDone doesn't trigger builds
func TestNotifyStateSyncDoneDoesNotBuild(t *testing.T) {
engine := New()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
vm := &mockVM{}
engine.SetVM(vm)
// Send StateSyncDone notification
err := engine.Notify(ctx, Message{Type: StateSyncDone})
if err != nil {
t.Fatalf("Notify failed: %v", err)
}
// BuildBlock should NOT be called
if vm.buildBlockCalls != 0 {
t.Errorf("Expected 0 BuildBlock calls for StateSyncDone, got %d", vm.buildBlockCalls)
}
}
// TestNotifyWithNoVMDoesNotPanic verifies Notify handles nil VM gracefully
func TestNotifyWithNoVMDoesNotPanic(t *testing.T) {
engine := New()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Don't set VM - it should be nil
// Should not panic
err := engine.Notify(ctx, Message{Type: PendingTxs})
if err != nil {
t.Fatalf("Notify with nil VM should not error: %v", err)
}
}
// TestNotifyBuildBlockError verifies error handling when BuildBlock fails
func TestNotifyBuildBlockError(t *testing.T) {
engine := New()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
// VM that always errors
vm := &mockVM{
buildBlockErr: context.DeadlineExceeded,
}
engine.SetVM(vm)
// Notify should not propagate the error (just log and continue)
err := engine.Notify(ctx, Message{Type: PendingTxs})
if err != nil {
t.Fatalf("Notify should not error on BuildBlock failure: %v", err)
}
// BuildBlock was still called
if vm.buildBlockCalls != 1 {
t.Errorf("Expected 1 BuildBlock call, got %d", vm.buildBlockCalls)
}
}
// -----------------------------------------------------------------------------
// Quorum-based acceptance tests
// -----------------------------------------------------------------------------
// trackingMockBlock tracks Accept/Reject calls for testing
type trackingMockBlock struct {
id ids.ID
parentID ids.ID
height uint64
timestamp time.Time
bytes []byte
acceptCalled int64
rejectCalled int64
}
func (b *trackingMockBlock) ID() ids.ID { return b.id }
func (b *trackingMockBlock) Parent() ids.ID { return b.parentID }
func (b *trackingMockBlock) ParentID() ids.ID { return b.parentID }
func (b *trackingMockBlock) Height() uint64 { return b.height }
func (b *trackingMockBlock) Timestamp() time.Time { return b.timestamp }
func (b *trackingMockBlock) Status() uint8 { return 0 }
func (b *trackingMockBlock) Verify(context.Context) error { return nil }
func (b *trackingMockBlock) Accept(context.Context) error {
atomic.AddInt64(&b.acceptCalled, 1)
return nil
}
func (b *trackingMockBlock) Reject(context.Context) error {
atomic.AddInt64(&b.rejectCalled, 1)
return nil
}
func (b *trackingMockBlock) Bytes() []byte { return b.bytes }
func (b *trackingMockBlock) AcceptCalled() int64 { return atomic.LoadInt64(&b.acceptCalled) }
func (b *trackingMockBlock) RejectCalled() int64 { return atomic.LoadInt64(&b.rejectCalled) }
// trackingMockVM returns trackingMockBlocks for acceptance tracking
type trackingMockVM struct {
blocks []*trackingMockBlock
buildBlockIdx int
lastAcceptedID ids.ID
}
func (m *trackingMockVM) BuildBlock(ctx context.Context) (block.Block, error) {
if m.buildBlockIdx >= len(m.blocks) {
return nil, errors.New("no more blocks")
}
blk := m.blocks[m.buildBlockIdx]
m.buildBlockIdx++
return blk, nil
}
func (m *trackingMockVM) GetBlock(ctx context.Context, id ids.ID) (block.Block, error) {
for _, blk := range m.blocks {
if blk.id == id {
return blk, nil
}
}
return nil, errors.New("block not found")
}
func (m *trackingMockVM) ParseBlock(ctx context.Context, bytes []byte) (block.Block, error) {
return &trackingMockBlock{bytes: bytes}, nil
}
func (m *trackingMockVM) LastAccepted(ctx context.Context) (ids.ID, error) {
return m.lastAcceptedID, nil
}
func (m *trackingMockVM) SetPreference(ctx context.Context, id ids.ID) error {
m.lastAcceptedID = id
return nil
}
// TestEngine_DoesNotAcceptWithoutQuorum verifies blocks are NOT accepted without sufficient votes
func TestEngine_DoesNotAcceptWithoutQuorum(t *testing.T) {
engine := New()
ctx := context.Background()
// Create a tracking block
blk := &trackingMockBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("block-data"),
}
vm := &trackingMockVM{
blocks: []*trackingMockBlock{blk},
}
engine.SetVM(vm)
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer engine.Stop(ctx)
// Trigger block build
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify failed: %v", err)
}
// Verify block was added to pending
engine.mu.RLock()
pending, exists := engine.pendingBlocks[blk.id]
engine.mu.RUnlock()
if !exists {
t.Fatal("Block should be in pending blocks")
}
if pending.VMBlock == nil {
t.Fatal("VMBlock should be set")
}
// Process a single vote (below quorum - default K=20, Alpha=15)
// One vote is not enough for quorum
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
})
// Give vote handler time to process
time.Sleep(100 * time.Millisecond)
// Block should NOT be accepted yet (insufficient votes)
if blk.AcceptCalled() > 0 {
t.Errorf("Block Accept() should NOT be called with insufficient votes, but was called %d times", blk.AcceptCalled())
}
// Check consensus state - should not be accepted
if engine.IsAccepted(blk.id) {
t.Error("Block should not be marked as accepted with insufficient votes")
}
}
// TestEngine_AcceptsAfterQuorum verifies blocks ARE accepted after sufficient votes
func TestEngine_AcceptsAfterQuorum(t *testing.T) {
// Use smaller parameters for testing (K=5, Alpha=3, Beta=2)
// This means we need 3 out of 5 votes for quorum
engine := NewWithParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
Beta: 2,
})
ctx := context.Background()
// Create a tracking block
blk := &trackingMockBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("block-data"),
}
vm := &trackingMockVM{
blocks: []*trackingMockBlock{blk},
}
engine.SetVM(vm)
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer engine.Stop(ctx)
// Trigger block build
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify failed: %v", err)
}
// Verify block was added
engine.mu.RLock()
_, exists := engine.pendingBlocks[blk.id]
engine.mu.RUnlock()
if !exists {
t.Fatal("Block should be in pending blocks")
}
// Send enough votes to reach quorum (3 votes for Alpha=3)
for i := 0; i < 4; i++ {
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
})
}
// Give poll loop time to process the acceptance
// The poll loop runs every 50ms
time.Sleep(300 * time.Millisecond)
// Block SHOULD be accepted after quorum
if blk.AcceptCalled() != 1 {
t.Errorf("Block Accept() should be called exactly once after quorum, but was called %d times", blk.AcceptCalled())
}
// Block should no longer be in pending
engine.mu.RLock()
_, stillPending := engine.pendingBlocks[blk.id]
engine.mu.RUnlock()
if stillPending {
t.Error("Block should be removed from pending after acceptance")
}
}
// -----------------------------------------------------------------------------
// Lifecycle invariant tests
// -----------------------------------------------------------------------------
// TestReceiveVote_DropsWhenNotStarted verifies votes are dropped when engine not started
func TestReceiveVote_DropsWhenNotStarted(t *testing.T) {
engine := New()
// DO NOT start the engine
vote := Vote{
BlockID: ids.GenerateTestID(),
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
}
// ReceiveVote should return false (dropped) when not started
accepted := engine.ReceiveVote(vote)
if accepted {
t.Error("ReceiveVote should return false when engine not started")
}
}
// TestReceiveVote_AcceptsWhenStarted verifies votes are queued when engine is started
func TestReceiveVote_AcceptsWhenStarted(t *testing.T) {
engine := New()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer engine.Stop(ctx)
vote := Vote{
BlockID: ids.GenerateTestID(),
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
}
// ReceiveVote should return true (queued) when started
accepted := engine.ReceiveVote(vote)
if !accepted {
t.Error("ReceiveVote should return true when engine is started")
}
}
// TestReceiveVote_DropsAfterStop verifies votes are dropped after engine stops
func TestReceiveVote_DropsAfterStop(t *testing.T) {
engine := New()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Stop the engine
if err := engine.Stop(ctx); err != nil {
t.Fatalf("Stop failed: %v", err)
}
vote := Vote{
BlockID: ids.GenerateTestID(),
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
}
// ReceiveVote should return false after stop
accepted := engine.ReceiveVote(vote)
if accepted {
t.Error("ReceiveVote should return false after engine stops")
}
}
// -----------------------------------------------------------------------------
// Vote correlation tests (pendingBlocks tracking)
// -----------------------------------------------------------------------------
// TestHandleVote_IgnoresUnknownBlocks verifies votes for untracked blocks are ignored
func TestHandleVote_IgnoresUnknownBlocks(t *testing.T) {
engine := New()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer engine.Stop(ctx)
// Get initial vote count
initialStats := engine.Stats()
initialReceived := initialStats["votes_received"].(uint64)
// Send vote for block that doesn't exist in pendingBlocks
unknownBlockID := ids.GenerateTestID()
engine.ReceiveVote(Vote{
BlockID: unknownBlockID,
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
})
// Give handler time to process
time.Sleep(100 * time.Millisecond)
// votes_received should increment (we received it)
stats := engine.Stats()
received := stats["votes_received"].(uint64)
if received != initialReceived+1 {
t.Errorf("Expected votes_received to increment, got %d", received)
}
// But block should NOT appear in consensus (unknown block ignored)
if engine.IsAccepted(unknownBlockID) {
t.Error("Unknown block should not be marked as accepted")
}
}
// TestHandleVote_ProcessesKnownBlocks verifies votes for tracked blocks are processed
func TestHandleVote_ProcessesKnownBlocks(t *testing.T) {
engine := NewWithParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
Beta: 2,
})
ctx := context.Background()
blk := &trackingMockBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("test-block"),
}
vm := &trackingMockVM{blocks: []*trackingMockBlock{blk}}
engine.SetVM(vm)
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer engine.Stop(ctx)
// Build block to add it to pendingBlocks
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify failed: %v", err)
}
// Verify block is in pendingBlocks
engine.mu.RLock()
pending, exists := engine.pendingBlocks[blk.id]
engine.mu.RUnlock()
if !exists {
t.Fatal("Block should be in pendingBlocks")
}
initialVoteCount := pending.VoteCount
// Send vote for known block
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
})
// Give handler time to process
time.Sleep(100 * time.Millisecond)
// VoteCount should increment (or block may have been accepted already)
engine.mu.RLock()
pending, stillExists := engine.pendingBlocks[blk.id]
var newVoteCount int
if stillExists {
newVoteCount = pending.VoteCount
}
engine.mu.RUnlock()
// Block might have been accepted and removed from pendingBlocks
// which is also valid - votes were processed and led to acceptance
if stillExists && newVoteCount <= initialVoteCount {
t.Errorf("Expected VoteCount to increment from %d, got %d", initialVoteCount, newVoteCount)
}
// If !stillExists, the block was accepted which means votes were processed successfully
}
// -----------------------------------------------------------------------------
// Accept/Reject vote handling tests
// -----------------------------------------------------------------------------
// TestProcessVote_AcceptTrueIncrementsSupport verifies Accept=true votes count toward acceptance
func TestProcessVote_AcceptTrueIncrementsSupport(t *testing.T) {
engine := NewWithParams(config.Parameters{
K: 3,
AlphaPreference: 2,
AlphaConfidence: 2,
Beta: 1,
})
ctx := context.Background()
blk := &trackingMockBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("test"),
}
vm := &trackingMockVM{blocks: []*trackingMockBlock{blk}}
engine.SetVM(vm)
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer engine.Stop(ctx)
// Build block
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify failed: %v", err)
}
// Send Accept=true votes to reach quorum
for i := 0; i < 3; i++ {
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
})
}
// Wait for acceptance
time.Sleep(200 * time.Millisecond)
// Block should be accepted
if blk.AcceptCalled() != 1 {
t.Errorf("Expected Accept() to be called once, got %d", blk.AcceptCalled())
}
}
// TestProcessVote_AcceptFalseDoesNotAccept verifies Accept=false votes don't trigger acceptance
func TestProcessVote_AcceptFalseDoesNotAccept(t *testing.T) {
engine := NewWithParams(config.Parameters{
K: 3,
AlphaPreference: 2,
AlphaConfidence: 2,
Beta: 1,
})
ctx := context.Background()
blk := &trackingMockBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("test"),
}
vm := &trackingMockVM{blocks: []*trackingMockBlock{blk}}
engine.SetVM(vm)
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer engine.Stop(ctx)
// Build block
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify failed: %v", err)
}
// Send Accept=false votes (rejections)
for i := 0; i < 5; i++ {
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: false, // Reject votes
SignedAt: time.Now(),
})
}
// Wait for processing
time.Sleep(200 * time.Millisecond)
// Block should NOT be accepted (only reject votes received)
if blk.AcceptCalled() > 0 {
t.Errorf("Accept() should NOT be called with only reject votes, got %d calls", blk.AcceptCalled())
}
}
// TestEngine_RejectsWithInsufficientSupport verifies blocks can be rejected
func TestEngine_RejectsWithInsufficientSupport(t *testing.T) {
// Use smaller parameters (K=5, Alpha=3, Beta=2)
engine := NewWithParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
Beta: 2,
})
ctx := context.Background()
blk := &trackingMockBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("block-data"),
}
vm := &trackingMockVM{
blocks: []*trackingMockBlock{blk},
}
engine.SetVM(vm)
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer engine.Stop(ctx)
// Trigger block build
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify failed: %v", err)
}
// Send reject votes (Accept=false)
for i := 0; i < 4; i++ {
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: false, // Reject votes
SignedAt: time.Now(),
})
}
// Give poll loop time to process
time.Sleep(300 * time.Millisecond)
// Block should NOT have Accept called
if blk.AcceptCalled() > 0 {
t.Errorf("Block Accept() should NOT be called for rejected block, but was called %d times", blk.AcceptCalled())
}
}
// -----------------------------------------------------------------------------
// SyncState tests (for RLP import recovery)
// -----------------------------------------------------------------------------
// TestSyncState_UpdatesConsensusState verifies SyncState correctly updates consensus.
// This is critical for recovering from admin_importChain RLP imports.
func TestSyncState_UpdatesConsensusState(t *testing.T) {
engine := New()
ctx := context.Background()
// Before sync, bootstrapped is false (engine not started)
if engine.IsBootstrapped() {
t.Error("Engine should not be bootstrapped before Start or SyncState")
}
// Simulate RLP import by syncing to a block at height 1000
importedBlockID := ids.GenerateTestID()
err := engine.SyncState(ctx, importedBlockID, 1000)
if err != nil {
t.Fatalf("SyncState failed: %v", err)
}
// After sync, engine should be bootstrapped
if !engine.IsBootstrapped() {
t.Error("Engine should be bootstrapped after SyncState")
}
// Consensus should have updated finalized tip
if engine.consensus.GetFinalizedTip() != importedBlockID {
t.Errorf("Expected finalizedTip=%s, got %s",
importedBlockID, engine.consensus.GetFinalizedTip())
}
}
// TestSyncState_ClearsStalePendingBlocks verifies SyncState removes stale blocks.
func TestSyncState_ClearsStalePendingBlocks(t *testing.T) {
engine := New()
ctx := context.Background()
// Add some pending blocks at various heights
block1 := &Block{id: ids.GenerateTestID(), height: 100}
block2 := &Block{id: ids.GenerateTestID(), height: 500}
block3 := &Block{id: ids.GenerateTestID(), height: 1500}
engine.mu.Lock()
engine.pendingBlocks[block1.id] = &PendingBlock{ConsensusBlock: block1}
engine.pendingBlocks[block2.id] = &PendingBlock{ConsensusBlock: block2}
engine.pendingBlocks[block3.id] = &PendingBlock{ConsensusBlock: block3}
engine.mu.Unlock()
// Sync to height 1000 - blocks at 100 and 500 should be cleared
importedBlockID := ids.GenerateTestID()
err := engine.SyncState(ctx, importedBlockID, 1000)
if err != nil {
t.Fatalf("SyncState failed: %v", err)
}
// Verify stale blocks were removed
engine.mu.RLock()
_, has1 := engine.pendingBlocks[block1.id]
_, has2 := engine.pendingBlocks[block2.id]
_, has3 := engine.pendingBlocks[block3.id]
engine.mu.RUnlock()
if has1 {
t.Error("Block at height 100 should be cleared (below sync height 1000)")
}
if has2 {
t.Error("Block at height 500 should be cleared (below sync height 1000)")
}
if !has3 {
t.Error("Block at height 1500 should NOT be cleared (above sync height 1000)")
}
}
// TestSyncState_Idempotent verifies calling SyncState multiple times is safe.
func TestSyncState_Idempotent(t *testing.T) {
engine := New()
ctx := context.Background()
blockID1 := ids.GenerateTestID()
blockID2 := ids.GenerateTestID()
// First sync
if err := engine.SyncState(ctx, blockID1, 100); err != nil {
t.Fatalf("First SyncState failed: %v", err)
}
if engine.consensus.GetFinalizedTip() != blockID1 {
t.Error("First sync should update finalizedTip")
}
// Second sync (higher block)
if err := engine.SyncState(ctx, blockID2, 200); err != nil {
t.Fatalf("Second SyncState failed: %v", err)
}
if engine.consensus.GetFinalizedTip() != blockID2 {
t.Error("Second sync should update finalizedTip to new block")
}
// Should still be bootstrapped
if !engine.IsBootstrapped() {
t.Error("Should remain bootstrapped after multiple SyncState calls")
}
}
// TestSyncState_WithEmptyID verifies SyncState handles empty block ID.
func TestSyncState_WithEmptyID(t *testing.T) {
engine := New()
ctx := context.Background()
// Sync with empty ID (genesis state)
err := engine.SyncState(ctx, ids.Empty, 0)
if err != nil {
t.Fatalf("SyncState with empty ID failed: %v", err)
}
// Should still bootstrap
if !engine.IsBootstrapped() {
t.Error("Should be bootstrapped even with empty block ID")
}
}
// burstVM implements BlockBuilder for throughput benchmarking.
// Simulates a GPU EVM that can produce blocks at maximum speed.
type burstVM struct {
height atomic.Uint64
accepted atomic.Uint64
parentID atomic.Value // ids.ID
txsPerBlk uint64
}
func newBurstVM(txsPerBlock uint64) *burstVM {
vm := &burstVM{txsPerBlk: txsPerBlock}
vm.parentID.Store(ids.Empty)
return vm
}
func (v *burstVM) BuildBlock(_ context.Context) (block.Block, error) {
h := v.height.Add(1)
parent, _ := v.parentID.Load().(ids.ID)
blk := &mockBlock{
id: ids.GenerateTestID(),
parentID: parent,
height: h,
timestamp: time.Now(),
bytes: make([]byte, v.txsPerBlk*100), // ~100 bytes per tx
}
return blk, nil
}
func (v *burstVM) GetBlock(_ context.Context, id ids.ID) (block.Block, error) {
return nil, errors.New("not needed for benchmark")
}
func (v *burstVM) ParseBlock(_ context.Context, b []byte) (block.Block, error) {
return &mockBlock{bytes: b}, nil
}
func (v *burstVM) LastAccepted(_ context.Context) (ids.ID, error) {
parent, _ := v.parentID.Load().(ids.ID)
return parent, nil
}
func (v *burstVM) SetPreference(_ context.Context, id ids.ID) error {
v.parentID.Store(id)
v.accepted.Add(1)
return nil
}
// BenchmarkBurstPipeline measures the consensus pipeline at maximum speed.
// This benchmarks the engine itself — how many blocks/sec can consensus
// propose, vote, finalize, and pipeline the next build.
func BenchmarkBurstPipeline(b *testing.B) {
scenarios := []struct {
name string
params config.Parameters
txsPerBlk uint64
}{
{"burst-100K-txs", singleNodeBurstParams(), 100_000},
{"local-47K-txs", singleNodeBurstParams(), 47_619},
{"default-47K-txs", config.SingleValidatorParams(), 47_619},
}
for _, sc := range scenarios {
b.Run(sc.name, func(b *testing.B) {
vm := newBurstVM(sc.txsPerBlk)
cfg := Config{
Params: sc.params,
VM: vm,
}
engine := NewWithConfig(cfg)
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
b.Fatal(err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = engine.Notify(ctx, Message{Type: PendingTxs})
}
b.StopTimer()
_ = engine.Stop(ctx)
accepted := vm.accepted.Load()
b.ReportMetric(float64(accepted), "blocks")
b.ReportMetric(float64(accepted)*float64(sc.txsPerBlk), "txs")
})
}
}
// singleNodeBurstParams returns BurstParams with K=1 for single-node benchmarking.
// Self-vote path: block is accepted synchronously in buildBlocksLocked.
func singleNodeBurstParams() config.Parameters {
p := config.BurstParams()
p.K = 1
p.Alpha = 1.0
p.Beta = 1
p.AlphaPreference = 1
p.AlphaConfidence = 1
p.BetaVirtuous = 1
p.BetaRogue = 1
return p
}
// TestBurstThroughput runs the pipeline for a fixed duration and reports TPS.
func TestBurstThroughput(t *testing.T) {
if testing.Short() {
t.Skip("skipping burst throughput test in short mode")
}
scenarios := []struct {
name string
params config.Parameters
txsPerBlk uint64
}{
{"SoloGPU-47K", config.SoloGPUParams(), 47_619},
{"Burst-100K", singleNodeBurstParams(), 100_000},
}
for _, sc := range scenarios {
t.Run(sc.name, func(t *testing.T) {
vm := newBurstVM(sc.txsPerBlk)
cfg := Config{Params: sc.params, VM: vm}
engine := NewWithConfig(cfg)
ctx, cancel := context.WithCancel(context.Background())
if err := engine.Start(ctx, true); err != nil {
t.Fatal(err)
}
duration := 3 * time.Second
deadline := time.Now().Add(duration)
notifications := 0
start := time.Now()
for time.Now().Before(deadline) {
_ = engine.Notify(ctx, Message{Type: PendingTxs})
notifications++
}
elapsed := time.Since(start)
cancel()
_ = engine.Stop(context.Background())
accepted := vm.accepted.Load()
height := vm.height.Load()
blocksPerSec := float64(accepted) / elapsed.Seconds()
tps := blocksPerSec * float64(sc.txsPerBlk)
t.Logf("")
t.Logf("=== Burst Pipeline: %s ===", sc.name)
t.Logf("Duration: %v", elapsed.Round(time.Millisecond))
t.Logf("Notifications: %d", notifications)
t.Logf("Blocks built: %d", height)
t.Logf("Blocks accepted: %d", accepted)
t.Logf("Blocks/sec: %.0f", blocksPerSec)
t.Logf("TPS (%dK/blk): %.2fM", sc.txsPerBlk/1000, tps/1_000_000)
t.Logf("Block time cfg: %v", cfg.Params.BlockTime)
t.Logf("Round timeout: %v", cfg.Params.RoundTO)
t.Logf("")
if accepted == 0 {
t.Fatalf("No blocks accepted — pipeline broken")
}
if blocksPerSec < 100 {
t.Logf("WARNING: < 100 blocks/sec — pipeline not saturated")
}
})
}
}
// --- Slashing integration tests ---
func TestSlashing_DoubleVoteDropped(t *testing.T) {
detector := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(detector, db))
ctx := context.Background()
_ = engine.Start(ctx, true)
defer engine.Stop(ctx)
// Add a pending block at height 100
blkID := ids.ID{0xAA}
blk := &Block{id: blkID, parentID: ids.Empty, height: 100, timestamp: time.Now().Unix()}
_ = engine.AddBlock(ctx, blk)
engine.mu.Lock()
engine.pendingBlocks[blkID] = &PendingBlock{
ConsensusBlock: blk,
ProposedAt: time.Now(),
}
engine.mu.Unlock()
// Also add a second block at height 100
blkID2 := ids.ID{0xBB}
blk2 := &Block{id: blkID2, parentID: ids.Empty, height: 100, timestamp: time.Now().Unix()}
_ = engine.AddBlock(ctx, blk2)
engine.mu.Lock()
engine.pendingBlocks[blkID2] = &PendingBlock{
ConsensusBlock: blk2,
ProposedAt: time.Now(),
}
engine.mu.Unlock()
var nodeA ids.NodeID
nodeA[0] = 0x01
// First vote: nodeA votes for blkID at height 100 -- should succeed
engine.ReceiveVote(Vote{BlockID: blkID, NodeID: nodeA, Accept: true})
time.Sleep(50 * time.Millisecond) // let vote handler process
engine.mu.RLock()
pending1 := engine.pendingBlocks[blkID]
vote1Count := 0
if pending1 != nil {
vote1Count = pending1.VoteCount
}
engine.mu.RUnlock()
if vote1Count != 1 {
t.Fatalf("first vote should be counted, got VoteCount=%d", vote1Count)
}
// Second vote: nodeA votes for blkID2 at height 100 -- equivocation, should be dropped
engine.ReceiveVote(Vote{BlockID: blkID2, NodeID: nodeA, Accept: true})
time.Sleep(50 * time.Millisecond)
engine.mu.RLock()
pending2 := engine.pendingBlocks[blkID2]
vote2Count := 0
if pending2 != nil {
vote2Count = pending2.VoteCount
}
engine.mu.RUnlock()
if vote2Count != 0 {
t.Fatalf("equivocating vote should be dropped, got VoteCount=%d", vote2Count)
}
// Verify evidence was recorded
rec := db.GetRecord(nodeA)
if rec == nil {
t.Fatal("slash record should exist for equivocating validator")
}
if rec.SlashCount != 1 {
t.Fatalf("expected 1 slash, got %d", rec.SlashCount)
}
if !db.IsJailed(nodeA) {
t.Fatal("equivocating validator should be jailed")
}
}
func TestSlashing_JailedVotesDropped(t *testing.T) {
detector := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(detector, db))
ctx := context.Background()
_ = engine.Start(ctx, true)
defer engine.Stop(ctx)
var nodeA ids.NodeID
nodeA[0] = 0x01
// Pre-jail nodeA
db.RecordEvidence(slashing.Evidence{
Type: slashing.DoubleVote,
ValidatorID: nodeA,
Height: 50,
Proof: []byte("test"),
})
// Add a pending block
blkID := ids.ID{0xCC}
blk := &Block{id: blkID, parentID: ids.Empty, height: 200, timestamp: time.Now().Unix()}
_ = engine.AddBlock(ctx, blk)
engine.mu.Lock()
engine.pendingBlocks[blkID] = &PendingBlock{
ConsensusBlock: blk,
ProposedAt: time.Now(),
}
engine.mu.Unlock()
// Jailed validator's vote should be dropped entirely (not counted)
engine.ReceiveVote(Vote{BlockID: blkID, NodeID: nodeA, Accept: true})
time.Sleep(50 * time.Millisecond)
engine.mu.RLock()
pending := engine.pendingBlocks[blkID]
count := 0
if pending != nil {
count = pending.VoteCount
}
engine.mu.RUnlock()
if count != 0 {
t.Fatalf("jailed validator vote should not be counted, got VoteCount=%d", count)
}
if !db.IsJailed(nodeA) {
t.Fatal("validator should be jailed")
}
}
func TestSlashing_CheckBlockProposal(t *testing.T) {
detector := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(detector, db))
var nodeA ids.NodeID
nodeA[0] = 0x01
blkID1 := ids.ID{0xAA}
blkID2 := ids.ID{0xBB}
// First block from nodeA at height 100 -- should pass
ev := engine.CheckBlockProposal(nodeA, 100, blkID1, []byte("data1"))
if ev != nil {
t.Fatal("first block should not produce evidence")
}
// Second different block from nodeA at height 100 -- equivocation
ev = engine.CheckBlockProposal(nodeA, 100, blkID2, []byte("data2"))
if ev == nil {
t.Fatal("double-sign must produce evidence")
}
if ev.Type != slashing.DoubleSign {
t.Fatalf("expected DoubleSign, got %s", ev.Type)
}
// Verify evidence recorded and validator jailed
if !db.IsJailed(nodeA) {
t.Fatal("double-signing validator should be jailed")
}
// Further proposals from jailed validator should be rejected
blkID3 := ids.ID{0xCC}
ev = engine.CheckBlockProposal(nodeA, 200, blkID3, []byte("data3"))
if ev == nil {
t.Fatal("jailed validator proposals should be rejected")
}
}
func TestSlashing_Disabled(t *testing.T) {
// Without slashing option, everything works normally
engine := New()
ctx := context.Background()
_ = engine.Start(ctx, true)
defer engine.Stop(ctx)
blkID := ids.ID{0xAA}
blk := &Block{id: blkID, parentID: ids.Empty, height: 100, timestamp: time.Now().Unix()}
_ = engine.AddBlock(ctx, blk)
engine.mu.Lock()
engine.pendingBlocks[blkID] = &PendingBlock{
ConsensusBlock: blk,
ProposedAt: time.Now(),
}
engine.mu.Unlock()
var nodeA ids.NodeID
nodeA[0] = 0x01
engine.ReceiveVote(Vote{BlockID: blkID, NodeID: nodeA, Accept: true})
time.Sleep(50 * time.Millisecond)
engine.mu.RLock()
pending := engine.pendingBlocks[blkID]
count := 0
if pending != nil {
count = pending.VoteCount
}
engine.mu.RUnlock()
if count != 1 {
t.Fatalf("vote should be counted when slashing disabled, got %d", count)
}
// CheckBlockProposal should return nil when slashing disabled
ev := engine.CheckBlockProposal(nodeA, 100, blkID, []byte("data"))
if ev != nil {
t.Fatal("CheckBlockProposal should return nil when slashing disabled")
}
}
func TestSlashing_SlashingDBAccessor(t *testing.T) {
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(slashing.NewDetector(64, 0.5), db))
got := engine.SlashingDB()
if got != db {
t.Fatal("SlashingDB() should return the configured DB")
}
engine2 := New()
if engine2.SlashingDB() != nil {
t.Fatal("SlashingDB() should return nil when slashing disabled")
}
}