ceremonyclient/consensus/forks/forks_test.go
Cassandra Heart c797d482f9
v2.1.0.5 (#457)
* wip: conversion of hotstuff from flow into Q-oriented model

* bulk of tests

* remaining non-integration tests

* add integration test, adjust log interface, small tweaks

* further adjustments, restore full pacemaker shape

* add component lifecycle management+supervisor

* further refinements

* resolve timeout hanging

* mostly finalized state for consensus

* bulk of engine swap out

* lifecycle-ify most types

* wiring nearly complete, missing needed hooks for proposals

* plugged in, vetting message validation paths

* global consensus, plugged in and verified

* app shard now wired in too

* do not decode empty keys.yml (#456)

* remove obsolete engine.maxFrames config parameter (#454)

* default to Info log level unless debug is enabled (#453)

* respect config's  "logging" section params, remove obsolete single-file logging (#452)

* Trivial code cleanup aiming to reduce Go compiler warnings (#451)

* simplify range traversal

* simplify channel read for single select case

* delete rand.Seed() deprecated in Go 1.20 and no-op as of Go 1.24

* simplify range traversal

* simplify channel read for single select case

* remove redundant type from array

* simplify range traversal

* simplify channel read for single select case

* RC slate

* finalize 2.1.0.5

* Update comments in StrictMonotonicCounter

Fix comment formatting and clarify description.

---------

Co-authored-by: Black Swan <3999712+blacks1ne@users.noreply.github.com>
2025-11-11 05:00:17 -06:00

951 lines
39 KiB
Go

package forks
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"source.quilibrium.com/quilibrium/monorepo/consensus"
"source.quilibrium.com/quilibrium/monorepo/consensus/helper"
"source.quilibrium.com/quilibrium/monorepo/consensus/mocks"
"source.quilibrium.com/quilibrium/monorepo/consensus/models"
)
/*****************************************************************************
* NOTATION: *
* A state is denoted as [◄(<qc_number>) <state_rank_number>]. *
* For example, [◄(1) 2] means: a state of rank 2 that has a QC for rank 1. *
*****************************************************************************/
// TestInitialization verifies that at initialization, Forks reports:
// - the root / genesis state as finalized
// - it has no finalization proof for the root / genesis state (state and its finalization is trusted)
func TestInitialization(t *testing.T) {
forks, _ := newForks(t)
requireOnlyGenesisStateFinalized(t, forks)
_, hasProof := forks.FinalityProof()
require.False(t, hasProof)
}
// TestFinalize_Direct1Chain tests adding a direct 1-chain on top of the genesis state:
// - receives [◄(1) 2] [◄(2) 5]
//
// Expected behaviour:
// - On the one hand, Forks should not finalize any _additional_ states, because there is
// no finalizable 2-chain for [◄(1) 2]. Hence, finalization no events should be emitted.
// - On the other hand, after adding the two states, Forks has enough knowledge to construct
// a FinalityProof for the genesis state.
func TestFinalize_Direct1Chain(t *testing.T) {
builder := NewStateBuilder().
Add(1, 2).
Add(2, 3)
states, err := builder.States()
require.NoError(t, err)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
// adding state [◄(1) 2] should not finalize anything
// as the genesis state is trusted, there should be no FinalityProof available for it
require.NoError(t, forks.AddValidatedState(states[0]))
requireOnlyGenesisStateFinalized(t, forks)
_, hasProof := forks.FinalityProof()
require.False(t, hasProof)
// After adding state [◄(2) 3], Forks has enough knowledge to construct a FinalityProof for the
// genesis state. However, finalization remains at the genesis state, so no events should be emitted.
expectedFinalityProof := makeFinalityProof(t, builder.GenesisState().State, states[0], states[1].ParentQuorumCertificate)
require.NoError(t, forks.AddValidatedState(states[1]))
requireLatestFinalizedState(t, forks, builder.GenesisState().State)
requireFinalityProof(t, forks, expectedFinalityProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
// After adding CertifiedState [◄(1) 2] ◄(2), Forks has enough knowledge to construct a FinalityProof for
// the genesis state. However, finalization remains at the genesis state, so no events should be emitted.
expectedFinalityProof := makeFinalityProof(t, builder.GenesisState().State, states[0], states[1].ParentQuorumCertificate)
c, err := models.NewCertifiedState(states[0], states[1].ParentQuorumCertificate)
require.NoError(t, err)
require.NoError(t, forks.AddCertifiedState(c))
requireLatestFinalizedState(t, forks, builder.GenesisState().State)
requireFinalityProof(t, forks, expectedFinalityProof)
})
}
// TestFinalize_Direct2Chain tests adding a direct 1-chain on a direct 1-chain (direct 2-chain).
// - receives [◄(1) 2] [◄(2) 3] [◄(3) 4]
// - Forks should finalize [◄(1) 2]
func TestFinalize_Direct2Chain(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2).
Add(2, 3).
Add(3, 4).
States()
require.NoError(t, err)
expectedFinalityProof := makeFinalityProof(t, states[0], states[1], states[2].ParentQuorumCertificate)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addValidatedStateToForks(forks, states))
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addCertifiedStatesToForks(forks, states))
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
})
}
// TestFinalize_DirectIndirect2Chain tests adding an indirect 1-chain on a direct 1-chain.
// receives [◄(1) 2] [◄(2) 3] [◄(3) 5]
// it should finalize [◄(1) 2]
func TestFinalize_DirectIndirect2Chain(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2).
Add(2, 3).
Add(3, 5).
States()
require.NoError(t, err)
expectedFinalityProof := makeFinalityProof(t, states[0], states[1], states[2].ParentQuorumCertificate)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addValidatedStateToForks(forks, states))
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addCertifiedStatesToForks(forks, states))
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
})
}
// TestFinalize_IndirectDirect2Chain tests adding a direct 1-chain on an indirect 1-chain.
// - Forks receives [◄(1) 3] [◄(3) 5] [◄(7) 7]
// - it should not finalize any states because there is no finalizable 2-chain.
func TestFinalize_IndirectDirect2Chain(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 3).
Add(3, 5).
Add(5, 7).
States()
require.NoError(t, err)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addValidatedStateToForks(forks, states))
requireOnlyGenesisStateFinalized(t, forks)
_, hasProof := forks.FinalityProof()
require.False(t, hasProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addCertifiedStatesToForks(forks, states))
requireOnlyGenesisStateFinalized(t, forks)
_, hasProof := forks.FinalityProof()
require.False(t, hasProof)
})
}
// TestFinalize_Direct2ChainOnIndirect tests adding a direct 2-chain on an indirect 2-chain:
// - ingesting [◄(1) 3] [◄(3) 5] [◄(5) 6] [◄(6) 7] [◄(7) 8]
// - should result in finalization of [◄(5) 6]
func TestFinalize_Direct2ChainOnIndirect(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 3).
Add(3, 5).
Add(5, 6).
Add(6, 7).
Add(7, 8).
States()
require.NoError(t, err)
expectedFinalityProof := makeFinalityProof(t, states[2], states[3], states[4].ParentQuorumCertificate)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addValidatedStateToForks(forks, states))
requireLatestFinalizedState(t, forks, states[2])
requireFinalityProof(t, forks, expectedFinalityProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addCertifiedStatesToForks(forks, states))
requireLatestFinalizedState(t, forks, states[2])
requireFinalityProof(t, forks, expectedFinalityProof)
})
}
// TestFinalize_Direct2ChainOnDirect tests adding a sequence of direct 2-chains:
// - ingesting [◄(1) 2] [◄(2) 3] [◄(3) 4] [◄(4) 5] [◄(5) 6]
// - should result in finalization of [◄(3) 4]
func TestFinalize_Direct2ChainOnDirect(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2).
Add(2, 3).
Add(3, 4).
Add(4, 5).
Add(5, 6).
States()
require.NoError(t, err)
expectedFinalityProof := makeFinalityProof(t, states[2], states[3], states[4].ParentQuorumCertificate)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addValidatedStateToForks(forks, states))
requireLatestFinalizedState(t, forks, states[2])
requireFinalityProof(t, forks, expectedFinalityProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addCertifiedStatesToForks(forks, states))
requireLatestFinalizedState(t, forks, states[2])
requireFinalityProof(t, forks, expectedFinalityProof)
})
}
// TestFinalize_Multiple2Chains tests the case where a state can be finalized by different 2-chains.
// - ingesting [◄(1) 2] [◄(2) 3] [◄(3) 5] [◄(3) 6] [◄(3) 7]
// - should result in finalization of [◄(1) 2]
func TestFinalize_Multiple2Chains(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2).
Add(2, 3).
Add(3, 5).
Add(3, 6).
Add(3, 7).
States()
require.NoError(t, err)
expectedFinalityProof := makeFinalityProof(t, states[0], states[1], states[2].ParentQuorumCertificate)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addValidatedStateToForks(forks, states))
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addCertifiedStatesToForks(forks, states))
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
})
}
// TestFinalize_OrphanedFork tests that we can finalize a state which causes a conflicting fork to be orphaned.
// We ingest the following state tree:
//
// [◄(1) 2] [◄(2) 3]
// [◄(2) 4] [◄(4) 5] [◄(5) 6]
//
// which should result in finalization of [◄(2) 4] and pruning of [◄(2) 3]
func TestFinalize_OrphanedFork(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2). // [◄(1) 2]
Add(2, 3). // [◄(2) 3], should eventually be pruned
Add(2, 4). // [◄(2) 4], should eventually be finalized
Add(4, 5). // [◄(4) 5]
Add(5, 6). // [◄(5) 6]
States()
require.NoError(t, err)
expectedFinalityProof := makeFinalityProof(t, states[2], states[3], states[4].ParentQuorumCertificate)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addValidatedStateToForks(forks, states))
require.False(t, forks.IsKnownState(states[1].Identifier))
requireLatestFinalizedState(t, forks, states[2])
requireFinalityProof(t, forks, expectedFinalityProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addCertifiedStatesToForks(forks, states))
require.False(t, forks.IsKnownState(states[1].Identifier))
requireLatestFinalizedState(t, forks, states[2])
requireFinalityProof(t, forks, expectedFinalityProof)
})
}
// TestDuplication tests that delivering the same state/qc multiple times has
// the same end state as delivering the state/qc once.
// - Forks receives [◄(1) 2] [◄(2) 3] [◄(2) 3] [◄(3) 4] [◄(3) 4] [◄(4) 5] [◄(4) 5]
// - it should finalize [◄(2) 3]
func TestDuplication(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2).
Add(2, 3).
Add(2, 3).
Add(3, 4).
Add(3, 4).
Add(4, 5).
Add(4, 5).
States()
require.NoError(t, err)
expectedFinalityProof := makeFinalityProof(t, states[1], states[3], states[5].ParentQuorumCertificate)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addValidatedStateToForks(forks, states))
requireLatestFinalizedState(t, forks, states[1])
requireFinalityProof(t, forks, expectedFinalityProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
require.Nil(t, addCertifiedStatesToForks(forks, states))
requireLatestFinalizedState(t, forks, states[1])
requireFinalityProof(t, forks, expectedFinalityProof)
})
}
// TestIgnoreStatesBelowFinalizedRank tests that states below finalized rank are ignored.
// - Forks receives [◄(1) 2] [◄(2) 3] [◄(3) 4] [◄(1) 5]
// - it should finalize [◄(1) 2]
func TestIgnoreStatesBelowFinalizedRank(t *testing.T) {
builder := NewStateBuilder().
Add(1, 2). // [◄(1) 2]
Add(2, 3). // [◄(2) 3]
Add(3, 4). // [◄(3) 4]
Add(1, 5) // [◄(1) 5]
states, err := builder.States()
require.NoError(t, err)
expectedFinalityProof := makeFinalityProof(t, states[0], states[1], states[2].ParentQuorumCertificate)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
// initialize forks and add first 3 states:
// * state [◄(1) 2] should then be finalized
// * and state [1] should be pruned
forks, _ := newForks(t)
require.Nil(t, addValidatedStateToForks(forks, states[:3]))
// sanity checks to confirm correct test setup
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
require.False(t, forks.IsKnownState(builder.GenesisState().Identifier()))
// adding state [◄(1) 5]: note that QC is _below_ the pruning threshold, i.e. cannot resolve the parent
// * Forks should store state, despite the parent already being pruned
// * finalization should not change
orphanedState := states[3]
require.Nil(t, forks.AddValidatedState(orphanedState))
require.True(t, forks.IsKnownState(orphanedState.Identifier))
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
// initialize forks and add first 3 states:
// * state [◄(1) 2] should then be finalized
// * and state [1] should be pruned
forks, _ := newForks(t)
require.Nil(t, addCertifiedStatesToForks(forks, states[:3]))
// sanity checks to confirm correct test setup
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
require.False(t, forks.IsKnownState(builder.GenesisState().Identifier()))
// adding state [◄(1) 5]: note that QC is _below_ the pruning threshold, i.e. cannot resolve the parent
// * Forks should store state, despite the parent already being pruned
// * finalization should not change
certStateWithUnknownParent := toCertifiedState(t, states[3])
require.Nil(t, forks.AddCertifiedState(certStateWithUnknownParent))
require.True(t, forks.IsKnownState(certStateWithUnknownParent.State.Identifier))
requireLatestFinalizedState(t, forks, states[0])
requireFinalityProof(t, forks, expectedFinalityProof)
})
}
// TestDoubleProposal tests that the DoubleProposal notification is emitted when two different
// states for the same rank are added. We ingest the following state tree:
//
// / [◄(1) 2]
// [1]
// \ [◄(1) 2']
//
// which should result in a DoubleProposal event referencing the states [◄(1) 2] and [◄(1) 2']
func TestDoubleProposal(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2). // [◄(1) 2]
AddVersioned(1, 2, 0, 1). // [◄(1) 2']
States()
require.NoError(t, err)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, notifier := newForks(t)
notifier.On("OnDoubleProposeDetected", states[1], states[0]).Once()
err = addValidatedStateToForks(forks, states)
require.NoError(t, err)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, notifier := newForks(t)
notifier.On("OnDoubleProposeDetected", states[1], states[0]).Once()
err = forks.AddCertifiedState(toCertifiedState(t, states[0])) // add [◄(1) 2] as certified state
require.NoError(t, err)
err = forks.AddCertifiedState(toCertifiedState(t, states[1])) // add [◄(1) 2'] as certified state
require.NoError(t, err)
})
}
// TestConflictingQCs checks that adding 2 conflicting QCs should return models.ByzantineThresholdExceededError
// We ingest the following state tree:
//
// [◄(1) 2] [◄(2) 3] [◄(3) 4] [◄(4) 6]
// [◄(2) 3'] [◄(3') 5]
//
// which should result in a `ByzantineThresholdExceededError`, because conflicting states 3 and 3' both have QCs
func TestConflictingQCs(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2). // [◄(1) 2]
Add(2, 3). // [◄(2) 3]
AddVersioned(2, 3, 0, 1). // [◄(2) 3']
Add(3, 4). // [◄(3) 4]
Add(4, 6). // [◄(4) 6]
AddVersioned(3, 5, 1, 0). // [◄(3') 5]
States()
require.NoError(t, err)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, notifier := newForks(t)
notifier.On("OnDoubleProposeDetected", states[2], states[1]).Return(nil)
err = addValidatedStateToForks(forks, states)
assert.True(t, models.IsByzantineThresholdExceededError(err))
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, notifier := newForks(t)
notifier.On("OnDoubleProposeDetected", states[2], states[1]).Return(nil)
// As [◄(3') 5] is not certified, it will not be added to Forks. However, its QC ◄(3') is
// delivered to Forks as part of the *certified* state [◄(2) 3'].
err = addCertifiedStatesToForks(forks, states)
assert.True(t, models.IsByzantineThresholdExceededError(err))
})
}
// TestConflictingFinalizedForks checks that finalizing 2 conflicting forks should return models.ByzantineThresholdExceededError
// We ingest the following state tree:
//
// [◄(1) 2] [◄(2) 3] [◄(3) 4] [◄(4) 5]
// [◄(2) 6] [◄(6) 7] [◄(7) 8]
//
// Here, both states [◄(2) 3] and [◄(2) 6] satisfy the finalization condition, i.e. we have a fork
// in the finalized states, which should result in a models.ByzantineThresholdExceededError exception.
func TestConflictingFinalizedForks(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2).
Add(2, 3).
Add(3, 4).
Add(4, 5). // finalizes [◄(2) 3]
Add(2, 6).
Add(6, 7).
Add(7, 8). // finalizes [◄(2) 6], conflicting with conflicts with [◄(2) 3]
States()
require.NoError(t, err)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
err = addValidatedStateToForks(forks, states)
assert.True(t, models.IsByzantineThresholdExceededError(err))
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
err = addCertifiedStatesToForks(forks, states)
assert.True(t, models.IsByzantineThresholdExceededError(err))
})
}
// TestAddDisconnectedState checks that adding a state which does not connect to the
// latest finalized state returns a `models.MissingStateError`
// - receives [◄(2) 3]
// - should return `models.MissingStateError`, because the parent is above the pruning
// threshold, but Forks does not know its parent
func TestAddDisconnectedState(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2). // we will skip this state [◄(1) 2]
Add(2, 3). // [◄(2) 3]
States()
require.NoError(t, err)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, _ := newForks(t)
err := forks.AddValidatedState(states[1])
require.Error(t, err)
assert.True(t, models.IsMissingStateError(err))
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, _ := newForks(t)
err := forks.AddCertifiedState(toCertifiedState(t, states[1]))
require.Error(t, err)
assert.True(t, models.IsMissingStateError(err))
})
}
// TestGetState tests that we can retrieve stored states. Here, we test that
// attempting to retrieve nonexistent or pruned states fails without causing an exception.
// - Forks receives [◄(1) 2] [◄(2) 3] [◄(3) 4], then [◄(4) 5]
// - should finalize [◄(1) 2], then [◄(2) 3]
func TestGetState(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2). // [◄(1) 2]
Add(2, 3). // [◄(2) 3]
Add(3, 4). // [◄(3) 4]
Add(4, 5). // [◄(4) 5]
States()
require.NoError(t, err)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
statesAddedFirst := states[:3] // [◄(1) 2] [◄(2) 3] [◄(3) 4]
remainingState := states[3] // [◄(4) 5]
forks, _ := newForks(t)
// should be unable to retrieve a state before it is added
_, ok := forks.GetState(states[0].Identifier)
assert.False(t, ok)
// add first 3 states - should finalize [◄(1) 2]
err = addValidatedStateToForks(forks, statesAddedFirst)
require.NoError(t, err)
// should be able to retrieve all stored states
for _, state := range statesAddedFirst {
b, ok := forks.GetState(state.Identifier)
assert.True(t, ok)
assert.Equal(t, state, b)
}
// add remaining state [◄(4) 5] - should finalize [◄(2) 3] and prune [◄(1) 2]
require.Nil(t, forks.AddValidatedState(remainingState))
// should be able to retrieve just added state
b, ok := forks.GetState(remainingState.Identifier)
assert.True(t, ok)
assert.Equal(t, remainingState, b)
// should be unable to retrieve pruned state
_, ok = forks.GetState(statesAddedFirst[0].Identifier)
assert.False(t, ok)
})
// Caution: finalization is driven by QCs. Therefore, we include the QC for state 3
// in the first batch of states that we add. This is analogous to previous test case,
// except that we are delivering the QC ◄(3) as part of the certified state of rank 2
// [◄(2) 3] ◄(3)
// while in the previous sub-test, the QC ◄(3) was delivered as part of state [◄(3) 4]
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
statesAddedFirst := toCertifiedStates(t, states[:2]...) // [◄(1) 2] [◄(2) 3] ◄(3)
remainingState := toCertifiedState(t, states[2]) // [◄(3) 4] ◄(4)
forks, _ := newForks(t)
// should be unable to retrieve a state before it is added
_, ok := forks.GetState(states[0].Identifier)
assert.False(t, ok)
// add first states - should finalize [◄(1) 2]
err := forks.AddCertifiedState(statesAddedFirst[0])
require.NoError(t, err)
err = forks.AddCertifiedState(statesAddedFirst[1])
require.NoError(t, err)
// should be able to retrieve all stored states
for _, state := range statesAddedFirst {
b, ok := forks.GetState(state.State.Identifier)
assert.True(t, ok)
assert.Equal(t, state.State, b)
}
// add remaining state [◄(4) 5] - should finalize [◄(2) 3] and prune [◄(1) 2]
require.Nil(t, forks.AddCertifiedState(remainingState))
// should be able to retrieve just added state
b, ok := forks.GetState(remainingState.State.Identifier)
assert.True(t, ok)
assert.Equal(t, remainingState.State, b)
// should be unable to retrieve pruned state
_, ok = forks.GetState(statesAddedFirst[0].State.Identifier)
assert.False(t, ok)
})
}
// TestGetStatesForRank tests retrieving states for a rank (also including double proposals).
// - Forks receives [◄(1) 2] [◄(2) 4] [◄(2) 4'],
// where [◄(2) 4'] is a double proposal, because it has the same rank as [◄(2) 4]
//
// Expected behaviour:
// - Forks should store all the states
// - Forks should emit a `OnDoubleProposeDetected` notification
// - we can retrieve all states, including the double proposals
func TestGetStatesForRank(t *testing.T) {
states, err := NewStateBuilder().
Add(1, 2). // [◄(1) 2]
Add(2, 4). // [◄(2) 4]
AddVersioned(2, 4, 0, 1). // [◄(2) 4']
States()
require.NoError(t, err)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, notifier := newForks(t)
notifier.On("OnDoubleProposeDetected", states[2], states[1]).Once()
err = addValidatedStateToForks(forks, states)
require.NoError(t, err)
// expect 1 state at rank 2
storedStates := forks.GetStatesForRank(2)
assert.Len(t, storedStates, 1)
assert.Equal(t, states[0], storedStates[0])
// expect 2 states at rank 4
storedStates = forks.GetStatesForRank(4)
assert.Len(t, storedStates, 2)
assert.ElementsMatch(t, states[1:], storedStates)
// expect 0 states at rank 3
storedStates = forks.GetStatesForRank(3)
assert.Len(t, storedStates, 0)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, notifier := newForks(t)
notifier.On("OnDoubleProposeDetected", states[2], states[1]).Once()
err := forks.AddCertifiedState(toCertifiedState(t, states[0]))
require.NoError(t, err)
err = forks.AddCertifiedState(toCertifiedState(t, states[1]))
require.NoError(t, err)
err = forks.AddCertifiedState(toCertifiedState(t, states[2]))
require.NoError(t, err)
// expect 1 state at rank 2
storedStates := forks.GetStatesForRank(2)
assert.Len(t, storedStates, 1)
assert.Equal(t, states[0], storedStates[0])
// expect 2 states at rank 4
storedStates = forks.GetStatesForRank(4)
assert.Len(t, storedStates, 2)
assert.ElementsMatch(t, states[1:], storedStates)
// expect 0 states at rank 3
storedStates = forks.GetStatesForRank(3)
assert.Len(t, storedStates, 0)
})
}
// TestNotifications tests that Forks emits the expected events:
// - Forks receives [◄(1) 2] [◄(2) 3] [◄(3) 4]
//
// Expected Behaviour:
// - Each of the ingested states should result in an `OnStateIncorporated` notification
// - Forks should finalize [◄(1) 2], resulting in a `MakeFinal` event and an `OnFinalizedState` event
func TestNotifications(t *testing.T) {
builder := NewStateBuilder().
Add(1, 2).
Add(2, 3).
Add(3, 4)
states, err := builder.States()
require.NoError(t, err)
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
notifier := &mocks.Consumer[*helper.TestState, *helper.TestVote]{}
// 4 states including the genesis are incorporated
notifier.On("OnStateIncorporated", mock.Anything).Return(nil).Times(4)
notifier.On("OnFinalizedState", states[0]).Once()
finalizationCallback := mocks.NewFinalizer(t)
finalizationCallback.On("MakeFinal", states[0].Identifier).Return(nil).Once()
forks, err := NewForks(builder.GenesisState(), finalizationCallback, notifier)
require.NoError(t, err)
require.NoError(t, addValidatedStateToForks(forks, states))
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
notifier := &mocks.Consumer[*helper.TestState, *helper.TestVote]{}
// 4 states including the genesis are incorporated
notifier.On("OnStateIncorporated", mock.Anything).Return(nil).Times(4)
notifier.On("OnFinalizedState", states[0]).Once()
finalizationCallback := mocks.NewFinalizer(t)
finalizationCallback.On("MakeFinal", states[0].Identifier).Return(nil).Once()
forks, err := NewForks(builder.GenesisState(), finalizationCallback, notifier)
require.NoError(t, err)
require.NoError(t, addCertifiedStatesToForks(forks, states))
})
}
// TestFinalizingMultipleStates tests that `OnFinalizedState` notifications are emitted in correct order
// when there are multiple states finalized by adding a _single_ state.
// - receiving [◄(1) 3] [◄(3) 5] [◄(5) 7] [◄(7) 11] [◄(11) 12] should not finalize any states,
// because there is no 2-chain with the first chain link being a _direct_ 1-chain
// - adding [◄(12) 22] should finalize up to state [◄(6) 11]
//
// This test verifies the following expected properties:
// 1. Safety under reentrancy:
// While Forks is single-threaded, there is still the possibility of reentrancy. Specifically, the
// consumers of our finalization events are served by the goroutine executing Forks. It is conceivable
// that a consumer might access Forks and query the latest finalization proof. This would be legal, if
// the component supplying the goroutine to Forks also consumes the notifications. Therefore, for API
// safety, we require forks to _first update_ its `FinalityProof()` before it emits _any_ events.
// 2. For each finalized state, `finalizationCallback` event is executed _before_ `OnFinalizedState` notifications.
// 3. States are finalized in order of increasing height (without skipping any states).
func TestFinalizingMultipleStates(t *testing.T) {
builder := NewStateBuilder().
Add(1, 3). // index 0: [◄(1) 2]
Add(3, 5). // index 1: [◄(2) 4]
Add(5, 7). // index 2: [◄(4) 6]
Add(7, 11). // index 3: [◄(6) 11] -- expected to be finalized
Add(11, 12). // index 4: [◄(11) 12]
Add(12, 22) // index 5: [◄(12) 22]
states, err := builder.States()
require.NoError(t, err)
// The Finality Proof should right away point to the _latest_ finalized state. Subsequently emitting
// Finalization events for lower states is fine, because notifications are guaranteed to be
// _eventually_ arriving. I.e. consumers expect notifications / events to be potentially lagging behind.
expectedFinalityProof := makeFinalityProof(t, states[3], states[4], states[5].ParentQuorumCertificate)
setupForksAndAssertions := func() (*Forks[*helper.TestState, *helper.TestVote], *mocks.Finalizer, *mocks.Consumer[*helper.TestState, *helper.TestVote]) {
// initialize Forks with custom event consumers so we can check order of emitted events
notifier := &mocks.Consumer[*helper.TestState, *helper.TestVote]{}
finalizationCallback := mocks.NewFinalizer(t)
notifier.On("OnStateIncorporated", mock.Anything).Return(nil)
forks, err := NewForks(builder.GenesisState(), finalizationCallback, notifier)
require.NoError(t, err)
// expecting finalization of [◄(1) 2] [◄(2) 4] [◄(4) 6] [◄(6) 11] in this order
statesAwaitingFinalization := toStateAwaitingFinalization(states[:4])
finalizationCallback.On("MakeFinal", mock.Anything).Run(func(args mock.Arguments) {
requireFinalityProof(t, forks, expectedFinalityProof) // Requirement 1: forks should _first update_ its `FinalityProof()` before it emits _any_ events
// Requirement 3: finalized in order of increasing height (without skipping any states).
expectedNextFinalizationEvents := statesAwaitingFinalization[0]
require.Equal(t, expectedNextFinalizationEvents.State.Identifier, args[0])
// Requirement 2: finalized state, `finalizationCallback` event is executed _before_ `OnFinalizedState` notifications.
// no duplication of events under normal operations expected
require.False(t, expectedNextFinalizationEvents.MakeFinalCalled)
require.False(t, expectedNextFinalizationEvents.OnFinalizedStateEmitted)
expectedNextFinalizationEvents.MakeFinalCalled = true
}).Return(nil).Times(4)
notifier.On("OnFinalizedState", mock.Anything).Run(func(args mock.Arguments) {
requireFinalityProof(t, forks, expectedFinalityProof) // Requirement 1: forks should _first update_ its `FinalityProof()` before it emits _any_ events
// Requirement 3: finalized in order of increasing height (without skipping any states).
expectedNextFinalizationEvents := statesAwaitingFinalization[0]
require.Equal(t, expectedNextFinalizationEvents.State, args[0])
// Requirement 2: finalized state, `finalizationCallback` event is executed _before_ `OnFinalizedState` notifications.
// no duplication of events under normal operations expected
require.True(t, expectedNextFinalizationEvents.MakeFinalCalled)
require.False(t, expectedNextFinalizationEvents.OnFinalizedStateEmitted)
expectedNextFinalizationEvents.OnFinalizedStateEmitted = true
// At this point, `MakeFinal` and `OnFinalizedState` have both been emitted for the state, so we are done with it
statesAwaitingFinalization = statesAwaitingFinalization[1:]
}).Times(4)
return forks, finalizationCallback, notifier
}
t.Run("consensus participant mode: ingest validated states", func(t *testing.T) {
forks, finalizationCallback, notifier := setupForksAndAssertions()
err = addValidatedStateToForks(forks, states[:5]) // adding [◄(1) 2] [◄(2) 4] [◄(4) 6] [◄(6) 11] [◄(11) 12]
require.NoError(t, err)
requireOnlyGenesisStateFinalized(t, forks) // finalization should still be at the genesis state
require.NoError(t, forks.AddValidatedState(states[5])) // adding [◄(12) 22] should trigger finalization events
requireFinalityProof(t, forks, expectedFinalityProof)
finalizationCallback.AssertExpectations(t)
notifier.AssertExpectations(t)
})
t.Run("consensus follower mode: ingest certified states", func(t *testing.T) {
forks, finalizationCallback, notifier := setupForksAndAssertions()
// adding [◄(1) 2] [◄(2) 4] [◄(4) 6] [◄(6) 11] ◄(11)
require.NoError(t, forks.AddCertifiedState(toCertifiedState(t, states[0])))
require.NoError(t, forks.AddCertifiedState(toCertifiedState(t, states[1])))
require.NoError(t, forks.AddCertifiedState(toCertifiedState(t, states[2])))
require.NoError(t, forks.AddCertifiedState(toCertifiedState(t, states[3])))
require.NoError(t, err)
requireOnlyGenesisStateFinalized(t, forks) // finalization should still be at the genesis state
// adding certified state [◄(11) 12] ◄(12) should trigger finalization events
require.NoError(t, forks.AddCertifiedState(toCertifiedState(t, states[4])))
requireFinalityProof(t, forks, expectedFinalityProof)
finalizationCallback.AssertExpectations(t)
notifier.AssertExpectations(t)
})
}
//* ************************************* internal functions ************************************* */
func newForks(t *testing.T) (*Forks[*helper.TestState, *helper.TestVote], *mocks.Consumer[*helper.TestState, *helper.TestVote]) {
notifier := mocks.NewConsumer[*helper.TestState, *helper.TestVote](t)
notifier.On("OnStateIncorporated", mock.Anything).Return(nil).Maybe()
notifier.On("OnFinalizedState", mock.Anything).Maybe()
finalizationCallback := mocks.NewFinalizer(t)
finalizationCallback.On("MakeFinal", mock.Anything).Return(nil).Maybe()
genesisBQ := makeGenesis()
forks, err := NewForks(genesisBQ, finalizationCallback, notifier)
require.NoError(t, err)
return forks, notifier
}
// addValidatedStateToForks adds all the given states to Forks, in order.
// If any errors occur, returns the first one.
func addValidatedStateToForks(forks *Forks[*helper.TestState, *helper.TestVote], states []*models.State[*helper.TestState]) error {
for _, state := range states {
err := forks.AddValidatedState(state)
if err != nil {
return fmt.Errorf("test failed to add state for rank %d: %w", state.Rank, err)
}
}
return nil
}
// addCertifiedStatesToForks iterates over all states, caches them locally in a map,
// constructs certified states whenever possible and adds the certified states to forks,
// Note: if states is a single fork, the _last state_ in the slice will not be added,
//
// because there is no qc for it
//
// If any errors occur, returns the first one.
func addCertifiedStatesToForks(forks *Forks[*helper.TestState, *helper.TestVote], states []*models.State[*helper.TestState]) error {
uncertifiedStates := make(map[models.Identity]*models.State[*helper.TestState])
for _, b := range states {
uncertifiedStates[b.Identifier] = b
parentID := b.ParentQuorumCertificate.Identity()
parent, found := uncertifiedStates[parentID]
if !found {
continue
}
delete(uncertifiedStates, parentID)
certParent, err := models.NewCertifiedState(parent, b.ParentQuorumCertificate)
if err != nil {
return fmt.Errorf("test failed to creat certified state for rank %d: %w", certParent.State.Rank, err)
}
err = forks.AddCertifiedState(certParent)
if err != nil {
return fmt.Errorf("test failed to add certified state for rank %d: %w", certParent.State.Rank, err)
}
}
return nil
}
// requireLatestFinalizedState asserts that the latest finalized state has the given rank and qc rank.
func requireLatestFinalizedState(t *testing.T, forks *Forks[*helper.TestState, *helper.TestVote], expectedFinalized *models.State[*helper.TestState]) {
require.Equal(t, expectedFinalized, forks.FinalizedState(), "finalized state is not as expected")
require.Equal(t, forks.FinalizedRank(), expectedFinalized.Rank, "FinalizedRank returned wrong value")
}
// requireOnlyGenesisStateFinalized asserts that no states have been finalized beyond the genesis state.
// Caution: does not inspect output of `forks.FinalityProof()`
func requireOnlyGenesisStateFinalized(t *testing.T, forks *Forks[*helper.TestState, *helper.TestVote]) {
genesis := makeGenesis()
require.Equal(t, forks.FinalizedState(), genesis.State, "finalized state is not the genesis state")
require.Equal(t, forks.FinalizedState().Rank, genesis.State.Rank)
require.Equal(t, forks.FinalizedState().Rank, genesis.CertifyingQuorumCertificate.GetRank())
require.Equal(t, forks.FinalizedRank(), genesis.State.Rank, "finalized state has wrong qc")
finalityProof, isKnown := forks.FinalityProof()
require.Nil(t, finalityProof, "expecting finality proof to be nil for genesis state at initialization")
require.False(t, isKnown, "no finality proof should be known for genesis state at initialization")
}
// requireNoStatesFinalized asserts that no states have been finalized (genesis is latest finalized state).
func requireFinalityProof(t *testing.T, forks *Forks[*helper.TestState, *helper.TestVote], expectedFinalityProof *consensus.FinalityProof[*helper.TestState]) {
finalityProof, isKnown := forks.FinalityProof()
require.True(t, isKnown)
require.Equal(t, expectedFinalityProof, finalityProof)
require.Equal(t, forks.FinalizedState(), expectedFinalityProof.State)
require.Equal(t, forks.FinalizedRank(), expectedFinalityProof.State.Rank)
}
// toCertifiedState generates a QC for the given state and returns their combination as a certified state
func toCertifiedState(t *testing.T, state *models.State[*helper.TestState]) *models.CertifiedState[*helper.TestState] {
qc := &helper.TestQuorumCertificate{
Rank: state.Rank,
Selector: state.Identifier,
}
cb, err := models.NewCertifiedState(state, qc)
require.NoError(t, err)
return cb
}
// toCertifiedStates generates a QC for the given state and returns their combination as a certified states
func toCertifiedStates(t *testing.T, states ...*models.State[*helper.TestState]) []*models.CertifiedState[*helper.TestState] {
certStates := make([]*models.CertifiedState[*helper.TestState], 0, len(states))
for _, b := range states {
certStates = append(certStates, toCertifiedState(t, b))
}
return certStates
}
func makeFinalityProof(t *testing.T, state *models.State[*helper.TestState], directChild *models.State[*helper.TestState], qcCertifyingChild models.QuorumCertificate) *consensus.FinalityProof[*helper.TestState] {
c, err := models.NewCertifiedState(directChild, qcCertifyingChild) // certified child of FinalizedState
require.NoError(t, err)
return &consensus.FinalityProof[*helper.TestState]{State: state, CertifiedChild: c}
}
// stateAwaitingFinalization is intended for tracking finalization events and their order for a specific state
type stateAwaitingFinalization struct {
State *models.State[*helper.TestState]
MakeFinalCalled bool // indicates whether `Finalizer.MakeFinal` was called
OnFinalizedStateEmitted bool // indicates whether `OnFinalizedStateCalled` notification was emitted
}
// toStateAwaitingFinalization creates a `stateAwaitingFinalization` tracker for each input state
func toStateAwaitingFinalization(states []*models.State[*helper.TestState]) []*stateAwaitingFinalization {
trackers := make([]*stateAwaitingFinalization, 0, len(states))
for _, b := range states {
tracker := &stateAwaitingFinalization{b, false, false}
trackers = append(trackers, tracker)
}
return trackers
}