Skip to content

fix(merkle): descend using build_tree's midpoint in collect_proof#6

Merged
XD637 merged 2 commits into
quantachain:mainfrom
jenish-25:fix/merkle-proof-midpoint
Jul 13, 2026
Merged

fix(merkle): descend using build_tree's midpoint in collect_proof#6
XD637 merged 2 commits into
quantachain:mainfrom
jenish-25:fix/merkle-proof-midpoint

Conversation

@jenish-25

@jenish-25 jenish-25 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Description

MerkleTree::generate_proof only returns verifiable proofs when the number of leaves is an exact power of two. For every other leaf count, most leaves produce a proof that fails MerkleProof::verify.

build_tree splits leaves at ceil(n / 2):

let mid = (hashes.len() + 1) / 2;   // ceil

but collect_proof descends using floor((start + end) / 2):

let mid = (start + end) / 2;        // floor

The two disagree whenever a subtree holds an odd number of leaves. Proof collection then walks into the wrong child and emits a path that authenticates a neighbouring leaf rather than the requested one.

Failing leaf indices with the current code:

leaves indices whose proof fails to verify
3 0, 1
5 0, 1, 2, 3
6 0, 1, 3, 4
7 0, 1, 2, 3, 4, 5
9 0, 1, 2, 3, 4, 5, 6, 7

1, 2, 4 and 8 leaves pass only because ceil == floor for powers of two.

The existing test_odd_number_of_leaves builds a 3-leaf tree but asserts only on index 2 — the one index that happens to survive the mismatch — which is why this went unnoticed.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Motivation and Context

generate_proof / MerkleProof are the SPV surface of the quanta library. Any light client built on them would reject valid inclusion proofs for essentially every real block, since blocks rarely contain exactly 2^k transactions.

This is not a consensus issue. Merkle roots are computed by build_tree, which is internally self-consistent and used identically by Block::new, Block::validate and Blockchain. Only proof generation is wrong. I verified roots are byte-identical before and after this change for n = 1..=9, so block validation and chain state are untouched.

How Has This Been Tested?

  • Confirmed the three new/updated tests fail on main and pass with the fix.
  • cargo test --lib94 passed, 0 failed (existing block/genesis/consensus tests unchanged).
  • Verified merkle roots are unchanged: printed root_hash() for n = 1..=9 with and without the patch, diffed, identical.
  • cargo clippy --lib and cargo fmt --check are clean for src/core/merkle.rs.

New coverage:

  • test_proofs_verify_for_every_leaf_count — every leaf index for every leaf count 1..=33.
  • test_proof_does_not_verify_for_wrong_leaf — a proof cannot be replayed against a different leaf. (This one is a direct regression guard: on main the proof generated for leaf 0 of a 3-leaf tree actually verifies leaf 1.)
  • test_odd_number_of_leaves — now asserts on all three leaves, not just the last.

Secondary cleanup

build_tree's else branch was unreachable dead code:

let right_hashes = if mid < hashes.len() {
    &hashes[mid..]
} else {
    &hashes[mid - 1..mid]   // "Duplicate last if odd (STANDARDIZED)"
};

mid = ceil(len/2) < len holds for every len >= 2, and len == 1 returns early, so the duplicate-last path never executed and no leaf was ever duplicated. I removed it and documented the actual invariant. Worth noting this is a good property: because leaves are never duplicated, the tree is not susceptible to the CVE-2012-2459 style root collision. The old comment implied the opposite scheme.

Note for maintainers (not addressed here, happy to file separately)

hash_to_bytes will panic on malformed input — hex::decode(...).expect("Invalid hex hash") and copy_from_slice(&bytes[..32]) on a short slice. It is reachable from the public generate_proof_hex and verify_hex. Internal callers always pass well-formed 64-char hashes so nothing panics today, but returning Option<Hash> would harden the public API. I kept this PR focused rather than folding that in.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • All tests pass (cargo test)
  • Documentation is updated (doc comments on build_tree / collect_proof)

jenish-25 and others added 2 commits July 10, 2026 09:04
build_tree splits leaves at ceil(n/2), but collect_proof descended using
floor((start + end)/2). The two disagree whenever a subtree holds an odd
number of leaves, so proof collection walked into the wrong child and
emitted a path authenticating a neighbouring leaf rather than the target.

The result: MerkleTree::generate_proof only returned verifiable proofs
when the leaf count was an exact power of two. For any other count most
leaves produced proofs that fail MerkleProof::verify -- with 3 leaves,
indices 0 and 1 both fail; with 9 leaves, 8 of 9 fail.

The existing test_odd_number_of_leaves built a 3-leaf tree but asserted
only on index 2, the single index that happens to survive the mismatch,
so the bug went unnoticed.

Fix collect_proof to mirror build_tree's ceil split. Merkle roots are
byte-identical before and after (verified for n = 1..=9), so block
validation and consensus are unaffected -- only proof generation changes.

Also drop the unreachable "duplicate last if odd" branch in build_tree:
mid < hashes.len() always holds for len >= 2, so no leaf was ever
duplicated. Document the ceil split and the coupling between the two
functions.

Tests: cover every leaf count 1..=33 for every leaf index, and assert a
proof cannot be replayed against a different leaf.
@XD637

XD637 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Thank you for your contribution!

@XD637
XD637 merged commit e58a570 into quantachain:main Jul 13, 2026
3 checks passed
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.

2 participants