Files
zeekay 395eab9874 consensus(engine/chain): real alpha-of-K quorum-cert finality (no proposer self-finality)
THE BUG (CFT, not BFT): a proposer self-finalized its own block on its lone
self-vote (ForceAccept) and a peer's REJECT was counted as ACCEPT for the
proposer's own block (effectiveAccept = ... || IsOwnProposal). Followers
fast-followed unverified gossip and courtesy-voted only back to the proposer
(broken vote topology), so they could never reach threshold themselves. An
equivocating proposer could finalize two valid roots at one height -> fork.

THE FIX:
- QuorumCert (engine/chain/quorum_cert.go): the alpha-of-K finality witness.
  Assembles from alpha distinct correctly-signed ACCEPT votes over the
  canonical position; Verify enforces threshold + strictly-increasing-distinct
  voters + per-vote signature via a pluggable VoteVerifier. Wire codec for
  gossip (quorum_cert_codec.go). Quasar bridge (quorum_cert_quasar.go): upgrades
  to protocol/quasar.WeightedQuorumCert with real ML-DSA records + weighted
  Merkle inclusion when the node supplies the PQ validator set; fails closed
  otherwise. PROVEN: quasar's own Verify accepts the bridged cert.
- ForceAccept demoted to single-validator (K==1) only; refuses K>1 (fail-closed).
- effectiveAccept REJECT->ACCEPT flip deleted. Vote signatures verified at
  ingestion (multi-validator): forged/unsigned/wrong-position votes dropped, so
  the count, the cert, and IsAccepted stay consistent and forgery-proof.
- Topology rebuilt: followers broadcast SIGNED votes to ALL validators; whoever
  collects alpha assembles + gossips the cert; followers finalize on the
  verified cert (HandleIncomingCert) instead of fast-following. Liveness held:
  late/dropped Chits no longer freeze (cert gossip + re-request), no self-finality.
- Fail-closed DEX guard (quorum_guard.go): RequireQuorumFinalityForValueDEX
  refuses value DEX unless Mode()==QuorumFinality (K>1 + verifier).
- Start refuses K>1 without a verifier (forces the node bump to wire it).

Gates (all pass, race-clean): TestConsensus_NoProposerSelfFinality,
_AcceptRequiresQuorum, _EquivocatingProposerCannotFinalizeBothForks,
_PartitionMinorityCannotFinalize, TestDEX_BFTQuorumFinalizesFill,
_ByzantineProposerFakeFillRejected, TestLiveness_NoFreezeUnderLateChits,
_DroppedProposerChitsStillFinalizeViaPeerCert, TestQuasarBridge_RealCertVerifies.
194 engine/chain tests pass.
2026-06-22 16:32:28 -07:00

448 lines
12 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"context"
"errors"
"testing"
"time"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
)
// mockImportVM simulates a VM that has had blocks imported via RLP.
// It tracks the last accepted block and supports SetPreference.
type mockImportVM struct {
lastAcceptedID ids.ID
lastAcceptedErr error
blocks map[ids.ID]*mockBlock
getBlockErr error
setPreferenceID ids.ID
setPreferenceErr error
setPreferenceCalls int
buildBlockErr error
parseBlockErr error
}
func newMockImportVM() *mockImportVM {
return &mockImportVM{
blocks: make(map[ids.ID]*mockBlock),
}
}
func (m *mockImportVM) LastAccepted(ctx context.Context) (ids.ID, error) {
return m.lastAcceptedID, m.lastAcceptedErr
}
func (m *mockImportVM) GetBlock(ctx context.Context, id ids.ID) (block.Block, error) {
if m.getBlockErr != nil {
return nil, m.getBlockErr
}
blk, ok := m.blocks[id]
if !ok {
return nil, errors.New("block not found")
}
return blk, nil
}
func (m *mockImportVM) SetPreference(ctx context.Context, id ids.ID) error {
m.setPreferenceCalls++
m.setPreferenceID = id
return m.setPreferenceErr
}
func (m *mockImportVM) BuildBlock(ctx context.Context) (block.Block, error) {
return nil, m.buildBlockErr
}
func (m *mockImportVM) ParseBlock(ctx context.Context, data []byte) (block.Block, error) {
return nil, m.parseBlockErr
}
// TestOnImportComplete_BasicSync verifies that OnImportComplete correctly
// syncs consensus state after an RLP import.
func TestOnImportComplete_BasicSync(t *testing.T) {
require := require.New(t)
ctx := context.Background()
// Create a block at height 1000 (simulating RLP import result)
importedBlockID := ids.GenerateTestID()
importedBlock := &mockBlock{
id: importedBlockID,
parentID: ids.GenerateTestID(),
height: 1000,
timestamp: time.Now(),
}
// Setup mock VM with imported state
vm := newMockImportVM()
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
// Create runtime with mock VM
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
})
// Before sync: engine not bootstrapped (not started)
require.False(rt.IsBootstrapped(), "Engine should not be bootstrapped before sync")
// Call OnImportComplete
err := rt.OnImportComplete(ctx)
require.NoError(err, "OnImportComplete should succeed")
// Verify consensus is now bootstrapped
require.True(rt.IsBootstrapped(), "Engine should be bootstrapped after OnImportComplete")
// Verify VM preference was set
require.Equal(1, vm.setPreferenceCalls, "SetPreference should be called once")
require.Equal(importedBlockID, vm.setPreferenceID, "SetPreference should use imported block ID")
// Verify consensus state was updated
require.Equal(importedBlockID, rt.consensus.GetFinalizedTip(),
"Consensus finalizedTip should match imported block")
}
// TestOnImportComplete_HighHeight tests sync with a very high block height
// (simulating importing a mature chain).
func TestOnImportComplete_HighHeight(t *testing.T) {
require := require.New(t)
ctx := context.Background()
// Create a block at height 10 million
importedBlockID := ids.GenerateTestID()
importedBlock := &mockBlock{
id: importedBlockID,
parentID: ids.GenerateTestID(),
height: 10_000_000,
timestamp: time.Now(),
}
vm := newMockImportVM()
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
})
err := rt.OnImportComplete(ctx)
require.NoError(err)
require.True(rt.IsBootstrapped())
require.Equal(importedBlockID, rt.consensus.GetFinalizedTip())
}
// TestOnImportComplete_EmptyBlock tests sync when VM returns empty block ID
// (e.g., genesis-only chain).
func TestOnImportComplete_EmptyBlock(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := newMockImportVM()
vm.lastAcceptedID = ids.Empty // No blocks imported yet
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
})
err := rt.OnImportComplete(ctx)
require.NoError(err)
require.True(rt.IsBootstrapped(), "Should be bootstrapped even with empty last accepted")
}
// TestOnImportComplete_VMError tests that VM errors are handled gracefully.
func TestOnImportComplete_VMError(t *testing.T) {
require := require.New(t)
ctx := context.Background()
vm := newMockImportVM()
vm.lastAcceptedErr = errors.New("database error")
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
})
err := rt.OnImportComplete(ctx)
require.Error(err, "Should return error when VM fails")
}
// TestOnImportComplete_GetBlockError tests that GetBlock errors are non-fatal.
func TestOnImportComplete_GetBlockError(t *testing.T) {
require := require.New(t)
ctx := context.Background()
importedBlockID := ids.GenerateTestID()
vm := newMockImportVM()
vm.lastAcceptedID = importedBlockID
vm.getBlockErr = errors.New("block not found") // GetBlock fails
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
})
// Should still succeed - GetBlock failure is non-fatal
err := rt.OnImportComplete(ctx)
require.NoError(err)
require.True(rt.IsBootstrapped())
require.Equal(importedBlockID, vm.setPreferenceID, "SetPreference should still be called")
}
// TestOnImportComplete_SetPreferenceError tests that SetPreference errors are non-fatal.
func TestOnImportComplete_SetPreferenceError(t *testing.T) {
require := require.New(t)
ctx := context.Background()
importedBlockID := ids.GenerateTestID()
importedBlock := &mockBlock{
id: importedBlockID,
height: 500,
}
vm := newMockImportVM()
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
vm.setPreferenceErr = errors.New("preference error") // SetPreference fails
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
})
// Should still succeed - SetPreference failure is non-fatal
err := rt.OnImportComplete(ctx)
require.NoError(err)
require.True(rt.IsBootstrapped())
}
// TestOnImportComplete_Idempotent tests that calling OnImportComplete multiple
// times is safe and idempotent.
func TestOnImportComplete_Idempotent(t *testing.T) {
require := require.New(t)
ctx := context.Background()
importedBlockID := ids.GenerateTestID()
importedBlock := &mockBlock{
id: importedBlockID,
height: 100,
}
vm := newMockImportVM()
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
})
// Call multiple times
for i := 0; i < 5; i++ {
err := rt.OnImportComplete(ctx)
require.NoError(err)
require.True(rt.IsBootstrapped())
require.Equal(importedBlockID, rt.consensus.GetFinalizedTip())
}
// SetPreference called each time
require.Equal(5, vm.setPreferenceCalls)
}
// TestOnImportComplete_NilVM tests that nil VM is handled gracefully.
func TestOnImportComplete_NilVM(t *testing.T) {
require := require.New(t)
ctx := context.Background()
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: nil, // No VM
})
err := rt.OnImportComplete(ctx)
require.NoError(err, "Should succeed with nil VM")
}
// TestOnImportComplete_AfterStart tests sync after engine has already started.
func TestOnImportComplete_AfterStart(t *testing.T) {
require := require.New(t)
ctx := context.Background()
// Start with genesis
genesisID := ids.GenerateTestID()
genesisBlock := &mockBlock{
id: genesisID,
height: 0,
}
vm := newMockImportVM()
vm.lastAcceptedID = genesisID
vm.blocks[genesisID] = genesisBlock
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
VoteVerifier: testAuth, // multi-validator engine requires a verifier
})
// Start the engine
err := rt.Start(ctx, true)
require.NoError(err)
require.True(rt.IsBootstrapped())
// Now simulate RLP import - update VM state to new block
importedBlockID := ids.GenerateTestID()
importedBlock := &mockBlock{
id: importedBlockID,
parentID: genesisID,
height: 5000,
}
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
// Sync after import
vm.setPreferenceCalls = 0 // Reset counter
err = rt.OnImportComplete(ctx)
require.NoError(err)
require.True(rt.IsBootstrapped())
require.Equal(importedBlockID, rt.consensus.GetFinalizedTip())
require.Equal(importedBlockID, vm.setPreferenceID)
}
// TestSyncStateFromVM tests the standalone sync function.
func TestSyncStateFromVM(t *testing.T) {
require := require.New(t)
ctx := context.Background()
importedBlockID := ids.GenerateTestID()
importedBlock := &mockBlock{
id: importedBlockID,
height: 750,
}
vm := newMockImportVM()
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
engine := newTestEngine()
blockID, height, err := SyncStateFromVM(ctx, vm, engine)
require.NoError(err)
require.Equal(importedBlockID, blockID)
require.Equal(uint64(750), height)
require.True(engine.IsBootstrapped())
}
// TestSyncStateFromVM_NilConsensus tests sync with nil consensus engine.
func TestSyncStateFromVM_NilConsensus(t *testing.T) {
require := require.New(t)
ctx := context.Background()
importedBlockID := ids.GenerateTestID()
importedBlock := &mockBlock{
id: importedBlockID,
height: 100,
}
vm := newMockImportVM()
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
blockID, height, err := SyncStateFromVM(ctx, vm, nil)
require.NoError(err)
require.Equal(importedBlockID, blockID)
require.Equal(uint64(100), height)
}
// TestOnImportComplete_ClearsPendingBlocks tests that pending blocks below
// the synced height are cleared.
func TestOnImportComplete_ClearsPendingBlocks(t *testing.T) {
require := require.New(t)
ctx := context.Background()
// Setup blocks
oldBlockID := ids.GenerateTestID()
oldBlock := &mockBlock{id: oldBlockID, height: 50}
importedBlockID := ids.GenerateTestID()
importedBlock := &mockBlock{id: importedBlockID, height: 1000}
vm := newMockImportVM()
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
})
// Add a pending block at old height
rt.Transitive.pendingBlocks[oldBlockID] = &PendingBlock{
ConsensusBlock: &Block{id: oldBlockID, height: 50},
VMBlock: oldBlock,
}
// Sync should clear old pending blocks
err := rt.OnImportComplete(ctx)
require.NoError(err)
// Old pending block should be cleared
_, exists := rt.Transitive.pendingBlocks[oldBlockID]
require.False(exists, "Old pending blocks should be cleared after sync")
}
// TestOnImportComplete_PreservesNewPendingBlocks tests that pending blocks
// above the synced height are preserved.
func TestOnImportComplete_PreservesNewPendingBlocks(t *testing.T) {
require := require.New(t)
ctx := context.Background()
// Setup blocks
newBlockID := ids.GenerateTestID()
newBlock := &mockBlock{id: newBlockID, height: 1050}
importedBlockID := ids.GenerateTestID()
importedBlock := &mockBlock{id: importedBlockID, height: 1000}
vm := newMockImportVM()
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
})
// Add a pending block ABOVE synced height
rt.Transitive.pendingBlocks[newBlockID] = &PendingBlock{
ConsensusBlock: &Block{id: newBlockID, height: 1050},
VMBlock: newBlock,
}
// Sync should preserve new pending blocks
err := rt.OnImportComplete(ctx)
require.NoError(err)
// New pending block should be preserved
_, exists := rt.Transitive.pendingBlocks[newBlockID]
require.True(exists, "New pending blocks should be preserved after sync")
}