From 8c88609444af43613902b6681ccd56890316b1de Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 2 Jul 2026 14:47:46 +0200 Subject: [PATCH 1/2] Garbage collect blacklist upon epoch change This commit garbage collects the blacklist upon an epoch change by initializing an empty one by constructing the previous blacklist in verification or block building. Signed-off-by: Yacov Manevich --- simplex/epoch.go | 10 ++++++++++ simplex/epoch_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/simplex/epoch.go b/simplex/epoch.go index 5d24c7e2..8045bd53 100644 --- a/simplex/epoch.go +++ b/simplex/epoch.go @@ -2274,6 +2274,11 @@ func (e *Epoch) verifyProposalMetadataAndBlacklist(block common.Block) bool { prevBlacklist = prevBlock.Blacklist() + // If the previous block belongs to an earlier epoch, the blacklist should be empty. + if prevBlock.BlockHeader().Epoch < e.Epoch { + prevBlacklist = common.NewBlacklist(uint16(len(e.validatorNodeIDs))) + } + if prevBlacklist.IsEmpty() { prevBlacklist = common.NewBlacklist(uint16(len(e.validatorNodeIDs))) } @@ -2428,6 +2433,11 @@ func (e *Epoch) retrieveBlacklistOfParentBlock(metadata common.ProtocolMetadata) } blacklist = prevBlock.Blacklist() + + // If the previous block belongs to a previous epoch, we need to reset the blacklist to be empty. + if metadata.Epoch > prevBlock.BlockHeader().Epoch { + blacklist = common.NewBlacklist(uint16(len(e.validatorNodeIDs))) + } } if blacklist.IsEmpty() { diff --git a/simplex/epoch_test.go b/simplex/epoch_test.go index 5485e142..7e017a88 100644 --- a/simplex/epoch_test.go +++ b/simplex/epoch_test.go @@ -737,6 +737,43 @@ func TestEpochSimpleFlow(t *testing.T) { } } +func TestEpochResizesBlacklistOnEpochChange(t *testing.T) { + storage := testutil.NewInMemStorage() + epoch1Block := testutil.NewTestBlock(ProtocolMetadata{Epoch: 1, Round: 0, Seq: 0}, NewBlacklist(1)) + require.NoError(t, storage.Index(context.Background(), epoch1Block, Finalization{})) + require.Equal(t, uint16(1), epoch1Block.Blacklist().NodeCount, + "blacklist must contain exactly one node") + + // Restart the node into epoch 2 with a two-validator set. + nodes := []NodeID{{1}, {2}} + bb := testutil.NewTestBlockBuilder() + conf, wal, _ := testutil.DefaultTestNodeEpochConfig(t, NodeID{2}, testutil.NewNoopComm(nodes), bb) + conf.Storage = storage + conf.Epoch = 2 + + e, err := NewEpoch(conf) + require.NoError(t, err) + // Run as epoch 2 while the last committed block still belongs to epoch 1. + e.Epoch = conf.Epoch + t.Cleanup(e.Stop) + require.NoError(t, e.Start()) + + // The node (leader) builds the next block on top of the epoch-1 block. Its + // blacklist must be sized for the new validator set (2), not inherited from the + // parent (1) — otherwise its blacklist is malformed and the block cannot be + // notarized. + bb.BlockShouldBeBuilt <- struct{}{} + block := bb.GetBuiltBlock() + require.Equal(t, uint16(2), block.Blacklist().NodeCount, + "blacklist must be resized to the new epoch's validator count") + + // The other validator votes for the block; together with the node's own vote + // that is a quorum, so the node persists a notarization for the block's round. + // This proves the blacklist has been verified by this node. + testutil.InjectTestVote(t, e, block, NodeID{1}) + wal.AssertNotarization(block.BlockHeader().Round) +} + func TestEpochStartedTwice(t *testing.T) { bb := testutil.NewTestBlockBuilder() From 0fc23d58a840695f85482a670385302426b1183a Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Thu, 2 Jul 2026 19:54:21 +0200 Subject: [PATCH 2/2] Address code review comments Signed-off-by: Yacov Manevich --- simplex/epoch.go | 4 ++-- simplex/epoch_test.go | 48 +++++++++++++++++++++++++++++-------------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/simplex/epoch.go b/simplex/epoch.go index 8045bd53..13b10f70 100644 --- a/simplex/epoch.go +++ b/simplex/epoch.go @@ -2374,7 +2374,7 @@ func (e *Epoch) locateBlock(seq uint64, digest []byte) (common.VerifiedBlock, *n func (e *Epoch) buildBlock() { metadata := e.metadata() - prevBlacklist, ok := e.retrieveBlacklistOfParentBlock(metadata) + prevBlacklist, ok := e.retrieveBlacklistForBlockBuilding(metadata) if !ok { return } @@ -2419,7 +2419,7 @@ func (e *Epoch) buildBlock() { e.buildBlockScheduler.ScheduleOrReplace(task) } -func (e *Epoch) retrieveBlacklistOfParentBlock(metadata common.ProtocolMetadata) (common.Blacklist, bool) { +func (e *Epoch) retrieveBlacklistForBlockBuilding(metadata common.ProtocolMetadata) (common.Blacklist, bool) { var blacklist common.Blacklist if metadata.Seq > 0 { prevBlock, _, ok := e.locateBlock(metadata.Seq-1, metadata.Prev[:]) diff --git a/simplex/epoch_test.go b/simplex/epoch_test.go index 7e017a88..1e000d03 100644 --- a/simplex/epoch_test.go +++ b/simplex/epoch_test.go @@ -738,25 +738,20 @@ func TestEpochSimpleFlow(t *testing.T) { } func TestEpochResizesBlacklistOnEpochChange(t *testing.T) { - storage := testutil.NewInMemStorage() epoch1Block := testutil.NewTestBlock(ProtocolMetadata{Epoch: 1, Round: 0, Seq: 0}, NewBlacklist(1)) - require.NoError(t, storage.Index(context.Background(), epoch1Block, Finalization{})) - require.Equal(t, uint16(1), epoch1Block.Blacklist().NodeCount, - "blacklist must contain exactly one node") - - // Restart the node into epoch 2 with a two-validator set. nodes := []NodeID{{1}, {2}} bb := testutil.NewTestBlockBuilder() - conf, wal, _ := testutil.DefaultTestNodeEpochConfig(t, NodeID{2}, testutil.NewNoopComm(nodes), bb) - conf.Storage = storage + conf, _, _ := testutil.DefaultTestNodeEpochConfig(t, NodeID{2}, testutil.NewNoopComm(nodes), bb) conf.Epoch = 2 + require.NoError(t, conf.Storage.Index(context.Background(), epoch1Block, Finalization{})) + require.Equal(t, uint16(1), epoch1Block.Blacklist().NodeCount, + "blacklist must contain exactly one node") e, err := NewEpoch(conf) require.NoError(t, err) - // Run as epoch 2 while the last committed block still belongs to epoch 1. e.Epoch = conf.Epoch - t.Cleanup(e.Stop) require.NoError(t, e.Start()) + require.Equal(t, uint64(2), e.Metadata().Epoch) // The node (leader) builds the next block on top of the epoch-1 block. Its // blacklist must be sized for the new validator set (2), not inherited from the @@ -766,12 +761,35 @@ func TestEpochResizesBlacklistOnEpochChange(t *testing.T) { block := bb.GetBuiltBlock() require.Equal(t, uint16(2), block.Blacklist().NodeCount, "blacklist must be resized to the new epoch's validator count") + e.Stop() + + // Next, create the other node (follower) and ensure it can verify the block. + conf, wal, _ := testutil.DefaultTestNodeEpochConfig(t, NodeID{1}, testutil.NewNoopComm(nodes), bb) + conf.Epoch = 2 + + require.NoError(t, conf.Storage.Index(context.Background(), epoch1Block, Finalization{})) + require.Equal(t, uint16(1), epoch1Block.Blacklist().NodeCount, + "blacklist must contain exactly one node") + + e, err = NewEpoch(conf) + require.NoError(t, err) + e.Epoch = conf.Epoch + require.NoError(t, e.Start()) + t.Cleanup(e.Stop) + require.Equal(t, uint64(2), e.Metadata().Epoch) + + vote, err := testutil.NewTestVote(block, nodes[1]) + require.NoError(t, err) + + err = e.HandleMessage(&Message{ + BlockMessage: &BlockMessage{ + Vote: *vote, + Block: block, + }, + }, nodes[1]) + require.NoError(t, err) + wal.AssertNotarization(1) - // The other validator votes for the block; together with the node's own vote - // that is a quorum, so the node persists a notarization for the block's round. - // This proves the blacklist has been verified by this node. - testutil.InjectTestVote(t, e, block, NodeID{1}) - wal.AssertNotarization(block.BlockHeader().Round) } func TestEpochStartedTwice(t *testing.T) {