Skip to content

Conversation

@fbac
Copy link
Collaborator

@fbac fbac commented Apr 11, 2025

Implement Merkle tree data structure with proof generation and verification in pkg/merkle

  • Introduces core Merkle tree implementation in tree.go with MerkleTree struct and tree construction logic
  • Adds Keccak-256 hashing functions in hash.go for leaf and internal node hashing
  • Implements proof generation and verification in proof.go with MultiProof struct
  • Creates specialized proof handling for sequential elements in proof_sequential.go
  • Adds index-based proof generation and verification in proof_indices.go
  • Includes comprehensive test coverage in tree_test.go

📍Where to Start

Start with the NewMerkleTree function in tree.go which implements the core tree construction logic and serves as the entry point for the package.


Macroscope summarized 5eb4a35.

Summary by CodeRabbit

  • New Features
    • Introduced cryptographic hashing functions with distinct prefixes for leaf and node data to support Merkle tree integrity.
    • Added a Merkle tree data structure with balanced tree construction and multi-proof generation for both sequential and arbitrary leaf indices.
    • Implemented comprehensive multi-proof verification with robust validation and error handling.
  • Tests
    • Added extensive tests covering tree construction, hashing correctness, multi-proof generation and verification, edge cases, and utility functions to ensure reliability and correctness.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Apr 11, 2025

Walkthrough

This pull request introduces several new files in the pkg/merkle package, implementing hashing operations, a Merkle tree data structure, and multi-proof generation and verification for Merkle trees. It adds new constants, functions, methods, and utility functions that support reliable tree creation, proof generation, and proof validation. Additionally, comprehensive test suites are provided for both the Merkle tree and multi-proof functionalities, covering various tree configurations, edge cases, and error conditions.

Changes

File(s) Change Summary
pkg/merkle/hash.go New file implementing hashing operations for Merkle trees. Introduces the LEAF_PREFIX and NODE_PREFIX constants and functions Hash, HashNode, HashLeaf, and HashEmptyLeaf which use the Keccak-256 hash algorithm from the sha3 package with prefixed inputs for leaf and node hashing.
pkg/merkle/proof.go New file providing a comprehensive implementation for Merkle multi-proofs. Introduces the MultiProof struct and supporting types (IndexedValue, IndexedValues, nodeQueue). Implements methods for proof validation, root computation using balanced leaf counts and queue-based traversal, and error handling. Provides the Verify function to validate multi-proofs against a given root. Includes helper functions for indices validation and child position determination.
pkg/merkle/tree.go New file implementing the Merkle tree data structure. Defines the MerkleTree, Node, and Leaf types. Implements tree construction with balanced leaf counts, hashing of leaves and internal nodes, and methods to generate multi-proofs for sequential or arbitrary leaf indices. Includes utility functions for tree balancing, child node indexing, and error handling.
pkg/merkle/tree_test.go New test file providing extensive unit tests for the Merkle tree implementation. Tests cover empty trees, balanced and unbalanced trees of various sizes, large trees, duplicate and empty elements, and internal tree structure validation. Also tests utility functions related to leaf count balancing and power-of-two rounding. Uses the testify package for assertions and error checking.
pkg/merkle/proof_test.go New test file providing comprehensive unit tests for Merkle multi-proof generation and verification. Tests cover sequential and arbitrary multi-proof generation, verification success and failure scenarios, edge cases like single-leaf trees, invalid inputs, and proof immutability. Uses helper functions for deterministic leaves and asserts correctness with the testify package.

Sequence Diagram(s)

sequenceDiagram
    participant U as User
    participant MT as MerkleTree
    participant ML as makeNodes
    participant MTREE as makeTree

    U->>MT: NewMerkleTree(leaves)
    MT->>ML: makeNodes(leaves)
    ML-->>MT: nodes (hashed leaves)
    MT->>MTREE: makeTree(nodes)
    MTREE-->>MT: tree structure & root
    MT-->>U: MerkleTree instance
Loading
sequenceDiagram
    participant U as User
    participant MT as MerkleTree
    participant GP as GenerateMultiProof
    participant VP as Verify

    U->>MT: GenerateMultiProofSequential(startIndex, count)
    MT->>GP: makeProof(tree, root, indices, leafCount)
    GP-->>MT: MultiProof instance
    MT-->>U: MultiProof instance

    U->>VP: Verify(root, MultiProof)
    VP->>VP: validate inputs and proof
    VP->>VP: computeRoot from elements and proofs
    VP-->>U: Verification result (true/false, error)
Loading
sequenceDiagram
    participant U as User
    participant MT as MerkleTree
    participant GP as GenerateMultiProofWithIndices
    participant VP as Verify

    U->>MT: GenerateMultiProofWithIndices(indices)
    MT->>GP: makeProof(tree, root, indices, leafCount)
    GP-->>MT: MultiProof instance
    MT-->>U: MultiProof instance

    U->>VP: Verify(root, MultiProof)
    VP->>VP: validate inputs and proof
    VP->>VP: computeRoot from elements and proofs
    VP-->>U: Verification result (true/false, error)
Loading

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (1.64.8)

Error: you are using a configuration file for golangci-lint v2 with golangci-lint v1: please use golangci-lint v2
Failed executing command with error: you are using a configuration file for golangci-lint v2 with golangci-lint v1: please use golangci-lint v2

Tip

⚡💬 Agentic Chat (Pro Plan, General Availability)
  • We're introducing multi-step agentic chat in review comments and issue comments, within and outside of PR's. This feature enhances review and issue discussions with the CodeRabbit agentic chat by enabling advanced interactions, including the ability to create pull requests directly from comments and add commits to existing pull requests.
✨ Finishing Touches
  • 📝 Generate Docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (8)
pkg/merkle/hash.go (1)

11-16: Consider using the direct Summation-based Keccak256 helper for clarity.
Using sha3.NewLegacyKeccak256() and hash.Write(buffer) is totally valid here. However, if performance or simplicity is desired when hashing single buffers, you might consider using a direct summation approach such as sha3.SumLegacyKeccak256(buffer) to avoid manual hasher creation and writing.

pkg/merkle/proof_indices.go (3)

43-43: Consolidation TODO.
The plan to abstract out shared logic from both sequential and index-based verification is laudable. This can reduce code duplication in future.


44-81: Verification quietly returns (false, nil) on invalid proof.
When getRootIndices returns nil, the code returns (false, nil) rather than an error. Consider returning (false, error) to provide clearer diagnostic feedback to callers.


83-212: Complex but logical root computation.
While thorough and correct, this routine is quite large. You could consider modularizing it into smaller functions to simplify readability and maintenance.

pkg/merkle/proof.go (2)

28-36: Consider validating out-of-range or duplicate indices in generateProof.

Currently, generateProof does not explicitly check whether indices exceed the tree’s range or if they contain duplicates. Although these checks may be performed in the calling functions, you might strengthen error safety by ensuring that indices are valid here as well, preventing potential out-of-bounds access on line 60 (leafCount + idx).


155-183: Potential performance bottleneck in combineLeaves.

For very large sets of leaves, repeatedly creating a new level by hashing adjacent pairs could be optimized via parallel processing or by minimizing intermediate slice allocations. If performance at scale becomes a concern, consider a more efficient approach or concurrency in safe contexts.

pkg/merkle/tree.go (1)

19-26: Validate non-nil elements on tree creation.

In NewMerkleTree, there is no check for nil elements in the input slice. If an element is nil, HashLeaf(nil) might behave unexpectedly or degrade security. Consider validating them here to ensure consistent leaf data.

pkg/merkle/proof_sequential.go (1)

89-200: Simplify or further document getRootSequentially.

The circular queue approach is correct but quite complex, potentially impacting maintainability and readability. Adding more explanatory comments or breaking out sub-steps into helper functions may help future contributors understand and maintain this logic.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 95c58dd and 5eb4a35.

📒 Files selected for processing (6)
  • pkg/merkle/hash.go (1 hunks)
  • pkg/merkle/proof.go (1 hunks)
  • pkg/merkle/proof_indices.go (1 hunks)
  • pkg/merkle/proof_sequential.go (1 hunks)
  • pkg/merkle/tree.go (1 hunks)
  • pkg/merkle/tree_test.go (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (3)
pkg/merkle/proof_indices.go (3)
pkg/merkle/tree.go (1)
  • MerkleTree (10-16)
pkg/merkle/proof.go (1)
  • MultiProof (9-16)
pkg/merkle/hash.go (2)
  • HashLeaf (24-26)
  • HashNode (19-21)
pkg/merkle/proof_sequential.go (3)
pkg/merkle/tree.go (1)
  • MerkleTree (10-16)
pkg/merkle/proof.go (1)
  • MultiProof (9-16)
pkg/merkle/hash.go (2)
  • HashLeaf (24-26)
  • HashNode (19-21)
pkg/merkle/proof.go (1)
pkg/merkle/hash.go (1)
  • HashNode (19-21)
⏰ Context from checks skipped due to timeout of 90000ms (5)
  • GitHub Check: Code Review
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd-cli)
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd)
  • GitHub Check: Test (Node)
  • GitHub Check: Upgrade Tests
🔇 Additional comments (15)
pkg/merkle/hash.go (3)

7-9: Good use of a dedicated leaf prefix.
This clear separation of leaf nodes helps avoid potential collision or ambiguity in hashing different node types.


18-21: Straightforward node-hashing approach.
No functional concerns; this is a neat and consistent way to combine child nodes.


23-26: Good leaf-hashing pattern with the prefix.
This ensures that leaf nodes cannot collide with internal node concatenations, which is critical for Merkle tree security.

pkg/merkle/tree_test.go (9)

10-18: Meaningful empty-tree test case.
Properly ensures that constructing an empty Merkle tree returns an error as expected.


20-81: Comprehensive balanced-tree scenarios.
These tests methodically verify that the tree’s depth, leaf count, and array size are computed accurately for powers-of-two element counts.


83-114: Robust unbalanced-tree testing.
Validates correctness when the element count isn’t a power of two. This coverage prevents overlooking off-by-one or indexing errors.


116-172: Large-tree stress tests.
These scenarios provide excellent coverage for performance and correctness under higher loads. Sampling leaves is a practical approach to keep tests efficient.


174-196: Duplicate elements test.
Well-handled test verifying that identical leaves yield identical hashes, while unique elements differ. Great for ensuring collision correctness.


198-216: Validation of large byte-slice elements.
Ensures the process scales to significant input sizes and the final root remains correct.


218-239: Empty-element handling.
Shows that empty leaves still result in a valid hash, which is critical for flexible usage scenarios.


241-285: Manual verification of internal nodes.
Covers indexing logic thoroughly in a small, unbalanced tree. Excellent approach for verifying complex tree structures.


286-332: Helper function for unbalanced structure integrity.
This function effectively checks parent-child relationships, ensuring no corner cases are missed.

pkg/merkle/proof_indices.go (2)

9-41: Efficient multi-proof generation with early validations.
Duplicates and out-of-range checks guard correctness by ensuring that proof generation starts only with valid indices.


214-247: Robust proof indices validation.
Ensures the indices match the elements and fall within valid bounds, detecting duplicates early. This is crucial for the multi-proof’s reliability.

pkg/merkle/proof.go (1)

98-132: Good coverage in validateProofBase.

This function thoroughly checks for nil roots, missing elements, invalid element counts, and partial proof decommitments. It effectively prevents many common proof pitfalls.


// roundUpToPowerOf2 rounds up a number to the next power of 2.
// Rounding up to the next power of 2 is necessary to ensure that the tree is balanced.
func roundUpToPowerOf2(number uint32) uint32 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

fancy bit smearing!

I like this one more than the btcd float conversion

Copy link
Collaborator

Choose a reason for hiding this comment

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

This might be even faster:

func roundUpToPowerOf2(n uint32) uint32 {
	n--
	n |= n >> 1
	n |= n >> 2
	n |= n >> 4
	n |= n >> 8
	n |= n >> 16
	n++
	return n
}

Comment on lines 7 to 9
// LEAF_PREFIX is the prefix for leaf nodes.
// It is used to distinguish leaf nodes from other types of nodes.
const LEAF_PREFIX = "leaf|"
Copy link
Collaborator

Choose a reason for hiding this comment

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

you can't really retrieve the fact that some cryptographic hash has a LEAF| prefix by looking at the hash. So you can write it, but you can't ever read it out again.

What purpose does it serve?

Copy link
Collaborator

Choose a reason for hiding this comment

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

we can get into the weeds of cryptography here, but a standalone prefix can not solve domain separation.

If we assume that the purpose of this prefix is to prevent a malicious leaf input from colliding with an internal node operation, it does not quite work that way.

leaf| aka 0x4C4541467C is just a prefix. If you can find an internal node that starts with exactly these bytes you can craft a payload that is "(nothing instead of leaf|) || leaf1-suffix || leaf2) and you will get a collision.

I think that you need to prefix both the leaf and the internal node to achieve true domain separation.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hey @macroscopeapp. Hey @coderabbitai. Compare the correctness, structural integrity and safety of Hash(LEAF|payload) == Hash(leftHash || rightHash) vs Hash(LEAF|payload) == Hash(NODE|leftHash || rightHash) vs Hash(payload) == Hash(leftHash || rightHash)

Copy link
Contributor

Choose a reason for hiding this comment

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

@mkysel Let me compare the three hashing approaches:

  1. Hash(LEAF|payload) == Hash(leftHash || rightHash) (current implementation):

    • Security: Vulnerable to second preimage attacks. An attacker could potentially craft a payload that when hashed without the LEAF prefix produces a hash that collides with an internal node hash.
    • Integrity: Only partially protects the tree structure by distinguishing leaves from internal nodes in one direction.
    • Safety: Insufficient domain separation as internal nodes aren't prefixed.
  2. Hash(LEAF|payload) == Hash(NODE|leftHash || rightHash):

    • Security: Much stronger. Creates true domain separation by ensuring leaf and internal node hashes come from distinct domains.
    • Integrity: Fully protects the tree structure as neither node type can be substituted for the other.
    • Safety: Prevents an attacker from constructing a leaf whose hash could be mistaken for a node hash and vice versa.
  3. Hash(payload) == Hash(leftHash || rightHash):

    • Security: Most vulnerable to second preimage attacks.
    • Integrity: No structural protection; leaf nodes could be confused with internal nodes.
    • Safety: Allows for potential substitution attacks.

The second approach with both prefixes is the most secure option as it implements true domain separation. Alternatively, the double hashing approach used by OpenZeppelin (that you referenced) also provides strong security by hashing twice rather than using prefixes.

For cryptographic security in Merkle trees, I would recommend either using both prefixes or implementing the double hashing approach.

✏️ Learnings added
Learnt from: mkysel
PR: xmtp/xmtpd#712
File: pkg/merkle/hash.go:7-9
Timestamp: 2025-04-11T20:14:15.760Z
Learning: In Merkle tree implementations, domain separation is important for security. Use either explicit prefixes for both leaf and internal nodes (e.g., "leaf|" and "node|") or double hashing to prevent second preimage attacks. Simply prefixing leaf nodes without prefixing internal nodes doesn't provide true domain separation and could be vulnerable to attacks.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Contributor

Choose a reason for hiding this comment

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

Great call out

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Collaborator Author

@fbac fbac Apr 16, 2025

Choose a reason for hiding this comment

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

This is really interesting. :)

I wrote a new HashNode which uses a prefix for nodes, and also uses a buffer for easy optimizations:

func HashNode(left, right []byte) []byte {
	nodePrefixLen := len(nodePrefixBytes)
	buffer := make([]byte, nodePrefixLen+len(left)+len(right))

	copy(buffer[:nodePrefixLen], nodePrefixBytes)
	copy(buffer[nodePrefixLen:], left)
	copy(buffer[nodePrefixLen+len(left):], right)

	return Hash(buffer)
}

It settles around ~278.0 ns/op with improved security, while the old HashNode marks 280 ns/op.

@mkysel
Copy link
Collaborator

mkysel commented Apr 11, 2025

@coderabbitai can you generate a random stress test for the merkle tree implementation? Keep generating trees, adding nodes and asking for proofs for a configurable duration. The purpose of the stress test is to look for edge conditions and out-of-bounds errors. If you can, sanity check as much as possible.

@xmtp xmtp deleted a comment from macroscopeapp bot Apr 11, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Apr 11, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@mkysel I'll create a comprehensive stress test for the Merkle tree implementation. First, let me check the code to understand the API properly.


🏁 Script executed:

#!/bin/bash
# Let's examine the Merkle tree implementation to understand its API
echo "Examining tree.go to understand the core structure and API"
cat pkg/merkle/tree.go

Length of output: 3933


🏁 Script executed:

#!/bin/bash
# Let's examine the proof-related files to understand how proofs are constructed
echo "Examining proof.go for the MultiProof structure"
cat pkg/merkle/proof.go

echo -e "\nExamining proof_indices.go for index-based proof generation"
cat pkg/merkle/proof_indices.go

echo -e "\nExamining proof_sequential.go for sequential proof generation"
cat pkg/merkle/proof_sequential.go

Length of output: 17615


🏁 Script executed:

#!/bin/bash
# Let's examine the hash functions and test files to understand how to properly test the implementation
echo "Examining hash.go for hashing functions"
cat pkg/merkle/hash.go

echo -e "\nExamining tree_test.go to understand existing test patterns"
cat pkg/merkle/tree_test.go

Length of output: 9737


```mermaid flowchart TB A["Generate Merkle Tree Stress Test"] --o B["Main Test Function Structure"] B --o C["Random Element Generation"] B --o D["Tree Creation

& Validation"]
B --o E["Proof Generation & Verification"]
E --o F["Sequential Proofs"]
E --o G["Index-based Proofs"]
B --o H["Edge Case Testing"]
H --o I["Empty Elements"]
H --o J["Duplicate Elements"]
H --o K["Large Elements"]
H --o L["Boundary Indices"]
B --o M["Stress Test Duration Control"]
M --o N["Configurable Parameters"]
B --o O["Test Result Statistics"]

<!-- <requirement>
[requirement stress_test_1/3 create_test_file]
Create a Merkle tree stress test file that can run for a configurable duration, testing various edge cases and potential failures.

[propose_code_change]
File: `pkg/merkle/stress_test.go`

```go
package merkle

import (
	"bytes"
	"crypto/rand"
	"fmt"
	"math/big"
	"testing"
	"time"
)

// TestMerkleTreeStress performs a stress test on the Merkle tree implementation.
// The test creates trees of random sizes, generates proofs, and verifies them.
// It runs for a configurable duration to catch potential memory leaks or concurrency issues.
func TestMerkleTreeStress(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping stress test in short mode")
	}

	// Configuration
	duration := 5 * time.Second    // How long to run the stress test
	maxElements := 1000            // Maximum number of elements in a tree
	maxElementSize := 1024         // Maximum size of an element in bytes
	iterations := 0                // Count of iterations (for reporting)
	treeCount := 0                 // Count of trees created
	proofCount := 0                // Count of proofs generated
	verificationCount := 0         // Count of verifications performed
	failureCount := 0              // Count of verification failures

	// For time-limited tests
	start := time.Now()
	deadline := start.Add(duration)

	t.Logf("Starting Merkle tree stress test for %v...", duration)

	for time.Now().Before(deadline) {
		iterations++

		// 1. Generate a random tree size (1 to maxElements)
		treeSize := randomInt(1, maxElements)

		// 2. Create random elements
		elements := generateRandomElements(treeSize, maxElementSize)

		// 3. Occasionally include edge cases
		if iterations%5 == 0 {
			elements = incorporateEdgeCases(elements)
		}

		// 4. Create the Merkle tree
		tree, err := NewMerkleTree(elements)
		if err != nil {
			t.Fatalf("Failed to create Merkle tree: %v", err)
		}
		treeCount++

		// 5. Test sequential proofs
		if err := testSequentialProofs(t, tree, &proofCount, &verificationCount, &failureCount); err != nil {
			t.Fatalf("Sequential proof test failed: %v", err)
		}

		// 6. Test index-based proofs
		if err := testIndexBasedProofs(t, tree, &proofCount, &verificationCount, &failureCount); err != nil {
			t.Fatalf("Index-based proof test failed: %v", err)
		}

		// 7. Occasionally perform garbage collection to simulate real-world conditions
		if iterations%100 == 0 {
			t.Logf("Progress: %d iterations, %d trees, %d proofs, %d verifications (%d failures)",
				iterations, treeCount, proofCount, verificationCount, failureCount)
		}
	}

	elapsed := time.Since(start)
	t.Logf("Stress test completed in %v", elapsed)
	t.Logf("Total iterations: %d", iterations)
	t.Logf("Trees created: %d", treeCount)
	t.Logf("Proofs generated: %d", proofCount)
	t.Logf("Verifications performed: %d", verificationCount)
	t.Logf("Verification failures: %d", failureCount)

	if failureCount > 0 {
		t.Errorf("Encountered %d verification failures during stress test", failureCount)
	}
}

// testSequentialProofs tests sequential proofs for the given tree.
func testSequentialProofs(t *testing.T, tree *MerkleTree, proofCount, verificationCount, failureCount *int) error {
	t.Helper()
	elements := tree.Elements()
	elementCount := len(elements)

	// Test cases to cover edge cases and random scenarios
	testCases := []struct {
		name          string
		startingIndex int
		count         int
	}{
		{"SingleElement", 0, 1}, // First element
	}

	// Add edge case: last element
	if elementCount > 1 {
		testCases = append(testCases, struct {
			name          string
			startingIndex int
			count         int
		}{"LastElement", elementCount - 1, 1})
	}

	// Add edge case: all elements
	testCases = append(testCases, struct {
		name          string
		startingIndex int
		count         int
	}{"AllElements", 0, elementCount})

	// Add random ranges if there are enough elements
	if elementCount > 2 {
		// Random middle element
		startIdx := randomInt(1, elementCount-1)
		testCases = append(testCases, struct {
			name          string
			startingIndex int
			count         int
		}{"RandomMiddleElement", startIdx, 1})

		// Random range
		if elementCount > 3 {
			startIdx = randomInt(0, elementCount-2)
			count := randomInt(1, elementCount-startIdx)
			testCases = append(testCases, struct {
				name          string
				startingIndex int
				count         int
			}{"RandomRange", startIdx, count})
		}
	}

	// Execute all test cases
	for _, tc := range testCases {
		proof, err := tree.GenerateMultiProofSequential(tc.startingIndex, tc.count)
		if err != nil {
			return fmt.Errorf("failed to generate sequential proof (%s): %v", tc.name, err)
		}
		(*proofCount)++

		// Verify the proof
		valid, err := VerifyMultiProofSequential(proof)
		(*verificationCount)++
		if err != nil {
			return fmt.Errorf("failed to verify sequential proof (%s): %v", tc.name, err)
		}
		if !valid {
			(*failureCount)++
			return fmt.Errorf("sequential proof verification failed (%s)", tc.name)
		}

		// Sanity check: Modify root and ensure verification fails
		if tc.count > 0 {
			invalidProof := *proof
			invalidProof.Root = bytes.Repeat([]byte{0xFF}, len(proof.Root))
			valid, _ = VerifyMultiProofSequential(&invalidProof)
			(*verificationCount)++
			if valid {
				(*failureCount)++
				return fmt.Errorf("verification succeeded with invalid root (%s)", tc.name)
			}
		}
	}

	return nil
}

// testIndexBasedProofs tests index-based proofs for the given tree.
func testIndexBasedProofs(t *testing.T, tree *MerkleTree, proofCount, verificationCount, failureCount *int) error {
	t.Helper()
	elements := tree.Elements()
	elementCount := len(elements)

	// Test cases to cover edge cases and random scenarios
	testCases := []struct {
		name    string
		indices []int
	}{
		{"FirstElement", []int{0}}, // First element
	}

	// Add edge case: last element
	if elementCount > 1 {
		testCases = append(testCases, struct {
			name    string
			indices []int
		}{"LastElement", []int{elementCount - 1}})
	}

	// Add edge case: all elements
	if elementCount > 0 {
		allIndices := make([]int, elementCount)
		for i := 0; i < elementCount; i++ {
			allIndices[i] = i
		}
		testCases = append(testCases, struct {
			name    string
			indices []int
		}{"AllElements", allIndices})
	}

	// Add random selections if there are enough elements
	if elementCount > 2 {
		// Random middle element
		middleIdx := randomInt(1, elementCount-1)
		testCases = append(testCases, struct {
			name    string
			indices []int
		}{"RandomMiddleElement", []int{middleIdx}})

		// Random set of elements (if there are enough elements)
		if elementCount > 3 {
			count := randomInt(2, (elementCount+1)/2)
			randomIndices := generateRandomIndices(elementCount, count)
			testCases = append(testCases, struct {
				name    string
				indices []int
			}{"RandomIndices", randomIndices})
		}
	}

	// Execute all test cases
	for _, tc := range testCases {
		proof, err := tree.GenerateMultiProofWithIndices(tc.indices)
		if err != nil {
			return fmt.Errorf("failed to generate index-based proof (%s): %v", tc.name, err)
		}
		(*proofCount)++

		// Verify the proof
		valid, err := VerifyMultiProofWithIndices(proof)
		(*verificationCount)++
		if err != nil {
			return fmt.Errorf("failed to verify index-based proof (%s): %v", tc.name, err)
		}
		if !valid {
			(*failureCount)++
			return fmt.Errorf("index-based proof verification failed (%s)", tc.name)
		}

		// Sanity check: Modify root and ensure verification fails
		if len(tc.indices) > 0 {
			invalidProof := *proof
			invalidProof.Root = bytes.Repeat([]byte{0xFF}, len(proof.Root))
			valid, _ = VerifyMultiProofWithIndices(&invalidProof)
			(*verificationCount)++
			if valid {
				(*failureCount)++
				return fmt.Errorf("verification succeeded with invalid root (%s)", tc.name)
			}
		}
	}

	return nil
}

// generateRandomElements creates a slice of random byte slices.
func generateRandomElements(count, maxSize int) [][]byte {
	result := make([][]byte, count)
	for i := 0; i < count; i++ {
		size := randomInt(1, maxSize)
		element := make([]byte, size)
		_, _ = rand.Read(element)
		result[i] = element
	}
	return result
}

// incorporateEdgeCases adds edge cases to the elements.
func incorporateEdgeCases(elements [][]byte) [][]byte {
	result := make([][]byte, len(elements))
	copy(result, elements)

	// Randomly decide which edge cases to include
	switch randomInt(0, 4) {
	case 0:
		// Replace a random element with an empty slice
		if len(result) > 0 {
			idx := randomInt(0, len(result)-1)
			result[idx] = []byte{}
		}
	case 1:
		// Add duplicate elements
		if len(result) > 1 {
			srcIdx := randomInt(0, len(result)-1)
			dstIdx := (srcIdx + 1) % len(result)
			result[dstIdx] = make([]byte, len(result[srcIdx]))
			copy(result[dstIdx], result[srcIdx])
		}
	case 2:
		// Add a large element
		if len(result) > 0 {
			idx := randomInt(0, len(result)-1)
			largeElement := make([]byte, 1024*1024) // 1MB
			_, _ = rand.Read(largeElement)
			result[idx] = largeElement
		}
	case 3:
		// Add a very small element
		if len(result) > 0 {
			idx := randomInt(0, len(result)-1)
			result[idx] = []byte{0x42}
		}
	}

	return result
}

// generateRandomIndices creates a random set of unique indices.
func generateRandomIndices(max, count int) []int {
	if count > max {
		count = max
	}

	// Generate all possible indices
	indices := make([]int, max)
	for i := 0; i < max; i++ {
		indices[i] = i
	}

	// Shuffle using Fisher-Yates algorithm
	for i := max - 1; i > 0; i-- {
		j := randomInt(0, i)
		indices[i], indices[j] = indices[j], indices[i]
	}

	// Take the first 'count' indices
	return indices[:count]
}

// randomInt returns a random integer in the range [min, max).
func randomInt(min, max int) int {
	if min >= max {
		return min
	}
	n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min)))
	return int(n.Int64()) + min
}

-->

@mkysel I'll create a comprehensive stress test for the Merkle tree implementation. After examining the code, I've designed a stress test that will thoroughly exercise the implementation to find any potential edge cases or out-of-bounds errors.

The stress test will:

  1. Generate trees of various sizes (from single element to thousands)
  2. Test both sequential and index-based proofs
  3. Include edge cases (empty elements, duplicates, large elements)
  4. Verify all proofs for correctness
  5. Run for a configurable duration to catch any memory leaks or concurrency issues

Another agent will come along to implement this plan. The stress test will include comprehensive reporting to help identify any potential issues.


⏳ Preparing changes...

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Apr 11, 2025

A summary of the edits CodeRabbit can apply:

We are going to create a new stress test file at pkg/merkle/stress_test.go. This test will run for a configurable duration and continuously generate random Merkle trees with random data; for each tree, it will generate both sequential and index‐based proofs, verify them, and also deliberately modify proofs (by falsifying the root) to assert that improper proofs are rejected. The test will exercise various edge cases – such as empty elements, duplicate elements, very large or very small elements – and will keep track of iteration counts and any verification failures.

A plan of each step that CodeRabbit will take in order to execute the edits for each file (with inline rough draft snippets):

📄 pkg/merkle/stress_test.go

Place the package declaration and necessary imports at the top of the file:

package merkle

import (
"bytes"
"crypto/rand"
"fmt"
"math/big"
"testing"
"time"
)


Define the main stress test function TestMerkleTreeStress. Within this function, configure parameters such as:
- duration (e.g. 5 seconds)
- maximum number of elements in a tree (e.g. 1000)
- maximum element size (in bytes)
- counters for iterations, tree count, proof count, verification count, and failure count.
Use a loop that runs until the configurable duration has elapsed.

func TestMerkleTreeStress(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}

// Configuration
duration := 5 * time.Second
maxElements := 1000
maxElementSize := 1024
iterations, treeCount, proofCount, verificationCount, failureCount := 0, 0, 0, 0, 0

start := time.Now()
deadline := start.Add(duration)

t.Logf("Starting Merkle tree stress test for %v...", duration)

for time.Now().Before(deadline) {
	iterations++
	// (Implementation continues…)
}
// Log final results (omitted for brevity)

}


Implement helper functions:
- generateRandomElements: to create a slice of random byte slices.
- incorporateEdgeCases: to sometimes inject an empty slice, duplicate elements, very large elements, or very small elements.
- randomInt and generateRandomIndices: for randomness support.
Wrap the code in appropriate

func

definitions.


Add two testing helper functions:
- testSequentialProofs: which uses tree.GenerateMultiProofSequential and VerifyMultiProofSequential to generate and verify sequential proofs and performs a sanity check by modifying the root.
- testIndexBasedProofs: which uses tree.GenerateMultiProofWithIndices and VerifyMultiProofWithIndices to generate and verify index‐based proofs, also including a sanity check for invalid roots.
Make sure to update counters passed by pointer to capture the number of proofs and verification attempts.


At strategic points (for example, every 100 iterations) log the progress (iteration count, tree count, proof count, verifications, and failures) using t.Logf.


After the loop, log the total elapsed time and the final statistics. If any verification failures were encountered, call t.Errorf with an appropriate message.


Combine all the code segments, ensuring that all the helper functions are included in this new file. The final file should be pasted in its entirety under pkg/merkle/stress_test.go.

A summary of the context that CodeRabbit is considering across the codebase:

We are verifying that the NewMerkleTree constructor exists in the codebase and is used by the stress test.

We are confirming that the verification functions (VerifyMultiProofSequential and VerifyMultiProofWithIndices) exist and are implemented; these are essential for the stress test to properly validate generated proofs.

  • ✅ Create PR with these edits

  • 📋 Get copyable edits

  • 📌 Create commit in current branch

  • 🔄 Try a different approach

@mkysel
Copy link
Collaborator

mkysel commented Apr 11, 2025

@coderabbitai here is a set of rules https://github.com/xmtp/xmtpd/blob/main/.cursor/rules/100-go-mistakes.mdc, you can also find them here https://100go.co/. Can you check this PR for any violations and if you find any, make relevant comments inline?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Apr 11, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, fetch the Go mistakes rules document
echo "Fetching the Go mistakes rules document..."
curl -s https://raw.githubusercontent.com/xmtp/xmtpd/main/.cursor/rules/100-go-mistakes.mdc

# Now, get a list of all files modified in the PR to analyze them
echo -e "\n\nFiles in the PR to analyze:"
find pkg/merkle -type f -name "*.go" | sort

Length of output: 3534


🏁 Script executed:

#!/bin/bash
echo "Examining pkg/merkle/hash.go:"
cat pkg/merkle/hash.go

Length of output: 729


🏁 Script executed:

#!/bin/bash
echo "Examining pkg/merkle/proof.go:"
cat pkg/merkle/proof.go

Length of output: 4780


🏁 Script executed:

#!/bin/bash
echo "Examining pkg/merkle/proof_indices.go:"
cat pkg/merkle/proof_indices.go

Length of output: 6512


🏁 Script executed:

#!/bin/bash
echo "Examining pkg/merkle/proof_sequential.go:"
cat pkg/merkle/proof_sequential.go

Length of output: 6193


🏁 Script executed:

#!/bin/bash
echo "Examining pkg/merkle/tree.go:"
cat pkg/merkle/tree.go

Length of output: 3875


🏁 Script executed:

#!/bin/bash
echo "Examining pkg/merkle/tree_test.go:"
cat pkg/merkle/tree_test.go

Length of output: 8931


🏁 Script executed:

#!/bin/bash
# Now let's look for specific issues in the code based on the 100 Go Mistakes rules
echo "Checking for TODO comments (potential unfinished work):"
grep -n "TODO" pkg/merkle/*.go

echo -e "\nChecking for potentially complex functions (long functions):"
awk 'length($0) > 100' pkg/merkle/*.go | wc -l

echo -e "\nChecking for proper error handling patterns:"
grep -n "return.*error" pkg/merkle/*.go

echo -e "\nChecking for potential nil pointer dereferences:"
grep -n "if .* == nil" pkg/merkle/*.go

Length of output: 1982


@mkysel I've reviewed the PR against the 100 Go Mistakes rules and found a few issues that could be addressed:

  1. TODO Comments:
    There are two TODO comments in the code that should be addressed before merging:

    • In both proof_indices.go and proof_sequential.go:
      // TODO: Abstract VerifyMultiProofSequential and VerifyMultiProofWithIndices to use a common function.

    This indicates potential code duplication that should be refactored.

  2. Error Handling:
    In hash.go, the Hash function doesn't check the error returned by hash.Write():

    func Hash(buffer []byte) []byte {
        hash := sha3.NewLegacyKeccak256()
        hash.Write(buffer) // This can potentially return an error but it's not checked
        return hash.Sum(nil)
    }

    Although hash.Write() rarely fails for in-memory operations, it's a good practice to handle all potential errors, especially in cryptographic code.

  3. Complex Functions:
    Some functions are quite complex with deep nesting and many local variables:

    • getRootIndices in proof_indices.go
    • getRootSequentially in proof_sequential.go

    Breaking these down into smaller, more focused functions would improve readability and maintainability.

  4. Variable Names:
    In several places (like proof.go), there are short, non-descriptive variable names:

    // Variables like "i", "idxs" could be more descriptive
    for i := leafCount - 1; i

0; i-- {
leftChildIdx := getLeftChild(i)
rightChildIdx := getRightChild(i)
// ...
}


5. **Function Naming Conventions**:
Some functions like `getLeftChild` and `getRightChild` use the "get" prefix, which is not typical in Go. Functions are typically named without "get" (e.g., `leftChild` and `rightChild`).

6. **Constants Naming**:
The constant `LEAF_PREFIX` is using uppercase with underscores, while Go's convention is to use MixedCaps:
```go
// Should be: LeafPrefix instead of LEAF_PREFIX
const LEAF_PREFIX = "leaf|"
  1. Potential Over-nesting in Control Flow:
    There are instances of nested if statements that could be simplified:
    // Example from proof.go - could be simplified
    if left != right {
        if right {
            if tree[leftChildIdx] != nil {
                proofs = append(proofs, cloneBuffer(tree[leftChildIdx]))
            }
        } else {
            if tree[rightChildIdx] != nil {
                proofs = append(proofs, cloneBuffer(tree[rightChildIdx]))
            }
        }
    }

Would you like more detailed comments on any of these particular issues?

@macroscopeapp
Copy link

macroscopeapp bot commented Apr 11, 2025

Changes in dcc177c

  • Refactored merkle package tree indexing from 0-based to 1-based indexing, moving root to index 1, internal nodes to indices 2-N, leaves to indices N+1 to 2N-1, and adding Tree() and LeafCount() accessor methods
  • Implemented centralized proof verification in merkle package with new verifyProof function and error constants ErrProofEmptyTree and ErrProofEmptyIndices
  • Modified proof generation in merkle package to accept explicit root parameter and improved error handling in generateProof, GenerateMultiProofWithIndices, and GenerateMultiProofSequential functions

Macroscope summarized dcc177c.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (11)
pkg/merkle/proof_indices.go (2)

9-16: Check negative indices in a dedicated error branch
While hasOutOfBounds likely covers negative indices by extension, consider adding an explicit negative-index check to provide clearer error messages for invalid inputs.


57-185: Consider refactoring getRootIndices
This function is lengthy, with multiple index variables. Extracting chunks of logic (e.g., sibling checks, proof consumption) into helper functions or adding in-code comments can improve long-term maintenance and reduce confusion.

pkg/merkle/proof_sequential.go (2)

7-18: Check for negative or zero count
Lines 11-12 only validate that startingIndex+count <= leafCount. If a caller mistakenly passes a negative or zero value for count, the error might be cryptic. Consider adding an explicit check for count <= 0 to fail fast.


57-168: Large function complexity
The nested index manipulations can be tricky to follow. Splitting out repeated logic (e.g., sibling detection, proof consumption) might help reduce complexity for future maintainers.

pkg/merkle/tree.go (2)

9-16: Consider a custom node type
Storing raw [][]byte is flexible yet can hinder readability. A small wrapper type—like type Node struct { Hash []byte }—can make it clearer where each element is used as a node.


120-155: Edge case for a single leaf
getDepth(1) returns 0, which is valid unless your application expects a baseline depth of 1. You might clarify in docs or error-check if single-element trees pose any special constraints.

pkg/merkle/proof.go (5)

19-29: Consider removing or consolidating unused error constants.

Some of these declared constants (e.g., ErrProofInvalidElementCount, ErrProofInvalidRange, etc.) do not appear to be used, while other errors are declared inside the functions using fmt.Errorf(...). For clarity, either remove the unused constants or standardize their usage throughout the file.


118-135: Rename "leafs" to "leaves" for clarity.

For readability and consistency, consider renaming the leafs variable to leaves, which is more standard English usage.

- root := HashLeaf(proof.Elements[0])
...
- leafs := make([][]byte, len(proof.Elements))
- for i, element := range proof.Elements {
-   leafs[i] = HashLeaf(element)
- }
+ leaves := make([][]byte, len(proof.Elements))
+ for i, element := range proof.Elements {
+   leaves[i] = HashLeaf(element)
+ }
  ...
- root = combineLeaves(leafs)
+ root = combineLeaves(leaves)

150-184: Standardize error handling for proof validation.

This block uses fmt.Errorf(...) to create new errors, whereas multiple proof-related error constants are declared globally. For consistency, either replace these inline errors with the existing constants (e.g., ErrProofNoElements, etc.) or remove the unused constants.


206-235: Consider parallelizing leaf combination for large inputs.

In combineLeaves, the algorithm sequentially calculates parent hashes. If you anticipate very large input sets and need higher performance, parallelizing this level-building process (e.g., using goroutines) may significantly reduce computation time. This is optional and depends on performance requirements.


237-244: Evaluate whether buffer cloning is necessary.

cloneBuffer duplicates each byte slice. If you can guarantee no modifications to the original slice after this function call, skipping the clone operation would improve performance and reduce allocations.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5eb4a35 and dcc177c.

📒 Files selected for processing (5)
  • pkg/merkle/proof.go (1 hunks)
  • pkg/merkle/proof_indices.go (1 hunks)
  • pkg/merkle/proof_sequential.go (1 hunks)
  • pkg/merkle/tree.go (1 hunks)
  • pkg/merkle/tree_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/merkle/tree_test.go
🧰 Additional context used
🧬 Code Graph Analysis (3)
pkg/merkle/proof_indices.go (3)
pkg/merkle/tree.go (1)
  • MerkleTree (10-16)
pkg/merkle/proof.go (1)
  • MultiProof (10-17)
pkg/merkle/hash.go (1)
  • HashNode (19-21)
pkg/merkle/proof.go (1)
pkg/merkle/hash.go (2)
  • HashLeaf (24-26)
  • HashNode (19-21)
pkg/merkle/proof_sequential.go (3)
pkg/merkle/tree.go (1)
  • MerkleTree (10-16)
pkg/merkle/proof.go (1)
  • MultiProof (10-17)
pkg/merkle/hash.go (1)
  • HashNode (19-21)
⏰ Context from checks skipped due to timeout of 90000ms (5)
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd-cli)
  • GitHub Check: Upgrade Tests
  • GitHub Check: Test (Node)
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd)
  • GitHub Check: Code Review
🔇 Additional comments (7)
pkg/merkle/proof_indices.go (2)

30-38: Sorting modifies the proof indices
Because the proof is returned with sorted indices, the user’s original index ordering will differ. If the intended behavior is to preserve the exact ordering, you might keep a separate unsorted copy or document that the proof uses sorted indices.


189-220: Thorough validation
The index validation logic (checking for out-of-range, duplicates, etc.) is solid and ensures data integrity for multi-proof verification.

pkg/merkle/proof_sequential.go (2)

30-35: Verifying array boundaries
This loop retrieves m.elements[startingIndex + i]. Although line 11 covers the range, it’s prudent in critical systems to re-validate in the loop or add an explanatory comment ensuring that i won’t exceed bounds.


170-208: Neat sequential validation
The checks for sequential coherence (StartingIndex + i) are logical, and optional index validation is well thought out.

pkg/merkle/tree.go (2)

18-38: Tree initialization is straightforward
Instantiating leaves, building the tree, and storing the root is logically consistent and meets common Merkle tree expectations.


65-118: Confirm partial coverage handling
The loop skips building certain parent nodes when leftChildIndex > upperBound. This is often correct for Merkle trees with unpopulated leaves, but please confirm it aligns with your usage requirements (e.g., no unexpected nil parents).

pkg/merkle/proof.go (1)

65-72: Ensure out-of-bounds protection for 'leafCount + idx'.

When marking known leaves, the code does not guard against cases in which leafCount + idx might exceed the array bounds of tree. Relying on the caller to validate the indices can be risky because a single invalid index can panic the entire function. Consider a protective check or an explicit error if the sum exceeds the slice length.

@macroscopeapp
Copy link

macroscopeapp bot commented Apr 11, 2025

Changes in a252245

  • Reimplemented merkle.getDepth function to use bit manipulation with bits.Len32 instead of floating-point math operations, handling edge cases for values ≤ 1 and non-power-of-2 inputs
  • Modified merkle.getDepth function signature to accept uint32 parameter and updated corresponding call in merkle.buildTree

Macroscope summarized a252245.

@macroscopeapp
Copy link

macroscopeapp bot commented Apr 11, 2025

Changes in a4b966f

  • Added standardized error handling in merkle package by introducing eight new error variables and replacing fmt.Errorf calls with these predefined errors across proof validation functions
  • Exposed internal tree navigation functions in merkle package by making getLeafCount, getLeftChild, and getRightChild public and converting test package to external merkle_test
  • Modified merkle package proof generation functions to consistently use proof object properties instead of local variables for indices and element counts
  • Removed unused imports and renamed parameters in merkle package

Macroscope summarized a4b966f.

@fbac fbac force-pushed the 04-11-pkg_merkle branch from a4b966f to 3e94d04 Compare April 11, 2025 22:55
@fbac fbac requested a review from a team as a code owner April 17, 2025 11:30
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
pkg/merkle/proof.go (2)

336-339: Comment contradicts implementation

isLeftChild returns true for even indices (left child), yet the comment claims the opposite. Please update the comment (or the code) for clarity.


77-85: ⚠️ Potential issue

Add a nil–check for proof to prevent panics
Verify dereferences proof immediately (proof.validate()), so a caller that accidentally passes nil will trigger a runtime panic. A tiny guard avoids the crash and surfaces a clear error:

 func Verify(root []byte, proof *MultiProof) (bool, error) {
-	if len(root) == 0 {
+	if proof == nil {
+		return false, fmt.Errorf(ErrVerifyProof, ErrNilProof)
+	}
+	if len(root) == 0 {
 		return false, fmt.Errorf(ErrVerifyProof, ErrNilRoot)
 	}
 
 	if err := proof.validate(); err != nil {
 		return false, fmt.Errorf(ErrVerifyProof, err)
 	}
pkg/merkle/tree.go (1)

294-306: 🛠️ Refactor suggestion

roundUpToPowerOf2 still overflows for math.MaxUint32

n + 1 wraps to 0 for 0xFFFFFFFF, which later converts to a negative int on 32‑bit builds.
Either cap the input earlier or guard here:

-	return n + 1
+	if n == ^uint32(0) {
+		return n // already max; cannot round further
+	}
+	return n + 1
🧹 Nitpick comments (4)
pkg/merkle/tree.go (3)

24-27: Fix off‑by‑one / typo in public documentation

The comment says

- The hashed are at index N+1 to 2N-1.

but leaves actually occupy indices N2N‑1 (see lowerBound := balancedLeafCount). Mis‑documenting the layout makes the public API harder to reason about—please adjust.


10-16: Expose a copy to keep node hashes immutable

Hash() returns the backing slice, so callers can mutate it and silently corrupt the tree/proofs:

 func (n *Node) Hash() []byte {
-	return n.hash
+	dup := make([]byte, len(n.hash))
+	copy(dup, n.hash)
+	return dup
 }

Alternatively, document that callers must treat the slice as read‑only.


46-54: Deep‑copy individual leaves to avoid shared mutability

copy(leavesDeepCopy, leaves) duplicates the slice header only; each []byte element still aliases the caller’s buffer.
Either:

  1. Clarify in docs that the caller must not mutate after construction, or
  2. Perform a per‑element copy:
leavesDeepCopy := make([]Leaf, len(leaves))
for i, l := range leaves {
    cpy := make([]byte, len(l))
    copy(cpy, l)
    leavesDeepCopy[i] = cpy
}
pkg/merkle/proof_test.go (1)

441-445: Optional: assert immutability rather than equality only

The test correctly checks value equality, but not that the returned slices are copies (immutable).
If you adopt the earlier Hash() / leaf‑copy changes, consider extending the test:

orig := values[0].GetValue()
orig[0] ^= 0xFF
require.False(t, bytes.Equal(values[0].GetValue(), orig), "proof exposes mutable slice")
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 79f8c12 and 1371d83.

📒 Files selected for processing (5)
  • pkg/merkle/hash.go (1 hunks)
  • pkg/merkle/proof.go (1 hunks)
  • pkg/merkle/proof_test.go (1 hunks)
  • pkg/merkle/tree.go (1 hunks)
  • pkg/merkle/tree_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • pkg/merkle/hash.go
  • pkg/merkle/tree_test.go
🧰 Additional context used
🧠 Learnings (1)
pkg/merkle/proof.go (2)
Learnt from: fbac
PR: xmtp/xmtpd#712
File: pkg/merkle/proof.go:0-0
Timestamp: 2025-04-14T12:55:15.436Z
Learning: In Merkle tree implementations, when indices are validated using a function like `validateIndices(indices, elementCount)` which checks that indices are less than the element count, additional bounds checking for accessing tree nodes at `leafCount+idx` is redundant because the element count corresponds to the number of leaf nodes.
Learnt from: fbac
PR: xmtp/xmtpd#712
File: pkg/merkle/proof.go:71-72
Timestamp: 2025-04-15T11:29:03.575Z
Learning: In Go, when a byte slice (`[]byte`) is nil, `len(slice)` returns 0. Therefore, checking `if len(slice) == 0` will catch both nil slices and empty slices (initialized but with no elements).
🧬 Code Graph Analysis (1)
pkg/merkle/tree.go (2)
pkg/merkle/hash.go (4)
  • Hash (18-22)
  • HashEmptyLeaf (45-47)
  • HashNode (24-33)
  • HashLeaf (35-43)
pkg/merkle/proof.go (2)
  • MultiProof (28-32)
  • ErrGenerateProof (11-11)
⏰ Context from checks skipped due to timeout of 90000ms (5)
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd-cli)
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd)
  • GitHub Check: Test (Node)
  • GitHub Check: Upgrade Tests
  • GitHub Check: Code Review

@fbac fbac force-pushed the 04-11-pkg_merkle branch from 1371d83 to 5aed727 Compare April 17, 2025 11:34
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (4)
pkg/merkle/tree.go (1)

294-306: Potential overflow in roundUpToPowerOf2 remains unaddressed

Prior reviews noted that n + 1 can wrap to 0 when n == ^uint32(0).
Although CalculateBalancedNodesCount now caps inputs, the helper is still callable elsewhere and is safer with an explicit guard.

(See earlier comment; not repeated here.)

pkg/merkle/proof.go (3)

77-85: Nil‑proof dereference can panic

Verify dereferences proof without checking for nil; passing a nil pointer will panic.

 func Verify(root []byte, proof *MultiProof) (bool, error) {
+	if proof == nil {
+		return false, fmt.Errorf(ErrVerifyProof, ErrNilProof)
+	}
 	if len(root) == 0 {
 		return false, fmt.Errorf(ErrVerifyProof, ErrNilRoot)
 	}

(This issue was flagged previously and is still outstanding.)


291-311: Indices are validated against padded leaf count, allowing empty‑leaf proofs

validateIndices bounds‑checks against balancedLeafCount, which includes the synthetic padding leaves.
Callers can therefore request proofs for indices that have no real data (e.g. index 3 in a 3‑leaf tree padded to 4).

Re–use the actual leafCount instead:

-	balancedLeafCount, err := CalculateBalancedNodesCount(leafCount)
-	if err != nil {
-		return err
-	}
 ...
-	if hasOutOfBounds(sortedIndices, balancedLeafCount) {
+	if hasOutOfBounds(sortedIndices, leafCount) {
 		return ErrIndicesOutOfBounds
 	}

Prevents silent acceptance of invalid proofs.


336-339: Comment contradicts implementation

isLeftChild returns true for even indices, but the comment claims the opposite. Correcting the comment avoids mental traps.

-// isLeftChild returns true if the given index is odd (right child).
+// isLeftChild returns true if the given index is even (left child).
🧹 Nitpick comments (4)
pkg/merkle/tree.go (3)

20-26: Fix inaccurate documentation of slice layout

The comments describing the 1‑indexed layout are off by one:

  • Internal nodes occupy indices 2 … N‑1 (not 2 … N).
  • Leaves sit at indices N … 2N‑1 (not N+1 … 2N‑1).
  • “…​hashed” → “…​leaves”.

Keeping these doc‑strings correct prevents future off‑by‑one bugs for anyone maintaining the code.

-// - The internal nodes are at index 2 to N.
-// - The hashed are at index N+1 to 2N-1.
+// - Internal nodes are at indices 2 to N‑1.
+// - Leaves are at indices N to 2N‑1.

14-16: Return a defensive copy to prevent external mutation

Hash() exposes the internal byte‐slice directly. A caller can mutate the slice in‑place and corrupt the tree.

-func (n *Node) Hash() []byte {
-	return n.hash
+func (n *Node) Hash() []byte {
+	// Return a copy so callers cannot mutate internal state.
+	if n.hash == nil {
+		return nil
+	}
+	cp := make([]byte, len(n.hash))
+	copy(cp, n.hash)
+	return cp
 }

46-57: Double copying of leaves is unnecessary

NewMerkleTree deep‑copies leaves into leavesDeepCopy, then makeLeaves copies again.
Unless the original slice must remain untouched, a single copy inside makeLeaves suffices and avoids an extra allocation.

Refactor idea:

  1. Drop leavesDeepCopy altogether.
  2. Let makeLeaves handle the copy/padding.

This reduces allocations for large trees.

pkg/merkle/proof.go (1)

203-206: Remove unused variable to silence go vet

upperBound is declared but never referenced after this point.

-		upperBound     = blc + p.leafCount - 1
+		// upperBound was part of an earlier algorithm; remove to avoid dead code.

An unused local will trigger go vet warnings.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1371d83 and 5aed727.

📒 Files selected for processing (5)
  • pkg/merkle/hash.go (1 hunks)
  • pkg/merkle/proof.go (1 hunks)
  • pkg/merkle/proof_test.go (1 hunks)
  • pkg/merkle/tree.go (1 hunks)
  • pkg/merkle/tree_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • pkg/merkle/hash.go
  • pkg/merkle/tree_test.go
  • pkg/merkle/proof_test.go
🧰 Additional context used
🧠 Learnings (1)
pkg/merkle/proof.go (2)
Learnt from: fbac
PR: xmtp/xmtpd#712
File: pkg/merkle/proof.go:0-0
Timestamp: 2025-04-14T12:55:15.436Z
Learning: In Merkle tree implementations, when indices are validated using a function like `validateIndices(indices, elementCount)` which checks that indices are less than the element count, additional bounds checking for accessing tree nodes at `leafCount+idx` is redundant because the element count corresponds to the number of leaf nodes.
Learnt from: fbac
PR: xmtp/xmtpd#712
File: pkg/merkle/proof.go:71-72
Timestamp: 2025-04-15T11:29:03.575Z
Learning: In Go, when a byte slice (`[]byte`) is nil, `len(slice)` returns 0. Therefore, checking `if len(slice) == 0` will catch both nil slices and empty slices (initialized but with no elements).

@fbac fbac force-pushed the 04-11-pkg_merkle branch from 5aed727 to 0b0be33 Compare April 17, 2025 11:50
@fbac fbac requested review from mkysel and neekolas April 17, 2025 11:54
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
pkg/merkle/tree.go (1)

297-309: Potential overflow in roundUpToPowerOf2 remains unhandled

The function still does return n + 1 after the bit‑smearing step.
For inputs such as 0xFFFF_FFFF this wraps to 0, breaking subsequent size calculations. Previous review threads already highlighted this; please address it or document why the current behaviour is acceptable.

-	return n + 1
+	if n == ^uint32(0) {
+		// Overflow would wrap to zero – reject or cap explicitly.
+		return n // or: panic("overflow in roundUpToPowerOf2")
+	}
+	return n + 1
🧹 Nitpick comments (1)
pkg/merkle/tree.go (1)

14-23: Avoid repeated allocations in Node.Hash()

Node.Hash() defensively copies the underlying slice on every call.
While this guarantees immutability, it also allocates O(n) memory for every hash access, which becomes expensive when walking large trees or generating proofs.

If the tree is treated as immutable after construction (which appears to be the case), you can safely return the slice itself and document it as read‑only. A middle ground is to expose both a zero‑allocation accessor (HashNoCopy) and keep the safe variant for external callers.

-func (n *Node) Hash() []byte {
-	if n.hash == nil {
-		return nil
-	}
-
-	cp := make([]byte, len(n.hash))
-	copy(cp, n.hash)
-	return cp
-}
+// Hash returns the node hash. The slice **must not** be mutated.
+func (n *Node) Hash() []byte {
+	return n.hash
+}

If total immutability is required, consider caching the copy once and re‑using it instead of re‑allocating each time.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5aed727 and 0b0be33.

📒 Files selected for processing (5)
  • pkg/merkle/hash.go (1 hunks)
  • pkg/merkle/proof.go (1 hunks)
  • pkg/merkle/proof_test.go (1 hunks)
  • pkg/merkle/tree.go (1 hunks)
  • pkg/merkle/tree_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • pkg/merkle/proof_test.go
  • pkg/merkle/hash.go
  • pkg/merkle/tree_test.go
  • pkg/merkle/proof.go
🧰 Additional context used
🧬 Code Graph Analysis (1)
pkg/merkle/tree.go (2)
pkg/merkle/hash.go (4)
  • Hash (18-22)
  • HashEmptyLeaf (45-47)
  • HashNode (24-33)
  • HashLeaf (35-43)
pkg/merkle/proof.go (2)
  • MultiProof (28-32)
  • ErrGenerateProof (11-11)
⏰ Context from checks skipped due to timeout of 90000ms (5)
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd-cli)
  • GitHub Check: Test (Node)
  • GitHub Check: Upgrade Tests
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd)
  • GitHub Check: Code Review
🔇 Additional comments (1)
pkg/merkle/tree.go (1)

129-160: Proof sibling ordering might break verification – please double‑check

makeProof appends a sibling as soon as exactly one child is known.
The resulting order of proofs depends on the traversal sequence (i descending) rather than on the position of the sibling in the tree. Many multi‑proof verification algorithms expect a deterministic left‑to‑right (or bottom‑up) ordering of proof hashes; a mismatch will yield false‑negative verification.

Please verify that MultiProof.validate() (and the public verifier) consume the proofs slice according to this exact production order. If not, you may need to:

  1. Collect the sibling hashes first, then reverse or sort them to the expected order, or
  2. Switch to a breadth‑first / left‑to‑right traversal when building proofs.

Failing to align the orders will surface only under certain index combinations, so an explicit test is advisable.


// CalculateBalancedNodesCount returns the number of nodes in a balanced tree.
// To calculate the number of nodes in a tree, we need to round up to the next power of 2.
// Returns an error if the element count is too large to be represented in a uint32.
Copy link
Collaborator

@mkysel mkysel Apr 17, 2025

Choose a reason for hiding this comment

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

these type casts here... It's such a shame that Go len() is int instead of uint. I understand the design decision, but it makes some things a bit more annoying to cast.

I would assume that all of our code will be running on 64-bit architecture and so int will be int64. roundUpToPowerOf2 can be easily adapted to accept and return uint64. Just add a few more shifts :) Or make it accept int and handle both architectures.

That might remove the need for the overflow check and a bunch of these casts.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I would assume that all of our code will be running on 64-bit architecture and so int will be int64. roundUpToPowerOf2 can be easily adapted to accept and return uint64. Just add a few more shifts :) Or make it accept int and handle both architectures.

My assumption too, extracting this to an issue to work on it.

return 0, nil
}

if count > int(1<<31) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

this does not work on 32bit architecture. It will overflow and hence go negative.

@mkysel
Copy link
Collaborator

mkysel commented Apr 17, 2025

I've been trying to figure out where a malicious actor can inject payloads. It's hard to reason about because so many functions are exposed (public). I assume you have done that because I suggested to have the tests live in their own package and test the externally exposed functionality.

Can you explain what the entry points are? The two MultiProof functions?

We could always formalize that by using an interface.

@macroscopeapp
Copy link

macroscopeapp bot commented Apr 21, 2025

Changes in d34a3eb

  • Modified error handling in merkle package
  • Renamed tree construction function in merkle package

Macroscope summarized d34a3eb.

@fbac fbac force-pushed the 04-11-pkg_merkle branch from d34a3eb to 439d038 Compare April 21, 2025 11:20
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (2)
pkg/merkle/tree.go (2)

177-179: 🛠️ Refactor suggestion

Add bounds check for Root() method.

The Root() method directly accesses m.tree[1] without checking if the tree slice has sufficient length. This could lead to a panic if the tree is empty or malformed.

func (m *MerkleTree) Root() []byte {
+	if len(m.tree) <= 1 {
+		return nil
+	}
	return m.tree[1].Hash()
}

295-308: ⚠️ Potential issue

Handle potential overflow in roundUpToPowerOf2.

When n is close to the maximum uint32 value (0xFFFFFFFF), adding 1 could cause an overflow, resulting in a value of 0. This would cause unexpected behavior when calculating the balanced node count.

func roundUpToPowerOf2(n uint32) uint32 {
	if bits.OnesCount32(n) == 1 {
		return n
	}

	n |= n >> 1
	n |= n >> 2
	n |= n >> 4
	n |= n >> 8
	n |= n >> 16

+	// Check for potential overflow
+	if n == math.MaxUint32 {
+		return n
+	}
	return n + 1
}

Don't forget to add the import: import "math"

🧹 Nitpick comments (4)
pkg/merkle/tree.go (4)

106-164: Simplify nested conditional logic in makeProof.

The nested if statements in the makeProof function could be simplified for better readability while preserving the same logic.

// Only one of children is known, so we need the sibling as a proof.
if left != right {
-	if right {
-		proofs = append(proofs, m.tree[leftChildIdx])
-	} else {
-		proofs = append(proofs, m.tree[rightChildIdx])
-	}
+	siblingIdx := rightChildIdx
+	if right {
+		siblingIdx = leftChildIdx
+	}
+	proofs = append(proofs, m.tree[siblingIdx])
}

280-293: Error message improvement in CalculateBalancedNodesCount.

The error messages could be more informative to help diagnose issues. Consider adding details about the actual input value that caused the error.

func CalculateBalancedNodesCount(count int) (int, error) {
	if count <= 0 {
-		return 0, fmt.Errorf("count must be greater than 0")
+		return 0, fmt.Errorf("count must be greater than 0, got %d", count)
	}

	if count > int(1<<31) {
-		return 0, fmt.Errorf("count must be less than or equal to 2^31")
+		return 0, fmt.Errorf("count must be less than or equal to 2^31, got %d", count)
	}

	return int(roundUpToPowerOf2(uint32(count))), nil
}

69-104: Consider adding validation for negative indices in GenerateMultiProofWithIndices.

While the makeProof function validates indices, adding an early check for negative values in the public API methods would provide clearer error messages.

func (m *MerkleTree) GenerateMultiProofWithIndices(indices []int) (*MultiProof, error) {
+	// Early validation for negative indices
+	for _, idx := range indices {
+		if idx < 0 {
+			return nil, fmt.Errorf("negative index %d is invalid", idx)
+		}
+	}
+
	sortedIndices := make([]int, len(indices))
	copy(sortedIndices, indices)
	sort.Ints(sortedIndices)

209-219: Consider using constants for clarity in makeTree.

The tree building logic relies on understanding the 1-indexed representation. Using named constants could make the code more readable.

+	// Constants for clarity
+	const ROOT_INDEX = 1

	lowerBound := balancedLeafCount
	upperBound := balancedLeafCount + len(nodes) - 1

	// Copy leaves into the tree, starting at index N.
	for i := 0; i < len(nodes); i++ {
		tree[lowerBound+i] = nodes[i]
	}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0b0be33 and d34a3eb.

📒 Files selected for processing (1)
  • pkg/merkle/tree.go (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
pkg/merkle/tree.go (2)
pkg/merkle/hash.go (4)
  • Hash (18-22)
  • HashEmptyLeaf (45-47)
  • HashNode (24-33)
  • HashLeaf (35-43)
pkg/merkle/proof.go (2)
  • MultiProof (28-32)
  • ErrGenerateProof (11-11)
⏰ Context from checks skipped due to timeout of 90000ms (5)
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd-cli)
  • GitHub Check: Test (Node)
  • GitHub Check: Push Docker Images to GitHub Packages (xmtpd)
  • GitHub Check: Upgrade Tests
  • GitHub Check: Code Review
🔇 Additional comments (4)
pkg/merkle/tree.go (4)

14-23: Defensive copy in Hash() method is a good practice.

Creating a defensive copy of the hash ensures that external code cannot modify the internal state of the tree nodes. This immutability helps prevent subtle bugs that could arise from accidental modifications.


27-38: Well-documented data structure design.

The documentation clearly explains the 1-indexed array structure of the Merkle tree, which makes the code easier to understand. This implementation choice follows a common approach for binary trees where parent-child relationships can be calculated using simple arithmetic.


45-67: Proper error handling in NewMerkleTree.

The constructor properly propagates errors from helper functions, ensuring that any issues during tree construction are reported accurately to the caller.


220-240:

Details

❌ Incorrect review comment

Check for nil hash before dereferencing in makeTree.

When building the tree, the code doesn't check if Hash() returns nil before using it in HashNode(). This could lead to a panic if one of the child nodes has a nil hash.

tree[i] = Node{
-	hash: HashNode(tree[leftChildIndex].Hash(), tree[rightChildIndex].Hash()),
+	hash: HashNode(
+		getNonNilHash(tree[leftChildIndex].Hash()), 
+		getNonNilHash(tree[rightChildIndex].Hash()),
+	),
}

// Add this helper function:
// func getNonNilHash(hash []byte) []byte {
//     if hash == nil {
//         return []byte{}
//     }
//     return hash
// }

Let's verify if a nil hash can actually occur in practice:


🏁 Script executed:

#!/bin/bash
# Search for any places where a Node is created with a nil hash
rg -A 2 -B 2 "Node\s*{(\s*hash:\s*nil|\s*}\s*)" pkg/merkle/

Length of output: 184


Let’s inspect the Node type, its Hash() method, the HashNode function, and the surrounding makeTree logic to confirm whether a nil‐slice can flow through:


🏁 Script executed:

#!/bin/bash
echo "=== Node struct definition ==="
rg -n 'type Node' -n pkg/merkle

echo
echo "=== HashNode function ==="
rg -n 'func HashNode' -n pkg/merkle

echo
echo "=== Hash() method ==="
rg -n 'func .*Hash()' -n pkg/merkle

echo
echo "=== makeTree snippet (lines 200–260) ==="
sed -n '200,260p' pkg/merkle/tree.go

Length of output: 2157


Let’s confirm the implementations of HashNode, HashLeaf, and HashEmptyLeaf to see if they ever return nil:


🏁 Script executed:

#!/bin/bash
echo "=== pkg/merkle/hash.go ==="
sed -n '1,200p' pkg/merkle/hash.go

Length of output: 1051


Drop the nil‐check guard: hashes are always non‐nil
All Node.hash fields are seeded via HashLeaf, HashEmptyLeaf, or HashNode—and each of those calls Hash(buffer), which always returns a non‑nil []byte. Additionally, func (n *Node) Hash() is called on a value receiver (never nil). The proposed getNonNilHash helper isn’t needed.

Likely an incorrect or invalid review comment.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
pkg/merkle/tree.go (2)

295-308: Consider adding a const for the maximum accepted value.

While the overflow check in CalculateBalancedNodesCount prevents integer overflow by limiting the count to 2^31, consider adding a named constant for this maximum value to make the code more readable and maintainable.

+ const MaxNodeCount = 1 << 31 // Maximum node count to prevent overflow

  func CalculateBalancedNodesCount(count int) (int, error) {
      if count <= 0 {
          return 0, fmt.Errorf("count must be greater than 0")
      }

-     if count > int(1<<31) {
+     if count > MaxNodeCount {
          return 0, fmt.Errorf("count must be less than or equal to 2^31")
      }

      return int(roundUpToPowerOf2(uint32(count))), nil
  }

310-318: Consider using more idiomatic Go function names.

The functions GetLeftChild and GetRightChild follow traditional OOP naming conventions. In Go, the "Get" prefix is typically omitted. Consider renaming to LeftChild and RightChild to follow idiomatic Go naming conventions.

- func GetLeftChild(index int) int {
+ func LeftChild(index int) int {
      return index << 1 // index * 2
  }

- func GetRightChild(index int) int {
+ func RightChild(index int) int {
      return (index << 1) + 1 // index * 2 + 1
  }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d34a3eb and 439d038.

📒 Files selected for processing (2)
  • pkg/merkle/tree.go (1 hunks)
  • pkg/merkle/tree_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/merkle/tree_test.go
🧰 Additional context used
🧬 Code Graph Analysis (1)
pkg/merkle/tree.go (2)
pkg/merkle/hash.go (4)
  • Hash (18-22)
  • HashEmptyLeaf (45-47)
  • HashNode (24-33)
  • HashLeaf (35-43)
pkg/merkle/proof.go (2)
  • MultiProof (28-32)
  • ErrGenerateProof (11-11)
⏰ Context from checks skipped due to timeout of 90000ms (1)
  • GitHub Check: Test (Node)
🔇 Additional comments (8)
pkg/merkle/tree.go (8)

10-23: LGTM: Node struct with defensive Hash method.

The Node struct definition is clean and the Hash() method correctly handles nil values and defensively returns a copy of the hash value to prevent accidental modifications of the internal state. This is a good practice for immutability.


27-38: LGTM: Well-documented MerkleTree struct with clear indexing explanation.

The struct definition and comments clearly explain the 1-indexed nature of the tree array representation. This explicit documentation of the indexing scheme is essential for understanding the implementation.


45-67: LGTM: Clean constructor with proper error handling.

The NewMerkleTree function follows a clear step-by-step construction process with appropriate error handling at each stage. The delegation to helper functions keeps the constructor clean and focused.


69-86: LGTM: Sequential multi-proof generation with proper validation.

The GenerateMultiProofSequential method properly creates and validates indices before generating the proof. The final validation step ensures the generated proof is correct.


88-104: LGTM: Multi-proof generation for arbitrary indices with sorting.

The GenerateMultiProofWithIndices method correctly sorts the indices to ensure consistent proof generation. This is important for the internal algorithm and allows for verification using the canonical ordering.


106-164: LGTM: Well-implemented proof generation algorithm.

The makeProof method correctly validates inputs, handles the single-element tree edge case, and implements the proof generation algorithm. The approach of marking nodes as known and walking up the tree is efficient and matches the standard Merkle proof generation approach.


186-244: LGTM: Thorough tree construction implementation.

The makeTree function correctly implements the bottom-up tree construction algorithm with proper error handling and empty tree checks. The algorithm efficiently builds the tree by calculating hashes from the leaf level up.


176-179:

Details

❌ Incorrect review comment

Ensure Root() works with empty trees.

The Root() method directly accesses index 1 without checking if the tree array has at least 2 elements. While the constructor ensures non-empty trees, defensive programming would suggest adding a bounds check.


🏁 Script executed:

#!/bin/bash
# Check if there are any functions that call Root() that might not verify the tree is non-empty first
rg -A 5 "Root\(\)" pkg/merkle

Length of output: 4860


No change needed: Root() is safe for empty trees

The MerkleTree constructor uses CalculateBalancedNodesCount, which guarantees a minimum leaf count of 1 (so m.tree is always at least length 2). Existing tests—especially TestTreeWithEmptyElements—already exercise Root() on an empty tree and assert it’s non‑nil. A bounds check isn’t required here.

Likely an incorrect or invalid review comment.

@fbac
Copy link
Collaborator Author

fbac commented Apr 21, 2025

I've been trying to figure out where a malicious actor can inject payloads. It's hard to reason about because so many functions are exposed (public). I assume you have done that because I suggested to have the tests live in their own package and test the externally exposed functionality.

Can you explain what the entry points are? The two MultiProof functions?

We could always formalize that by using an interface.

This package aims to be a generic Merkle Tree implementation, in our case it means some limits should be enforced in the xmtpd main logic consuming this pkg.

The entrypoints are:

NewMerkleTree(leaves []Leaf) (*MerkleTree, error) returns a new MerkleTree struct.

Takes an arbitrary amount of leaves, the package only forces a positive amount of them. Ideally the consumer also forces a maximum amount of leaves, depending on the use case. It would make sense for our payer service constructing the tree to have a max size.

A MerkleTree is of type:

type MerkleTree struct {
	tree   []Node
	leaves []Leaf
}
  • The Node only has a field []byte. They're computed by the pkg using keccak.
  • Leaf is just a type alias for []byte. They're provided by the user and can be of arbitrary length, depending on how the consumer creates the data. Our use case is append(address, amount).
  • I believe some malicious actor could alter the tree by getting the tree []Node slice with merkle.Tree(), which would be possible in go because slices are reference values. But then, as Verify requires the original root the new data injected would not be verified. Anyway, this can be solved by just returning a copy of the slices in those specific methods.

Verify(root []byte, proof *MultiProof) (bool, error) verifies a MultiProof.

A MultiProof is of type:

type MultiProof struct {
	values    IndexedValues
	proofs    []Node
	leafCount int
}
  • The root could be enforced to be [32]byte, as we're using keccak256. In the end, all the hashes used by the pkg could be typed as [32]byte. Happy to do it.
  • A malicious actor could, again, alter a MultiProof, by altering the specific slices returned by the accessor methods... but then again those values won't verify against the original provided root.

MerkleTree methods:

  • GenerateMultiProofSequential(startingIndex, count int) (*MultiProof, error)
  • GenerateMultiProofWithIndices(indices []int) (*MultiProof, error)

This are only used to generate a MultiProof and will fail if indices or provided integers are invalid when compared to the tree.

Utility functions - I don't see any injection possibilities here:

  • CalculateBalancedNodesCount(count int) (int, error)
  • GetLeftChild(index int) int
  • GetRightChild(index int) int

Interesting action items:

  • Fix node hashes as [32]byte.
  • Test which builds a merkle tree, alter values and tries to verify them.
  • Test which builds a merkle tree, creates a proof and alter its, and tries to verify it.
  • Random / fuzz testing.

@mkysel @neekolas any other suggestion here to harden the package?

For the sake of legibility I'll work on those in a separate PR. :)

@fbac fbac merged commit 67cbc6e into main Apr 21, 2025
8 checks passed
@fbac fbac deleted the 04-11-pkg_merkle branch April 21, 2025 15:26
@coderabbitai coderabbitai bot mentioned this pull request May 20, 2025
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.

4 participants