diff --git a/docs/merkle-proof-depth-bound.md b/docs/merkle-proof-depth-bound.md new file mode 100644 index 00000000..5496c822 --- /dev/null +++ b/docs/merkle-proof-depth-bound.md @@ -0,0 +1,172 @@ +# Merkle Proof Depth Bound (`MAX_PROOF_DEPTH = 32`) + +## Summary + +The `verify_merkle_proof` entrypoint enforces a hard upper limit on the number of +sibling hashes accepted in a single Merkle membership proof. + +| Constant | Value | Location | +|----------|-------|----------| +| `MAX_PROOF_DEPTH` | `32` | `src/merkle_helpers.rs` | + +Proofs longer than 32 siblings are rejected **before any hashing** with: + +- **Error** `RevoraError::ProofTooDeep` (wire value `77`) +- **Event** `prf_rej_d` — topics `(prf_rej_d, caller)`, data `(proof_len, MAX_PROOF_DEPTH)` + +--- + +## Rationale + +### Why a depth bound is necessary + +Each step in proof verification requires: + +1. One `SHA-256` call (≈ O(1) instructions per 64-byte input) +2. Two `BytesN<32>` allocations for the sorted-pair comparison +3. Two `Bytes` copies for the `min/max` ordering + +Without a bound, an adversary submitting `proof.len() = 1_000_000` would force +**one million SHA-256 calls** inside a single contract invocation, exhausting both +the Soroban instruction budget and the per-frame memory budget. + +### Why 32 specifically + +A standard binary Merkle tree over **N** leaves has depth `ceil(log₂(N))`. + +| Leaf count | Max depth | +|-----------|-----------| +| 50 (current `MAX_SNAPSHOT_BATCH`) | 6 | +| 1 000 | 10 | +| 1 000 000 | 20 | +| 4 294 967 296 (2³²) | **32** | + +A depth of 32 covers every realistic snapshot while providing ~26 levels of +headroom above the current batch limit. + +### Why the check is O(1) + +```rust +if proof.len() > MAX_PROOF_DEPTH { + // emit event, return error + return Err(MerkleError::ProofTooDeep); +} +``` + +`Vec::len()` in the Soroban SDK is a single field read — no iteration, no hashing. +An adversary cannot make this check expensive regardless of the proof content. + +--- + +## API + +### Helper function (`src/merkle_helpers.rs`) + +```rust +pub fn verify_merkle_proof( + env: &Env, + leaf_hash: BytesN<32>, + root: BytesN<32>, + proof: &Vec>, +) -> Result +``` + +| Return value | Meaning | +|-------------|---------| +| `Ok(true)` | Leaf is a member of the tree | +| `Ok(false)` | Proof is structurally valid but path does not reach root | +| `Err(MerkleError::ProofTooDeep)` | `proof.len() > MAX_PROOF_DEPTH` | + +### Contract entrypoint (`src/lib.rs`) + +```rust +pub fn verify_merkle_proof( + env: Env, + caller: Address, + leaf_hash: BytesN<32>, + root: BytesN<32>, + proof: Vec>, +) -> Result +``` + +| Return value | Meaning | +|-------------|---------| +| `Ok(true)` | Leaf is a member of the tree | +| `Ok(false)` | Proof structurally valid but root mismatch | +| `Err(RevoraError::ProofTooDeep)` | `proof.len() > MAX_PROOF_DEPTH` | + +No auth required. No storage reads or writes. + +--- + +## Error codes + +| Name | Wire value | Module | +|------|-----------|--------| +| `MerkleError::ProofTooDeep` | `1003` | `merkle_helpers` | +| `RevoraError::ProofTooDeep` | `77` | `lib.rs` | + +Wire values are **frozen**. Do not renumber. + +--- + +## Event: `prf_rej_d` + +Emitted by the contract entrypoint **only** when `proof.len() > MAX_PROOF_DEPTH`. +Never emitted for valid-depth proofs. + +``` +Topics: (Symbol("prf_rej_d"), caller: Address) +Data: (proof_len: u32, MAX_PROOF_DEPTH: u32) +``` + +Off-chain indexers can use this event to: +- Monitor for oversized-proof submission attempts +- Alert on potential denial-of-service probing +- Audit which callers are sending malformed proofs + +--- + +## Proof construction (off-chain) + +To build a valid proof for leaf `i` in a tree of `N` leaves: + +1. Build all leaf hashes: `h[i] = SHA-256(0x00 || holder_xdr || share_bps_xdr)` +2. Build the tree bottom-up, at each level computing: + `parent = SHA-256(0x01 || min(left, right) || max(left, right))` +3. The proof for leaf `i` is the sequence of sibling hashes at each level + from bottom to top (length = `ceil(log₂(N))`) + +The maximum valid proof for any current snapshot is **6 siblings** (50-leaf batch, +depth = `ceil(log₂(50)) = 6`), far below the `MAX_PROOF_DEPTH = 32` ceiling. + +--- + +## Changing the bound + +| Action | Compatibility | +|--------|--------------| +| Increase `MAX_PROOF_DEPTH` | Backwards-compatible: old valid proofs still accepted | +| Decrease `MAX_PROOF_DEPTH` | **Breaking change**: proofs between new and old bound are rejected | + +Any decrease requires a contract version bump and migration notice. + +--- + +## Test coverage + +Tests live in `src/test_merkle_proof_depth.rs`: + +| Test | What it checks | +|------|---------------| +| `max_proof_depth_is_32` | Constant value | +| `helper_proof_at_max_depth_ok_true` | Exactly `MAX_PROOF_DEPTH` → accepted | +| `helper_proof_one_over_max_depth_err_proof_too_deep` | `MAX_PROOF_DEPTH + 1` → rejected | +| `helper_proof_depth_100_err_proof_too_deep` | Far over bound → rejected | +| `helper_proof_depth_zero_accepted` | Empty proof → accepted | +| `contract_proof_at_max_depth_ok_true` | Contract entrypoint boundary check | +| `contract_proof_one_over_max_depth_err_proof_too_deep` | Contract entrypoint rejects oversized | +| `contract_oversized_proof_emits_proof_reject_depth_event` | `prf_rej_d` event emitted | +| `contract_valid_depth_proof_no_reject_event` | No event for valid proofs | +| `round_trip_two_leaf_tree_both_leaves_verify` | Build + verify self-consistency | +| `round_trip_tampered_sibling_fails_verification` | Tampered proof returns `Ok(false)` | diff --git a/src/lib.rs b/src/lib.rs index 802056f6..82aa1713 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -330,6 +330,17 @@ pub enum RevoraError { /// until the holder relocates to an allowed jurisdiction or the issuer /// updates the allowlist. JurisdictionMigrationDeadlineExceeded = 76, + /// The `proof` vector supplied to `verify_merkle_proof` exceeds + /// `MAX_PROOF_DEPTH` (32) siblings. + /// + /// Very deep proofs risk exhausting contract memory and gas budgets. + /// The check is performed before any hashing so the contract incurs no + /// additional cost from the oversized payload. A `proof_reject_depth` + /// event is emitted before returning this error so off-chain indexers + /// can observe and alert on oversized-proof attempts. + /// + /// Wire value: 77. Stable since v1. + ProofTooDeep = 77, } pub mod tax_bucket; @@ -624,6 +635,15 @@ const EVENT_SNAP_COMMIT: Symbol = symbol_short!("snap_cmt"); const EVENT_SNAP_SHARES_APPLIED: Symbol = symbol_short!("snap_shr"); const EVENT_SNAP_FINALIZED: Symbol = symbol_short!("snap_fin"); const EVENT_SNAP_FINALIZATION_CONFIG: Symbol = symbol_short!("snap_fnc"); +/// Emitted when a Merkle proof is rejected because its depth exceeds `MAX_PROOF_DEPTH`. +/// +/// Topics: `(proof_reject_depth, caller)` +/// Data: `(proof_len, MAX_PROOF_DEPTH)` +/// +/// Off-chain indexers can use this event to detect and alert on oversized-proof +/// submission attempts. Because the check fires before any hashing, the contract +/// incurs no additional compute cost from the malicious payload. +const EVENT_PROOF_REJECT_DEPTH: Symbol = symbol_short!("prf_rej_d"); const EVENT_FREEZE_OFFERING: Symbol = symbol_short!("frz_off"); const EVENT_UNFREEZE_OFFERING: Symbol = symbol_short!("ufrz_off"); const EVENT_PROPOSAL_CREATED: Symbol = symbol_short!("prop_new"); @@ -14038,6 +14058,72 @@ impl RevoraRevenueShare { } } + + // ── Merkle proof verification ──────────────────────────────────────────── + + /// Verify a Merkle membership proof against a known root. + /// + /// This is a **read-only**, **auth-free** entrypoint that lets off-chain clients + /// confirm that a specific leaf is a member of the Merkle tree with the given + /// root hash without performing any state mutations. + /// + /// ## Parameters + /// + /// * `leaf_hash` — SHA-256 hash of the leaf being proven (use + /// `SHA-256(0x00 || holder_xdr || share_bps_xdr)` to match the on-chain leaf + /// construction from [`crate::merkle_helpers`]). + /// * `root` — The expected Merkle root (e.g. from a committed and finalized snapshot). + /// * `proof` — Ordered vector of sibling hashes, one per tree level, bottom-up. + /// + /// ## Depth bound — `MAX_PROOF_DEPTH = 32` + /// + /// The `proof` vector **must not exceed `MAX_PROOF_DEPTH` (32)** siblings. + /// A standard binary Merkle tree over 2³² leaves has depth 32, making this + /// sufficient for any realistic snapshot while preventing gas and memory + /// exhaustion from adversarially crafted deep proofs. + /// + /// If `proof.len() > MAX_PROOF_DEPTH` the function: + /// 1. Emits a `proof_reject_depth` event carrying `(proof_len, MAX_PROOF_DEPTH)` + /// so off-chain indexers can detect and alert on oversized-proof attempts. + /// 2. Returns `Err(RevoraError::ProofTooDeep)` without performing any hashing. + /// + /// ## Returns + /// + /// * `Ok(true)` — proof is valid; the leaf belongs to the tree. + /// * `Ok(false)` — proof is structurally valid but the computed path does not + /// reach the given root (leaf is not a member, or root/proof is wrong). + /// * `Err(RevoraError::ProofTooDeep)` — `proof.len() > MAX_PROOF_DEPTH`. + /// + /// ## Security notes + /// + /// * No storage reads or writes; no token transfers. + /// * No auth required — the function is purely computational. + /// * The depth check executes **before** any SHA-256 calls, so an adversary + /// cannot force expensive hashing by submitting an oversized proof. + pub fn verify_merkle_proof( + env: Env, + caller: Address, + leaf_hash: BytesN<32>, + root: BytesN<32>, + proof: Vec>, + ) -> Result { + use crate::merkle_helpers::{verify_merkle_proof as merkle_verify_proof, MAX_PROOF_DEPTH}; + + // Depth-bound check with event emission on failure. + // This mirrors the check inside `merkle_verify_proof` but also emits the + // structured event required by the contract API contract. + if proof.len() > MAX_PROOF_DEPTH { + env.events().publish( + (EVENT_PROOF_REJECT_DEPTH, caller), + (proof.len(), MAX_PROOF_DEPTH), + ); + return Err(RevoraError::ProofTooDeep); + } + + merkle_verify_proof(&env, leaf_hash, root, &proof) + .map_err(|_e| RevoraError::ProofTooDeep) + } + /// Execute the storage walker migration from `from_version` to `to_version`. /// /// The walker supports a dry-run mode (`dry_run = true`) that emits plan @@ -14142,3 +14228,7 @@ mod test_merkle_root_rotation; mod test_snapshot_voting_weight; #[cfg(test)] mod test_storage_layout_version; +#[cfg(test)] +mod test_merkle_root_rotation; +#[cfg(test)] +mod test_merkle_proof_depth; diff --git a/src/merkle_helpers.rs b/src/merkle_helpers.rs index c6cc83f2..ba98382e 100644 --- a/src/merkle_helpers.rs +++ b/src/merkle_helpers.rs @@ -1,12 +1,14 @@ -//! # Deterministic Merkle-Tree Construction for Snapshot Finalization +//! # Deterministic Merkle-Tree Construction and Proof Verification //! -//! This module provides two pure helpers for building a tamper-evident, cross- -//! implementation-reproducible Merkle root over a snapshot holder set: +//! This module provides pure helpers for building and verifying a tamper-evident, +//! cross-implementation-reproducible Merkle tree over a snapshot holder set: //! //! * [`canonical_leaves`] — validates and sorts `(Address, share_bps)` pairs into //! a canonical, lexicographic order by holder-address XDR bytes. //! * [`build_merkle_root`] — constructs a standard binary Merkle tree from the sorted //! leaves and returns the SHA-256 root as a `BytesN<32>`. +//! * [`verify_merkle_proof`] — verifies a Merkle membership proof (path from a leaf +//! hash to the root), bounded by [`MAX_PROOF_DEPTH`]. //! //! ## Ordering guarantee //! @@ -32,6 +34,37 @@ //! Combined with [`canonical_leaves`]'s duplicate-address rejection this gives a //! bijection between holder sets and Merkle roots. //! +//! ## Merkle proof verification +//! +//! [`verify_merkle_proof`] accepts a pre-computed `leaf_hash`, a `root`, and an ordered +//! `proof` slice of sibling hashes (one per tree level, bottom-up). It recomputes the +//! path from the leaf to the root applying the same sorted-pair rule at each step: +//! +//! ```text +//! current = leaf_hash +//! for sibling in proof: +//! current = SHA-256( 0x01 || min(current, sibling) || max(current, sibling) ) +//! return current == root +//! ``` +//! +//! ### Proof depth bound — `MAX_PROOF_DEPTH = 32` +//! +//! **`MAX_PROOF_DEPTH = 32`** caps the number of sibling hashes accepted per call. +//! A standard binary Merkle tree over 2³² ≈ 4 billion leaves requires at most 32 +//! levels, so this bound covers every realistic snapshot while providing two critical +//! security guarantees: +//! +//! 1. **Gas / memory safety** — each sibling hash triggers one SHA-256 call and two +//! `Bytes` allocations. Without an upper bound an adversary could submit a proof +//! with millions of siblings, exhausting contract memory and gas. +//! 2. **Fail-fast** — the length check runs *before* any hashing, so an oversized proof +//! is rejected in O(1) and the contract never touches the malicious payload. +//! +//! Proofs longer than `MAX_PROOF_DEPTH` are rejected with [`MerkleError::ProofTooDeep`]. +//! The contract entrypoint [`crate::RevoraRevenueShare::verify_merkle_proof`] additionally +//! emits a `proof_reject_depth` event so off-chain indexers can detect and alert on +//! oversized-proof attempts. +//! //! ## Empty-tree convention //! //! When no leaves are supplied `build_merkle_root` returns `SHA-256(b"")` (the hash of @@ -42,7 +75,7 @@ //! //! 1. **Collision resistance** — relies on SHA-256 collision resistance. No known //! practical attack exists; this is consistent with the rest of the contract. -//! 2. **No state mutation** — both helpers are pure: they read no storage and write no +//! 2. **No state mutation** — all helpers are pure: they read no storage and write no //! storage. They cannot be used to alter contract state and require no auth. //! 3. **Duplicate rejection** — [`canonical_leaves`] returns [`MerkleError::DuplicateAddress`] //! if the same `Address` appears more than once. This prevents a tree with duplicate @@ -52,11 +85,32 @@ //! 5. **Input size** — callers are responsible for bounding the input length. The //! helpers do not enforce a maximum; the contract entry points that call them enforce //! `MAX_SNAPSHOT_BATCH` (50) per call. +//! 6. **Proof depth bound** — [`verify_merkle_proof`] asserts `proof.len() <= +//! [`MAX_PROOF_DEPTH`]` before performing any hashing, preventing gas exhaustion from +//! adversarially large proof vectors. #![allow(dead_code)] use soroban_sdk::{contracterror, xdr::ToXdr, Address, Bytes, BytesN, Env, Vec}; +// ── Depth bound ───────────────────────────────────────────────────────────── + +/// Maximum number of sibling hashes accepted in a single Merkle membership proof. +/// +/// A binary Merkle tree over 2³² leaves has depth 32, so this bound is sufficient +/// for any realistic snapshot while capping the per-call gas cost and preventing +/// memory exhaustion from adversarially crafted proof vectors. +/// +/// # Developer notes +/// +/// * The check is performed **before** any SHA-256 computation, making it O(1). +/// * Off-chain clients should never need a proof longer than `ceil(log2(N))` where +/// `N` is the number of leaves in the snapshot. For the current `MAX_SNAPSHOT_BATCH` +/// of 50 leaves the maximum valid proof depth is 6 — far below this ceiling. +/// * Increasing this value is a backwards-compatible change (old proofs remain valid). +/// Decreasing it is a breaking change and requires a new contract version. +pub const MAX_PROOF_DEPTH: u32 = 32; + // ── Error type ───────────────────────────────────────────────────────────── /// Errors returned by the Merkle-helper functions. @@ -71,6 +125,13 @@ pub enum MerkleError { DuplicateAddress = 1001, /// A `share_bps` value exceeded 10 000 (100 %). InvalidShareBps = 1002, + /// The `proof` vector supplied to [`verify_merkle_proof`] exceeds + /// [`MAX_PROOF_DEPTH`] (32) siblings. + /// + /// Very deep proofs risk exhausting contract memory and gas budgets. + /// The check is performed before any hashing so the contract incurs no + /// additional cost from the oversized payload. + ProofTooDeep = 1003, } // ── Public helpers ────────────────────────────────────────────────────────── @@ -222,6 +283,77 @@ pub fn build_merkle_root(env: &Env, leaves: &Vec) -> BytesN<32> { level.get(0).unwrap() } +/// Verify a Merkle membership proof against a known root. +/// +/// Given a `leaf_hash` (the SHA-256 hash of the leaf being proven), a `root` +/// (the expected Merkle root), and an ordered `proof` vector of sibling hashes +/// (bottom-up, one per tree level), this function recomputes the path from the +/// leaf to the root and returns `true` if and only if the recomputed value equals +/// `root`. +/// +/// ## Depth bound +/// +/// **`proof.len()` must be ≤ [`MAX_PROOF_DEPTH`] (32).** +/// +/// This assertion is checked before any hashing. If the proof is longer, +/// [`MerkleError::ProofTooDeep`] is returned immediately. This prevents +/// adversarially crafted proofs from exhausting contract memory or gas budgets. +/// +/// ## Algorithm +/// +/// At each level the same sorted-pair rule used by [`build_merkle_root`] is applied: +/// +/// ```text +/// current = leaf_hash +/// for sibling in proof: +/// current = SHA-256( 0x01 || min(current, sibling) || max(current, sibling) ) +/// return current == root +/// ``` +/// +/// ## Returns +/// +/// * `Ok(true)` — proof is valid; the leaf is a member of the tree with the given root. +/// * `Ok(false)` — proof is structurally valid but the computed root does not match. +/// * `Err(MerkleError::ProofTooDeep)` — `proof.len() > MAX_PROOF_DEPTH`. +/// +/// ## Empty proof +/// +/// A zero-length `proof` is valid (leaf *is* the root — single-leaf tree). +/// `Ok(leaf_hash == root)` is returned. +/// +/// ## Example (off-chain reference pseudo-code) +/// ```ignore +/// let leaf_hash = hash_leaf(&env, &leaf); +/// let ok = verify_merkle_proof(&env, leaf_hash, root, &siblings)?; +/// assert!(ok, "holder is not in the committed snapshot"); +/// ``` +pub fn verify_merkle_proof( + env: &Env, + leaf_hash: BytesN<32>, + root: BytesN<32>, + proof: &Vec>, +) -> Result { + // ── Depth-bound assertion ─────────────────────────────────────────────── + // + // SECURITY: This check MUST come first — before any iteration or allocation. + // An adversary that can submit an arbitrarily long `proof` vector would cause + // the contract to perform O(proof.len()) SHA-256 calls and byte copies, which + // can exhaust both the instruction and memory budgets. By checking the length + // here we reject oversized proofs in O(1) with no hashing cost. + if proof.len() > MAX_PROOF_DEPTH { + return Err(MerkleError::ProofTooDeep); + } + + // ── Walk the proof path ───────────────────────────────────────────────── + let mut current = leaf_hash; + for i in 0..proof.len() { + let sibling = proof.get(i).unwrap(); + current = hash_node(env, ¤t, &sibling); + } + + Ok(current == root) +} + // ── Private helpers ───────────────────────────────────────────────────────── /// Simple three-way comparison result (no std `Ordering` in no_std environments). diff --git a/src/test_merkle_proof_depth.rs b/src/test_merkle_proof_depth.rs new file mode 100644 index 00000000..784af71f --- /dev/null +++ b/src/test_merkle_proof_depth.rs @@ -0,0 +1,386 @@ +//! Tests for the Merkle proof depth bound (`MAX_PROOF_DEPTH = 32`). +//! +//! Coverage: +//! +//! | Area | Tests | +//! |---------------------------------------------|-------| +//! | `MAX_PROOF_DEPTH` constant value | 1 | +//! | helper — valid proofs | 5 | +//! | helper — depth-bound rejection | 4 | +//! | contract entrypoint — happy path | 2 | +//! | contract entrypoint — ProofTooDeep error | 2 | +//! | event emission — proof_reject_depth | 2 | +//! | integration — build + verify round-trip | 2 | + +#![cfg(test)] + +use crate::merkle_helpers::{ + build_merkle_root, canonical_leaves, verify_merkle_proof as helper_verify, MerkleError, + MAX_PROOF_DEPTH, +}; +use crate::{RevoraError, RevoraRevenueShare, RevoraRevenueShareClient}; +use soroban_sdk::{ + symbol_short, testutils::Address as _, testutils::BytesN as _, Address, BytesN, Env, Vec, +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn make_env() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env +} + +fn make_client(env: &Env) -> RevoraRevenueShareClient<'static> { + let contract_id = env.register_contract(None, RevoraRevenueShare); + RevoraRevenueShareClient::new(env, &contract_id) +} + +/// Leaf hash matching the on-chain construction: +/// `SHA-256( 0x00 || holder_xdr || share_bps_xdr )` +fn make_leaf_hash(env: &Env, holder: &Address, share_bps: u32) -> BytesN<32> { + use soroban_sdk::{xdr::ToXdr, Bytes}; + let mut input = Bytes::new(env); + input.push_back(0x00u8); + input.append(&holder.to_xdr(env)); + input.append(&share_bps.to_xdr(env)); + env.crypto().sha256(&input) +} + +/// Internal-node hash matching the on-chain construction: +/// `SHA-256( 0x01 || min(left, right) || max(left, right) )` +fn make_node_hash(env: &Env, left: &BytesN<32>, right: &BytesN<32>) -> BytesN<32> { + use soroban_sdk::Bytes; + let lb: Bytes = left.clone().into(); + let rb: Bytes = right.clone().into(); + let (lo, hi) = if lb > rb { (rb, lb) } else { (lb, rb) }; + let mut input = Bytes::new(env); + input.push_back(0x01u8); + input.append(&lo); + input.append(&hi); + env.crypto().sha256(&input) +} + +/// Build a synthetic proof chain of `depth` sibling hashes. +/// +/// Returns `(leaf_hash, root, proof_vec)` where walking `proof_vec` from +/// `leaf_hash` via sorted-pair node hashing reaches exactly `root`. +fn build_proof_chain(env: &Env, depth: u32) -> (BytesN<32>, BytesN<32>, Vec>) { + let holder = Address::generate(env); + let leaf_hash = make_leaf_hash(env, &holder, 5_000); + + let mut proof: Vec> = Vec::new(env); + let mut current = leaf_hash.clone(); + + for _ in 0..depth { + let sibling = BytesN::random(env); + proof.push_back(sibling.clone()); + current = make_node_hash(env, ¤t, &sibling); + } + + (leaf_hash, current, proof) +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Constant +// ══════════════════════════════════════════════════════════════════════════════ + +#[test] +fn max_proof_depth_is_32() { + assert_eq!(MAX_PROOF_DEPTH, 32); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// helper verify_merkle_proof — valid proofs +// ══════════════════════════════════════════════════════════════════════════════ + +/// Empty proof, leaf == root → Ok(true). +#[test] +fn helper_empty_proof_leaf_equals_root_ok_true() { + let env = make_env(); + let holder = Address::generate(&env); + let leaf = make_leaf_hash(&env, &holder, 5_000); + let proof: Vec> = Vec::new(&env); + assert_eq!(helper_verify(&env, leaf.clone(), leaf, &proof), Ok(true)); +} + +/// Empty proof, leaf != root → Ok(false). +#[test] +fn helper_empty_proof_leaf_not_root_ok_false() { + let env = make_env(); + let holder = Address::generate(&env); + let leaf = make_leaf_hash(&env, &holder, 5_000); + let wrong_root = BytesN::random(&env); + let proof: Vec> = Vec::new(&env); + assert_eq!(helper_verify(&env, leaf, wrong_root, &proof), Ok(false)); +} + +/// Depth-1 proof with correct sibling → Ok(true). +#[test] +fn helper_depth_1_valid_proof_ok_true() { + let env = make_env(); + let holder = Address::generate(&env); + let leaf = make_leaf_hash(&env, &holder, 5_000); + let sibling = BytesN::random(&env); + let root = make_node_hash(&env, &leaf, &sibling); + let mut proof: Vec> = Vec::new(&env); + proof.push_back(sibling); + assert_eq!(helper_verify(&env, leaf, root, &proof), Ok(true)); +} + +/// Depth-2 proof with correct siblings → Ok(true). +#[test] +fn helper_depth_2_valid_proof_ok_true() { + let env = make_env(); + let (leaf, root, proof) = build_proof_chain(&env, 2); + assert_eq!(helper_verify(&env, leaf, root, &proof), Ok(true)); +} + +/// Depth-1 proof with wrong sibling → Ok(false). +#[test] +fn helper_depth_1_tampered_sibling_ok_false() { + let env = make_env(); + let holder = Address::generate(&env); + let leaf = make_leaf_hash(&env, &holder, 5_000); + let real_sibling = BytesN::random(&env); + let root = make_node_hash(&env, &leaf, &real_sibling); + + // Different sibling — path will not reach root. + let wrong_sibling = BytesN::random(&env); + let mut proof: Vec> = Vec::new(&env); + proof.push_back(wrong_sibling); + assert_eq!(helper_verify(&env, leaf, root, &proof), Ok(false)); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// helper verify_merkle_proof — depth bound +// ══════════════════════════════════════════════════════════════════════════════ + +/// Proof of exactly MAX_PROOF_DEPTH (32) → accepted, returns Ok(true). +#[test] +fn helper_proof_at_max_depth_ok_true() { + let env = make_env(); + let (leaf, root, proof) = build_proof_chain(&env, MAX_PROOF_DEPTH); + assert_eq!(proof.len(), MAX_PROOF_DEPTH); + assert_eq!(helper_verify(&env, leaf, root, &proof), Ok(true)); +} + +/// Proof of MAX_PROOF_DEPTH + 1 (33) → Err(ProofTooDeep). +#[test] +fn helper_proof_one_over_max_depth_err_proof_too_deep() { + let env = make_env(); + let leaf = BytesN::random(&env); + let root = BytesN::random(&env); + let mut proof: Vec> = Vec::new(&env); + for _ in 0..=MAX_PROOF_DEPTH { + // produces MAX_PROOF_DEPTH + 1 entries + proof.push_back(BytesN::random(&env)); + } + assert_eq!(proof.len(), MAX_PROOF_DEPTH + 1); + assert_eq!( + helper_verify(&env, leaf, root, &proof), + Err(MerkleError::ProofTooDeep) + ); +} + +/// Proof of depth 100 (well above MAX_PROOF_DEPTH) → Err(ProofTooDeep). +#[test] +fn helper_proof_depth_100_err_proof_too_deep() { + let env = make_env(); + let leaf = BytesN::random(&env); + let root = BytesN::random(&env); + let mut proof: Vec> = Vec::new(&env); + for _ in 0..100 { + proof.push_back(BytesN::random(&env)); + } + assert_eq!( + helper_verify(&env, leaf, root, &proof), + Err(MerkleError::ProofTooDeep) + ); +} + +/// Proof of depth 0 (empty) → accepted (lower boundary). +#[test] +fn helper_proof_depth_zero_accepted() { + let env = make_env(); + let holder = Address::generate(&env); + let leaf = make_leaf_hash(&env, &holder, 5_000); + let proof: Vec> = Vec::new(&env); + // leaf == root is Ok(true); leaf != root is Ok(false) — neither is an error. + assert!(helper_verify(&env, leaf.clone(), leaf, &proof).is_ok()); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Contract entrypoint — verify_merkle_proof +// ══════════════════════════════════════════════════════════════════════════════ + +/// Contract entrypoint: valid proof at MAX_PROOF_DEPTH → Ok(true). +#[test] +fn contract_proof_at_max_depth_ok_true() { + let env = make_env(); + let client = make_client(&env); + let caller = Address::generate(&env); + let (leaf, root, proof) = build_proof_chain(&env, MAX_PROOF_DEPTH); + + let result = client.try_verify_merkle_proof(&caller, &leaf, &root, &proof); + assert_eq!(result, Ok(Ok(true))); +} + +/// Contract entrypoint: valid proof with wrong root → Ok(false). +#[test] +fn contract_valid_depth_wrong_root_ok_false() { + let env = make_env(); + let client = make_client(&env); + let caller = Address::generate(&env); + let holder = Address::generate(&env); + let leaf = make_leaf_hash(&env, &holder, 5_000); + let wrong_root = BytesN::random(&env); + let proof: Vec> = Vec::new(&env); + + let result = client.try_verify_merkle_proof(&caller, &leaf, &wrong_root, &proof); + assert_eq!(result, Ok(Ok(false))); +} + +/// Contract entrypoint: proof of MAX_PROOF_DEPTH + 1 → Err(ProofTooDeep). +#[test] +fn contract_proof_one_over_max_depth_err_proof_too_deep() { + let env = make_env(); + let client = make_client(&env); + let caller = Address::generate(&env); + let leaf = BytesN::random(&env); + let root = BytesN::random(&env); + let mut proof: Vec> = Vec::new(&env); + for _ in 0..=MAX_PROOF_DEPTH { + proof.push_back(BytesN::random(&env)); + } + assert_eq!(proof.len(), MAX_PROOF_DEPTH + 1); + + let result = client.try_verify_merkle_proof(&caller, &leaf, &root, &proof); + assert_eq!(result.err(), Some(Ok(RevoraError::ProofTooDeep))); +} + +/// Contract entrypoint: proof of depth 100 → Err(ProofTooDeep). +#[test] +fn contract_proof_depth_100_err_proof_too_deep() { + let env = make_env(); + let client = make_client(&env); + let caller = Address::generate(&env); + let leaf = BytesN::random(&env); + let root = BytesN::random(&env); + let mut proof: Vec> = Vec::new(&env); + for _ in 0..100 { + proof.push_back(BytesN::random(&env)); + } + + let result = client.try_verify_merkle_proof(&caller, &leaf, &root, &proof); + assert_eq!(result.err(), Some(Ok(RevoraError::ProofTooDeep))); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Event emission — proof_reject_depth +// ══════════════════════════════════════════════════════════════════════════════ + +/// Oversized proof emits a `prf_rej_d` event before returning ProofTooDeep. +#[test] +fn contract_oversized_proof_emits_proof_reject_depth_event() { + let env = make_env(); + let client = make_client(&env); + let caller = Address::generate(&env); + let leaf = BytesN::random(&env); + let root = BytesN::random(&env); + let mut proof: Vec> = Vec::new(&env); + for _ in 0..=MAX_PROOF_DEPTH { + proof.push_back(BytesN::random(&env)); + } + + let _ = client.try_verify_merkle_proof(&caller, &leaf, &root, &proof); + + let all_events = env.events().all(); + assert!(!all_events.is_empty(), "at least one event must be emitted"); + + let reject_sym = symbol_short!("prf_rej_d"); + let found = all_events.iter().any(|(_cid, topics, _data)| { + topics.get(0) == Some(soroban_sdk::Val::from(reject_sym)) + }); + assert!(found, "prf_rej_d event must appear in the event log"); +} + +/// Valid-depth proof does NOT emit the `prf_rej_d` event. +#[test] +fn contract_valid_depth_proof_no_reject_event() { + let env = make_env(); + let client = make_client(&env); + let caller = Address::generate(&env); + let (leaf, root, proof) = build_proof_chain(&env, MAX_PROOF_DEPTH); + + let _ = client.try_verify_merkle_proof(&caller, &leaf, &root, &proof); + + let all_events = env.events().all(); + let reject_sym = symbol_short!("prf_rej_d"); + let found = all_events.iter().any(|(_cid, topics, _data)| { + topics.get(0) == Some(soroban_sdk::Val::from(reject_sym)) + }); + assert!(!found, "prf_rej_d must NOT be emitted for valid-depth proofs"); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Integration — build_merkle_root + verify_merkle_proof round-trip +// ══════════════════════════════════════════════════════════════════════════════ + +/// Build a real 2-leaf tree, verify each leaf's membership with a correct proof. +#[test] +fn round_trip_two_leaf_tree_both_leaves_verify() { + let env = make_env(); + let h1 = Address::generate(&env); + let h2 = Address::generate(&env); + + let entries = [(h1.clone(), 4_000u32), (h2.clone(), 6_000u32)]; + let leaves = canonical_leaves(&env, &entries).unwrap(); + let root = build_merkle_root(&env, &leaves); + + let l0 = leaves.get(0).unwrap(); + let l1 = leaves.get(1).unwrap(); + let h_l0 = make_leaf_hash(&env, &l0.holder, l0.share_bps); + let h_l1 = make_leaf_hash(&env, &l1.holder, l1.share_bps); + + // Proof for leaf[0]: sibling is leaf[1]. + let mut proof_l0: Vec> = Vec::new(&env); + proof_l0.push_back(h_l1.clone()); + assert!( + helper_verify(&env, h_l0.clone(), root.clone(), &proof_l0).unwrap(), + "leaf[0] must verify against root" + ); + + // Proof for leaf[1]: sibling is leaf[0]. + let mut proof_l1: Vec> = Vec::new(&env); + proof_l1.push_back(h_l0); + assert!( + helper_verify(&env, h_l1, root, &proof_l1).unwrap(), + "leaf[1] must verify against root" + ); +} + +/// A tampered sibling in an otherwise-valid proof causes verification to fail. +#[test] +fn round_trip_tampered_sibling_fails_verification() { + let env = make_env(); + let h1 = Address::generate(&env); + let h2 = Address::generate(&env); + + let entries = [(h1.clone(), 4_000u32), (h2.clone(), 6_000u32)]; + let leaves = canonical_leaves(&env, &entries).unwrap(); + let root = build_merkle_root(&env, &leaves); + + let l0 = leaves.get(0).unwrap(); + let h_l0 = make_leaf_hash(&env, &l0.holder, l0.share_bps); + + // Wrong sibling — verification must return false, not an error. + let mut bad_proof: Vec> = Vec::new(&env); + bad_proof.push_back(BytesN::random(&env)); + + assert_eq!( + helper_verify(&env, h_l0, root, &bad_proof), + Ok(false), + "tampered sibling must produce Ok(false)" + ); +}