Skip to content

Add Verification + Tracking Logic To Simplex Blocks #4052

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: master
Choose a base branch
from

Conversation

samliok
Copy link
Contributor

@samliok samliok commented Jul 3, 2025

Why this should be merged

In addition to calling Verify on the blocks inner vmBlock, we need to do some additional checks to ensure a simplex block can be verified. Furthermore, we need to ensure that any verified blocks are eventually accepted or rejected.

How this works

In simplex we do some pre-verification logic. However this is not enough, as we do not have access to the inner vmBlock pointed to by the prev field in the ProtocolMetadata. This means our verification logic in avalanchego must verify that prev for the block we are currently verifying points to that blocks parent. To understand more about why this important, see this test.

This PR also introduces a blockTracker struct to keep track of blocks that have been verified. When index is called on a digest, the blockTracker calls Accept on that block and Reject on any competing blocks.

How this was tested

Need to be documented in RELEASES.md?

@Copilot Copilot AI review requested due to automatic review settings July 3, 2025 21:34
@samliok samliok marked this pull request as draft July 3, 2025 21:34
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR refactors change tracking in the Merkle DB view and history layers, replacing the old keyChanges+sortedKeys approach with a unified values map and a new recordValueChange method. It also updates the view iterator to reflect the map-based storage and overhauls history retrieval functions to collapse and filter changes via the new map structure.

  • Replaces keyChanges and sortedKeys with values map and recordValueChange in view.go
  • Updates NewIteratorWithStartAndPrefix to iterate over values and then sort the results in view_iterator.go
  • Refactors history functions (getValueChanges, getChangesToGetToRoot) to use the new map-based change summary in history.go and updates tests

Reviewed Changes

Copilot reviewed 89 out of 91 changed files in this pull request and generated 1 comment.

File Description
x/merkledb/view.go Introduced recordValueChange method and values map
x/merkledb/view_iterator.go Changed iterator to range over values and sort KeyChanges
x/merkledb/history.go Refactored history change accumulation to use changeSummary
x/merkledb/history_test.go Updated tests to assert on values instead of slices
Comments suppressed due to low confidence (2)

x/merkledb/view_iterator.go:27

  • [nitpick] The local variable changes shadows v.changes and may be confusing. Consider renaming it to something like result or filteredChanges.
		changes   = make([]KeyChange, 0, len(v.changes.values))

@samliok samliok force-pushed the simplex-tracker branch from 39129f0 to 99463d4 Compare July 3, 2025 21:36
@samliok samliok marked this pull request as ready for review July 3, 2025 21:36
@samliok samliok marked this pull request as draft July 3, 2025 21:36
@samliok samliok force-pushed the simplex-tracker branch from 99463d4 to 217daff Compare July 3, 2025 21:45
@samliok samliok force-pushed the simplex-tracker branch from 24727f8 to 152bc99 Compare July 7, 2025 19:44
@samliok samliok marked this pull request as ready for review July 7, 2025 19:45
simplex/block.go Outdated

"github.com/ava-labs/simplex"

"github.com/ava-labs/avalanchego/snow/consensus/snowman"
"github.com/ava-labs/avalanchego/snow/engine/snowman/block"
"github.com/ava-labs/avalanchego/utils/hashing"
"github.com/ava-labs/avalanchego/vms/proposervm/tree"
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we move vms/proposervm/tree to somewhere like snow/tree ? It's a bit insanitary that Simplex uses something that is in proposervm

Copy link
Contributor Author

Choose a reason for hiding this comment

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

moved to utils

simplex/block.go Outdated
}

// if we do not have it in the map, it's possible for it to be the last accepted block
lastID, err := b.blockTracker.vm.LastAccepted(ctx)
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of doing that can't we load the last accepted to simplexDigestsToBlock upon creation? This way we also don't depend on the implementation of the VM's LastAccepted in this method, for instance if the VM doesn't implement this efficiently.

But the bigger benefit is that we should always find the previous block this way (unless it's the genesis block) and we have less special handling.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

simplex/block.go Outdated
return fmt.Errorf("failed to get last accepted block: %w", err)
}

if lastID != b.vmBlock.Parent() {
Copy link
Contributor

Choose a reason for hiding this comment

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

I would very much like us to explicitly make sure that the chain induced by Simplex is aligned with the chain induced by the VM, and not only the inner VMs are aligned:

prevBlock.BlockHeader().Digest == b.metadata.Prev && prevBlock.vmBlock.ID() == b.vmBlock.Parent()


bd, exists := bt.simplexDigestsToBlock[digest]
if !exists {
return fmt.Errorf("%w: %s", errDigestNotFound, digest)
Copy link
Contributor

Choose a reason for hiding this comment

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

this case isn't covered by tests. Can we add a test for this?

simplex/block.go Outdated
simplexDigestsToBlock map[simplex.Digest]*Block

// lastAcceptedID is the ID of the last accepted block at the time of the block tracker creation.
lastAcceptedID ids.ID
Copy link
Contributor

@yacovm yacovm Jul 9, 2025

Choose a reason for hiding this comment

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

shouldn't we update these two values once we we call indexBlock?

Can we make a test that is like TestVerifyInnerBlockBreaksHashChain but goes through several blocks?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

i dont think we need to update them, since we will have always have the last accepted id/digest in the block trackers memory. I do think the name is misleading though. Added to this test

yacovm
yacovm previously approved these changes Jul 10, 2025
@joshua-kim joshua-kim moved this to Ready 🚦 in avalanchego Jul 14, 2025
if b.blockTracker.isBlockAlreadyVerified(b.vmBlock) {
return b, nil
}

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we need to grab the context lock here, because we're executing this function asynchronously.

@yacovm yacovm dismissed their stale review July 14, 2025 23:12

I think that there is something I overlooked, please see comment.

return fmt.Errorf("%w: %s", errDigestNotFound, b.metadata.Prev)
}

if b.vmBlock.Parent() != prevBlock.vmBlock.ID() || b.metadata.Prev != prevBlock.digest {
Copy link
Contributor

Choose a reason for hiding this comment

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

When can b.metadata.Prev != prevBlock.digest ever be true? Removing this check doesn't break any tests.

Comment on lines +155 to +161
simplexDigestsToBlock := make(map[simplex.Digest]*Block)
simplexDigestsToBlock[latestBlock.digest] = latestBlock

return &blockTracker{
tree: tree.New(),
simplexDigestsToBlock: simplexDigestsToBlock,
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
simplexDigestsToBlock := make(map[simplex.Digest]*Block)
simplexDigestsToBlock[latestBlock.digest] = latestBlock
return &blockTracker{
tree: tree.New(),
simplexDigestsToBlock: simplexDigestsToBlock,
}
return &blockTracker{
tree: tree.New(),
simplexDigestsToBlock: map[simplex.Digest]*Block{
latestBlock.digest: latestBlock,
},
}

Comment on lines +20 to +22
// newBlockWithDigest is a helper function that creates a new block and sets its digest.
// This is helpful since otherwise we would need the blockDeserializer to create the block.
func newBlockWithDigest(t *testing.T, vmBlock snowman.Block, tracker *blockTracker, round, seq uint64, prev simplex.Digest) *Block {
Copy link
Contributor

Choose a reason for hiding this comment

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

I have found in other tests that having various newBlockWithXXX(, newBlock( ect scales really annoyingly.

If we don't foresee adding more things here, then feel free to ignore... But it might make sense to make a single newBlock function which takes in a config with reasonable zero values.

That has proven to be much more flexible in my experience.

Comment on lines +82 to +84
if b.blockTracker.isBlockAlreadyVerified(b.vmBlock) {
return b, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't we still need to add b to the simplexDigestsToBlock map here?

Comment on lines +77 to +78
err := b.verifyParentMatchesPrevBlock()
if err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

For simple checks like these (where err shouldn't be referenced later) I think we typically try to use inline if statements. The only exception to this is with very long (multi-line) statements, which are hard to read imo.

Suggested change
err := b.verifyParentMatchesPrevBlock()
if err != nil {
if err := b.verifyParentMatchesPrevBlock(); err != nil {

Comment on lines +86 to +87
err = b.vmBlock.Verify(ctx)
if err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

See above comment

Suggested change
err = b.vmBlock.Verify(ctx)
if err != nil {
if err := b.vmBlock.Verify(ctx); err != nil {

Comment on lines +99 to +100
prevBlock := b.blockTracker.getBlockByDigest(b.metadata.Prev)
if prevBlock == nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

I know that maps return the zero value when looking up something fails, and that we never expect to actually place a nil value in the map. But in avalanchego we typically do explicit success checks (so prevBlock, ok := ...)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Ready 🚦
Development

Successfully merging this pull request may close these issues.

3 participants