Skip to content

Ensure Non-Validators and Validators Bootstrap Before Indexing and Verifying Blocks - #573

Open
samliok wants to merge 16 commits into
mainfrom
bootstrap-first
Open

samliok wants to merge 16 commits into
mainfrom
bootstrap-first

Conversation

@samliok

@samliok samliok commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

Addresses #530 but for non-validator and validators.

Nodes will first need to complete bootstrapping before being able to index/verify blocks.

@samliok samliok self-assigned this Sep 1, 2026
@samliok
samliok force-pushed the bootstrap-first branch 3 times, most recently from 6be0d9c to 23d9c67 Compare September 1, 2026 23:55
@samliok
samliok force-pushed the bootstrap-first branch 2 times, most recently from 9c76fa7 to 9501164 Compare September 2, 2026 16:18
Base automatically changed from instance-test-refactor to main September 4, 2026 17:58
@samliok
samliok force-pushed the bootstrap-first branch 6 times, most recently from a1a3fb9 to b507b64 Compare September 9, 2026 18:30
Comment thread nonvalidator/epochs.go Outdated
@yacovm

yacovm commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Looks like this test demonstrates we have a liveness problem in case we miss the first broadcast we send via broadcastLatestEpoch:

// TestNonValidatorBootstrapsWhenStartupBroadcastIsLost asserts a non-validator still bootstraps when
// nobody receives the replication request it broadcasts on start. The non-validator joins an empty
// network, so that broadcast reaches no one, and the validator only comes online afterwards. Until it
// bootstraps, the non-validator drops every block and finalization the validator sends it, so the only
// way for it to recover is to ask again.
func TestNonValidatorBootstrapsWhenStartupBroadcastIsLost(t *testing.T) {
	validator := newNodeMapping(1)
	pChain := newTestPChain([]metadata.NodeBLSMapping{validator})
	network := newNetwork(t, pChain)

	// The non-validator starts alone, so the replication request it broadcasts on start is lost.
	nonValidator := newNodeMapping(2)
	node := network.addNode(nonValidator.NodeID[:])
	isValidator, bootstrapped := node.role()
	require.False(t, isValidator)
	require.False(t, bootstrapped)

	// The validator then comes online and commits the first block on its own,
	// broadcasting its finalization to the non-validator, which drops it.
	network.addNode(validator.NodeID[:]).sync()

	// The validator is there to answer, so the non-validator must eventually ask again and bootstrap.
	require.Eventually(t, func() bool {
		_, bootstrapped := node.role()
		return bootstrapped
	}, 20*time.Second, 100*time.Millisecond, "the non-validator never bootstrapped after its startup broadcast was lost")

	node.sync()
}

@yacovm

yacovm commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

In the issue #530 I wrote:

What we should do instead is when we bootstrap the node, we replicate only the sealing blocks backwards, until we reach the latest committed sealing block, and then we keep the chain of sealing blocks and use them and never replicate them by verifying quorum certificates.

The below test demonstrates that that's not what we do:

// TestNonValidatorRejectsSealingBlockOffTheHashChain asserts a bootstrapping non-validator only accepts
// sealing blocks that lie on the backward hash chain from the sealing block it bootstrapped on, however
// well they are signed. See https://github.com/ava-labs/Simplex/issues/530.
//
// The node has committed epoch 1's defining block. The current validator set tells it the latest sealing
// block is at seq 3, whose PrevSealingBlockHash names an honest sealing block at seq 2. A peer then serves
// a forged sealing block at seq 2 that carries a valid quorum certificate of epoch 1's validators but is
// not the block seq 3 points to. Epoch 1's keys may have leaked long ago, so the forged block must be
// rejected. Today the node validates it by its quorum certificate alone and indexes it.
func TestNonValidatorRejectsSealingBlockOffTheHashChain(t *testing.T) {
	validator := newNodeMapping(1)
	validators := metadata.NodeBLSMappings{validator}
	signers := []common.NodeID{validator.NodeID[:]}
	sigAggregator := &testutil.TestSignatureAggregator{N: len(validators)}

	pChain := newTestPChain(validators)
	storage, epochBlock := newChainStorage(t, validators)

	// A node outside the validator set whose ledger ends at epoch 1's defining block.
	nonValidator := newNodeMapping(2)
	node := newNetwork(t, pChain).addNodeWithConfig(nonValidator.NodeID[:], nodeConfig{storage: storage})
	isValidator, bootstrapped := node.role()
	require.False(t, isValidator)
	require.False(t, bootstrapped)

	// sealingBlock builds a sealing block at seq in the given epoch, keeping the same validator set.
	sealingBlock := func(seq, epoch uint64, prev, prevSealingBlockHash [32]byte, payload string) *ParsedBlock {
		timestamp := epochBlockTime.Add(time.Duration(seq) * time.Millisecond)
		return &ParsedBlock{StateMachineBlock: metadata.StateMachineBlock{
			InnerBlock: &testInnerBlock{Height_: seq, TS: timestamp, Payload: []byte(payload)},
			Metadata: metadata.StateMachineMetadata{
				Timestamp:               uint64(timestamp.UnixMilli()),
				SimplexProtocolMetadata: common.ProtocolMetadata{Epoch: epoch, Round: seq, Seq: seq, Prev: common.Digest(prev)},
				SimplexEpochInfo: metadata.SimplexEpochInfo{
					EpochNumber:          epoch,
					PrevSealingBlockHash: prevSealingBlockHash,
					BlockValidationDescriptor: &metadata.BlockValidationDescriptor{
						AggregatedMembership: metadata.AggregatedMembership{Members: validators},
					},
				},
			},
		}}
	}

	// The honest chain: seq 1 (in storage) <- honest seq 2 <- seq 3. The forged seq 2 is signed by the
	// same epoch 1 validators but is not what seq 3 points to.
	honestSealing2 := sealingBlock(2, 1, epochBlock.Digest(), epochBlock.Digest(), "honest sealing block 2")
	forgedSealing2 := sealingBlock(2, 1, epochBlock.Digest(), epochBlock.Digest(), "forged sealing block 2")
	require.NotEqual(t, honestSealing2.Digest(), forgedSealing2.Digest())
	sealing3 := sealingBlock(3, 2, honestSealing2.Digest(), honestSealing2.Digest(), "sealing block 3")

	honestFinalization2, _ := testutil.NewFinalizationRecord(t, sigAggregator, honestSealing2, signers)
	forgedFinalization2, _ := testutil.NewFinalizationRecord(t, sigAggregator, forgedSealing2, signers)
	finalization3, _ := testutil.NewFinalizationRecord(t, sigAggregator, sealing3, signers)

	// The current validator set reports seq 3 as the latest sealing block, which bootstraps the node.
	require.NoError(t, node.inst.HandleMessage(&common.Message{
		ReplicationResponse: &common.ReplicationResponse{
			LatestSeq: &common.QuorumRound{Block: sealing3, Finalization: &finalization3},
		},
	}, validator.NodeID[:]))
	_, bootstrapped = node.role()
	require.True(t, bootstrapped)

	// A peer serves the forged seq 2. Its quorum certificate checks out against epoch 1, but its digest
	// is not the PrevSealingBlockHash of seq 3, so it is off the hash chain and must not be indexed.
	require.NoError(t, node.inst.HandleMessage(&common.Message{
		ReplicationResponse: &common.ReplicationResponse{
			Data: []common.QuorumRound{{Block: forgedSealing2, Finalization: &forgedFinalization2}},
		},
	}, validator.NodeID[:]))
	require.Never(t, func() bool {
		_, _, err := node.storage.Retrieve(2)
		return err == nil
	}, 2*time.Second, 50*time.Millisecond, "indexed a sealing block that is not on the hash chain from the sealing block we bootstrapped on")

	// The honest seq 2 is what seq 3 points to, so it is accepted.
	require.NoError(t, node.inst.HandleMessage(&common.Message{
		ReplicationResponse: &common.ReplicationResponse{
			Data: []common.QuorumRound{{Block: honestSealing2, Finalization: &honestFinalization2}},
		},
	}, validator.NodeID[:]))
	committed := node.storage.WaitForBlockCommit(2)
	require.Equal(t, honestSealing2.Bytes(), committed.Bytes())
}

Comment thread nonvalidator/non_validator.go Outdated
return nil
}

n.Logger.Info("Bootstrapped, received a threshold of sealing block info for an epoch", zap.Stringer("Info", qr.Block.SealingBlockInfo()))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only consider ourselves as bootstrapped when we have replicated and committed all blocks from the last block in the ledger to the last known tip.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we can consider ourselves bootstrapped once we have validated the hash chain of sealing blocks, then we can start as normal syncing all the blocks in between

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as to your test, i made a few non-validator tests to ensure we do the backwards hash validation first

TestNonValidator_BootstrapIgnoresSealingBlockOffChain && TestNonValidator_BootstrapWalksHashChain

@samliok
samliok marked this pull request as draft September 10, 2026 14:25
@samliok
samliok marked this pull request as ready for review September 10, 2026 18:45

@yacovm yacovm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the comments I have so far, I'm not nearly done with the review.

Comment thread common/api.go
Comment thread common/timeout_handler.go Outdated
Comment thread instance_test.go Outdated
Comment thread nonvalidator/non_validator.go Outdated
// and it is in the validator set
TransitionToValidator func(epoch uint64, validators common.Nodes)

// Bootstrapped is set once every epoch from our tip up to the one a threshold of the latest

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we call that bootstrapped? Bootstrapped should just mean that we finished bootstrapping, exactly like we do in snowman. Which is that we have replicated all blocks we know are missing from the latest discovered tip down to the tip before bootstrapping.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated from bootstrapped terminology to EpochsReplicated 57c282e

return nil
}

// No sealing block is missing, so every epoch from our tip to the highest is validated.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but we should still replicate all blocks in the last epoch that we know about before we declare that we have finished bootstrapping.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment is still relevant

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated from bootstrapped terminology to EpochsReplicated 57c282e

Comment thread instance.go
}

comm := newCommunication(i.Config.Sender, i.Config.Broadcaster, mappings.Nodes())
comm := newCommunication(i.Config.Sender, i.Config.Broadcaster, latestValidatorSet.Nodes())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated to this PR, but... what updates the comm's validator set once we move through epochs after we bootstrap?

@samliok samliok Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea we need to change the non-validator comm to not hardcode its Validators() method. everytime it calls Validators the pchain should be queried or something

Comment thread instance.go
Comment thread nonvalidator/non_validator.go Outdated
Comment thread nonvalidator/epochs.go
Comment thread nonvalidator/non_validator.go
for _, seq := range seqs {
n.Logger.Debug("Re-requesting a sealing block", zap.Uint64("Seq", seq))
n.Comm.Broadcast(&common.Message{
ReplicationRequest: &common.ReplicationRequest{Seqs: []uint64{seq}},

@yacovm yacovm Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we add here the LatestFinalizedSeq or call broadcastLatestEpoch?

Seems like the initial broadcastLatestEpoch we do in Start() only works once now, no?

@samliok samliok Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yea good point


n.sealingBlockTimeouts.RemoveTask(nextEpoch)

// The first simplex block opens its own epoch, so there is no earlier sealing block.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how is this comment relevant to the code below it?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it about prevSealingSeq == nextEpoch ?

}
}

// validateSealingBlock validates the epoch a sealing block opens and stores its quorum round.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure validateX is the right way to call this method.

Judging by the name I would expect it should check the sealing block and return if it's valid or not.

In practice the method stores it or even sends request to fetch the previous one.

Can we give it a more descriptive name?

@yacovm yacovm Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can call this processSealingBlock and call maybeValidateNextEpoch - maybeReplicateNextEpoch ?

And can we add a comment saying that we expect the given block to have already been authenticated here and in maybeValidateNextEpoch?


func (n *NonValidator) maybeValidateNextEpoch(block common.Block) {
nextEpoch := block.BlockHeader().Seq
// maybeValidateNextEpoch validates the epoch block opens when block is a sealing block. While

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybeValidateNextEpoch validates the epoch block opens when block is a sealing block.

I can't grok this sentence. The epoch block opens what?

if block.SealingBlockInfo() != nil {
highestEpoch, highestValidatorSet := n.epochs.highestEpoch()

// We should only transition to become a validator, if the sealing block is creating the highest

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you fold the below lines into maybeTransitionToValidator ?

if !n.isIndexed(bh.Seq) {
n.validateSealingBlock(qr, from)
}
case n.highestEpochCollector.collectedSealingBlockInfo(sealingInfo, bh, from):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can rename collectedSealingBlockInfo to something that reflects better its implementation or what it actually stands for?

Perhaps maybeObserveThresholdResponses() ?

Comment thread instance.go
}

func (i *Instance) maybeReplicateEpochs() error {
i.Config.Logger.Debug("Checking if epoch replication is required")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be something like: "Checking if latest persisted validator set is up to date" ?

Comment thread instance.go
// We have indexed the latest validator set, therefore we can skip epoch replication and start as a validator.
// Note: this may not be the latest epoch, but a future PR will eventually notice we are behind and transition properly.
if latestIndexedEpochValidators.Equal(latestValidatorSet.Nodes()) && latestValidatorSet.Nodes().Contains(i.Config.ID) {
i.Config.Logger.Debug("Node skipping epoch replication because its latest epoch is up to date with the Platform Chain")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should prefer writing log messages that are understandable to node operators and not to us.

So instead of "epoch replication" let's say "validator set replication" here and below.

I think it conveys more what we're trying to accomplish.

Comment thread instance_test.go
network.setOnline(v3.NodeID[:])
node.sync()

// The only way for the epoch transition to finish is if ourNode becomes a validator

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we check the node is also a validator now?

}

// TestNonValidator_EpochReplicationWalksHashChain asserts a non-validator several epochs behind requests
// sealing blocks one hop back at a time once a threshold reports the highest one, and finishes

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why "once" and not "starting from" ?

}

// TestNonValidator_EpochReplicationIgnoresSealingBlockOffChain asserts that while following the hash chain
// a sealing block further down it is dropped until the epoch pointing back to it has been validated.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a sealing block further down it is dropped until the epoch pointing back to it has been validated

I am not sure I understand what this means. Why is a sealing block dropped? How can something be dropped until something?

require.NoError(t, err)
defer nv.Stop()

inOurEpoch := tc.appendBlock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code below is confusing regarding which epoch corresponds to which block.

Can we add some kind of require.Equal(expectedEpoch, block.Epoch) after to make this more clear?

@@ -1047,6 +1058,291 @@ func TestNonValidatorRejectsQuorumRoundFromNonValidator(t *testing.T) {
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't we missing a non_validator_test test case that shows that when a new non-validator is added, it replicates the sealing blocks first and afterwards the blocks between them?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validate sealing blocks only via backward hash chain validation on bootstrap instead of also via QC

2 participants