From 2d4bfd9d1b5c58f3b1a099d2e75e14813e1d01c8 Mon Sep 17 00:00:00 2001 From: grumbach Date: Mon, 27 Apr 2026 11:42:36 +0900 Subject: [PATCH 1/2] fix: correct midpoint level so odd-depth uploads don't revert MerklePaymentLib.expectedRewardPools(depth) = 2^ceil(depth/2), but the client was computing midpoints at level ceil(depth/2) from the leaves. ant_merkle::get_nodes_at_level numbers levels from the leaves up (L=0 leaves, L=depth root) so a tree of 2^d leaves has 2^(d-L) nodes at level L. With L=ceil(d/2) the client produced 2^floor(d/2) pools, half the count for odd depths, and the on-chain payForMerkleTree call reverted with WrongPoolCount(expected, actual). Setting the midpoint level to floor(depth/2) yields 2^ceil(depth/2) midpoints, matching the contract for both even and odd depths. Observed in production as WrongPoolCount(16, 8): any file producing 65-128 chunks (depth 7) failed at payment regardless of network state. Files with chunk counts in 5-8 (depth 3) and 17-32 (depth 5) hit the same bug. Adds five regression tests: - reward_candidate_count_matches_contract_for_all_depths (every leaf count up to MAX_LEAVES) - depth_seven_produces_sixteen_pools_not_eight (the production failure) - depth_one_produces_two_pools_not_one (smallest odd-depth case) - every_midpoint_proof_verifies_for_all_depths (proofs still verify post-fix) - verify_merkle_proof_accepts_all_depths (full production verifier path) --- src/merkle_payments/merkle_tree.rs | 160 ++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/src/merkle_payments/merkle_tree.rs b/src/merkle_payments/merkle_tree.rs index b592ea9..5a6b5d0 100644 --- a/src/merkle_payments/merkle_tree.rs +++ b/src/merkle_payments/merkle_tree.rs @@ -372,8 +372,23 @@ pub fn midpoint_proof_depth(depth: u8) -> u8 { depth.div_ceil(2) } +/// Level at which reward-pool midpoints sit, in `ant_merkle`'s level numbering. +/// +/// `ant_merkle::MerkleTree::get_nodes_at_level(L)` numbers levels from the +/// leaves up — `L = 0` is the leaf layer, `L = depth` is the root — so a +/// padded tree of `2^depth` leaves contains `2^(depth - L)` nodes at level `L`. +/// +/// We need exactly `expected_reward_pools(depth) = 2^ceil(depth/2)` midpoints +/// to match the on-chain `MerklePaymentLib.expectedRewardPools`. Picking +/// `L = floor(depth/2)` yields `2^(depth - floor(depth/2)) = 2^ceil(depth/2)` +/// nodes, matching the contract for both even and odd depths. +/// +/// Do NOT change this to `depth.div_ceil(2)` to "match" `expected_reward_pools`: +/// because the level is counted from the leaves, `ceil(depth/2)` halves the +/// pool count on odd depths and triggers a `WrongPoolCount` revert at the +/// payment vault. fn midpoint_level(depth: u8) -> usize { - depth.div_ceil(2) as usize + (depth / 2) as usize } /// Errors for Merkle proof verification. @@ -555,3 +570,146 @@ impl ant_merkle::Hasher for Sha3Hasher { 32 } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_leaves(count: usize) -> Vec { + (0..count) + .map(|i| { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&(i as u64).to_le_bytes()); + XorName(bytes) + }) + .collect() + } + + type TestResult = std::result::Result<(), Box>; + + /// Regression: client-produced midpoint count must equal the on-chain + /// `expected_reward_pools(depth)`. Pre-fix this only held for even depths; + /// odd depths produced 2^floor(d/2) instead of 2^ceil(d/2), causing + /// `WrongPoolCount` reverts at the payment vault for files in the chunk + /// bands 5-8 (depth 3), 17-32 (depth 5) and 65-128 (depth 7). + #[test] + fn reward_candidate_count_matches_contract_for_all_depths() -> TestResult { + for leaf_count in MIN_LEAVES..=MAX_LEAVES { + let tree = MerkleTree::from_xornames(make_leaves(leaf_count))?; + let candidates = tree.reward_candidates(0)?; + let expected = expected_reward_pools(tree.depth()); + assert_eq!( + candidates.len(), + expected, + "leaf_count={leaf_count} depth={} produced {} pools, contract expects {expected}", + tree.depth(), + candidates.len(), + ); + } + Ok(()) + } + + /// The exact failure observed in production: `WrongPoolCount(16, 8)`. + /// 65-128 chunks → depth 7 → contract expects 16 pools; client must send 16. + #[test] + fn depth_seven_produces_sixteen_pools_not_eight() -> TestResult { + for leaf_count in [65usize, 100, 128] { + let tree = MerkleTree::from_xornames(make_leaves(leaf_count))?; + assert_eq!(tree.depth(), 7, "leaf_count={leaf_count}"); + let candidates = tree.reward_candidates(0)?; + assert_eq!( + candidates.len(), + 16, + "leaf_count={leaf_count} should produce 16 pools, got {}", + candidates.len(), + ); + } + Ok(()) + } + + /// Smallest odd-depth case: depth=1 (2 leaves) must produce 2 midpoint pools. + /// Pre-fix this produced only 1 pool (the root), which would have triggered + /// `WrongPoolCount(2, 1)` for any 2-chunk upload. + #[test] + fn depth_one_produces_two_pools_not_one() -> TestResult { + let tree = MerkleTree::from_xornames(make_leaves(2))?; + assert_eq!(tree.depth(), 1); + let candidates = tree.reward_candidates(0)?; + assert_eq!(candidates.len(), 2); + Ok(()) + } + + /// Each midpoint proof must verify against the tree root using the same + /// `total_leaves_count` the contract uses (= number of midpoints). + #[test] + fn every_midpoint_proof_verifies_for_all_depths() -> TestResult { + for leaf_count in MIN_LEAVES..=MAX_LEAVES { + let tree = MerkleTree::from_xornames(make_leaves(leaf_count))?; + let tree_root = tree.root(); + let candidates = tree.reward_candidates(0)?; + for (i, mp) in candidates.iter().enumerate() { + assert!( + mp.branch.verify(), + "midpoint {i}/{} failed branch.verify() (leaf_count={leaf_count}, depth={}, branch_root={:?}, tree_root={:?})", + candidates.len(), + tree.depth(), + mp.branch.root(), + tree_root, + ); + assert_eq!( + mp.branch.depth(), + midpoint_proof_depth(tree.depth()) as usize, + "midpoint {i} proof depth mismatch (leaf_count={leaf_count}, depth={})", + tree.depth(), + ); + assert_eq!( + mp.branch.root(), + &tree_root, + "midpoint {i} root divergence (leaf_count={leaf_count})", + ); + } + } + Ok(()) + } + + /// End-to-end: the production verifier `verify_merkle_proof` must accept + /// proofs produced by the fixed client across every supported depth. + /// `branch.verify()` alone only checks Merkle hashing — this exercises + /// the full set of checks (proof depth, root match, leaf identity, timestamp). + #[test] + fn verify_merkle_proof_accepts_all_depths() -> TestResult { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + for leaf_count in MIN_LEAVES..=MAX_LEAVES { + let leaves = make_leaves(leaf_count); + let first_leaf = match leaves.first() { + Some(leaf) => *leaf, + None => return Err(format!("make_leaves({leaf_count}) returned empty").into()), + }; + let tree = MerkleTree::from_xornames(leaves)?; + let address_branch = tree.generate_address_proof(0, first_leaf)?; + let candidates = tree.reward_candidates(now)?; + let winner = match candidates.first() { + Some(c) => c.clone(), + None => { + return Err(format!( + "no reward candidates for leaf_count={leaf_count} depth={}", + tree.depth() + ) + .into()); + } + }; + let root = tree.root(); + + verify_merkle_proof( + &first_leaf, + &address_branch, + &winner, + tree.depth(), + &root, + now, + )?; + } + Ok(()) + } +} From 53fcb893cf49dd5fa68d2a2388b8aabaf986001e Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Mon, 27 Apr 2026 10:10:10 +0200 Subject: [PATCH 2/2] chore: bump version to 0.8.1 Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f814301..d2f1315 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ homepage = "https://maidsafe.net" license = "GPL-3.0" name = "evmlib" repository = "https://github.com/WithAutonomi/evmlib" -version = "0.8.0" +version = "0.8.1" [features] external-signer = []