diff --git a/packages/rs-drive/src/drive/saved_block_transactions/fetch_compacted_address_balances/v0/mod.rs b/packages/rs-drive/src/drive/saved_block_transactions/fetch_compacted_address_balances/v0/mod.rs index df29ec6f9f2..2a658265428 100644 --- a/packages/rs-drive/src/drive/saved_block_transactions/fetch_compacted_address_balances/v0/mod.rs +++ b/packages/rs-drive/src/drive/saved_block_transactions/fetch_compacted_address_balances/v0/mod.rs @@ -1,5 +1,6 @@ use crate::drive::Drive; use crate::error::Error; +use crate::util::common::compacted_key; use dpp::address_funds::PlatformAddress; use dpp::balances::credits::BlockAwareCreditOperation; use dpp::ProtocolError; @@ -51,11 +52,15 @@ impl Drive { let mut compacted_changes = Vec::new(); let limit_usize = limit.map(|l| l as usize); + // A zero limit can never include any entry — return empty before the + // boundary probe, which would otherwise still surface a containing range. + if limit_usize == Some(0) { + return Ok(compacted_changes); + } + // Query 1: Find if there's a range containing start_block_height // Query descending from (start_block_height, u64::MAX) with limit 1 - let mut desc_end_key = Vec::with_capacity(16); - desc_end_key.extend_from_slice(&start_block_height.to_be_bytes()); - desc_end_key.extend_from_slice(&u64::MAX.to_be_bytes()); + let desc_end_key = compacted_key(start_block_height, u64::MAX); let mut desc_query = Query::new_with_direction(false); // descending desc_query.insert_range_to_inclusive(..=desc_end_key); @@ -119,9 +124,7 @@ impl Drive { // Always use (start_block_height, 0) for consistent proof verification // The result may overlap with descending query if descending found a range // starting exactly at start_block_height - we dedupe below - let mut asc_start_key = Vec::with_capacity(16); - asc_start_key.extend_from_slice(&start_block_height.to_be_bytes()); - asc_start_key.extend_from_slice(&0u64.to_be_bytes()); + let asc_start_key = compacted_key(start_block_height, 0); let mut asc_query = Query::new(); asc_query.insert_range_from(asc_start_key..); @@ -189,11 +192,23 @@ impl Drive { /// Version 0 implementation for proving compacted address balance changes. /// - /// Uses a two-step approach: - /// 1. First query (non-proving): descending to find any range containing start_block_height - /// 2. Second query (proving): ascending from the found start_block or start_block_height + /// The proof must let the verifier authenticate **two** things against the + /// same root hash (see `verify_compacted_address_balance_changes_v0`): + /// + /// 1. A **boundary query** — the single greatest compacted key + /// `<= (start_block_height, u64::MAX)`. This is what protects against the + /// compacted-range absence-proof attack: a range like `(100, 200)` that + /// contains the requested height sorts *before* `(150, 150)`, so the + /// verifier cannot trust a forward-only proof to surface it. + /// 2. A **forward query** — `range_from(start_key..)` where `start_key` is + /// the containing range (if any) or `(start_block_height, + /// start_block_height)`. /// - /// This ensures the proof covers all relevant ranges efficiently. + /// We discover `start_key` with a non-proving descending query, then prove a + /// single combined `PathQuery` (boundary key via `insert_key` + the forward + /// `range_from`, capped at the caller's limit) through + /// `grove_get_proved_path_query`, so the verifier's chained boundary and + /// forward queries are both satisfiable. pub(super) fn prove_compacted_address_balance_changes_v0( &self, start_block_height: u64, @@ -203,10 +218,9 @@ impl Drive { ) -> Result, Error> { let path = Self::saved_compacted_block_transactions_address_balances_path_vec(); - // Step 1: Non-proving descending query to find any range containing start_block_height - let mut desc_end_key = Vec::with_capacity(16); - desc_end_key.extend_from_slice(&start_block_height.to_be_bytes()); - desc_end_key.extend_from_slice(&u64::MAX.to_be_bytes()); + // Step 1: Non-proving descending query to find the greatest compacted + // key <= (start_block_height, u64::MAX). + let desc_end_key = compacted_key(start_block_height, u64::MAX); let mut desc_query = Query::new_with_direction(false); // descending desc_query.insert_range_to_inclusive(..=desc_end_key); @@ -222,48 +236,74 @@ impl Drive { &platform_version.drive, )?; - // Determine the actual start key for the proved query - // If we found a containing range, use its exact key - // Otherwise use (start_block_height, start_block_height) since end_block >= start_block always - let start_key = if let Some((key, _)) = desc_results.to_key_elements().into_iter().next() { - if key.len() == 16 { - let end_block = u64::from_be_bytes(key[8..16].try_into().unwrap()); - // If this range contains start_block_height, use its exact key - if end_block >= start_block_height { - key - } else { - // No containing range, use (start_block_height, start_block_height) - let mut key = Vec::with_capacity(16); - key.extend_from_slice(&start_block_height.to_be_bytes()); - key.extend_from_slice(&start_block_height.to_be_bytes()); - key + // `boundary_key` is the authenticated lower-bound anchor the verifier + // will re-derive from its boundary query. `forward_start` is the lower + // bound of the ascending result scan. + let (boundary_key, forward_start) = match desc_results.to_key_elements().into_iter().next() + { + Some((key, _)) => { + if key.len() != 16 { + return Err(Error::Protocol(Box::new( + ProtocolError::CorruptedSerialization( + "invalid compacted block key length, expected 16 bytes".to_string(), + ), + ))); } - } else { - let mut key = Vec::with_capacity(16); - key.extend_from_slice(&start_block_height.to_be_bytes()); - key.extend_from_slice(&start_block_height.to_be_bytes()); - key + let end_block = u64::from_be_bytes(key[8..16].try_into().map_err(|_| { + Error::Protocol(Box::new(ProtocolError::CorruptedSerialization( + "invalid compacted key slice".to_string(), + ))) + })?); + let forward_start = if end_block >= start_block_height { + key.clone() + } else { + compacted_key(start_block_height, start_block_height) + }; + (Some(key), forward_start) } - } else { - let mut key = Vec::with_capacity(16); - key.extend_from_slice(&start_block_height.to_be_bytes()); - key.extend_from_slice(&start_block_height.to_be_bytes()); - key + None => (None, compacted_key(start_block_height, start_block_height)), }; - // Step 2: Proved ascending query from start_key - - let mut query = Query::new(); - query.insert_range_from(start_key..); - - let path_query = PathQuery::new(path, SizedQuery::new(query, limit, None)); - - self.grove_get_proved_path_query( - &path_query, - transaction, - &mut vec![], - &platform_version.drive, - ) + // Step 2: build the proof. See the nullifier prover and the verifier docs + // for the soundness rationale. + match boundary_key { + Some(boundary_key) => { + // Cap the proof at the caller's `limit` (+1 for the authenticated + // boundary point) so a client requesting `prove=true` from an early + // height cannot force a proof spanning the entire compacted history + // (the public handler passes `limit = Some(25)` "to stay within proof + // size limits"). The verifier re-applies `limit` to its forward subset + // query, so this cap is soundness-neutral. Routed through the + // transaction-aware proof path (not `prove_query_many`, which ignores + // the transaction) for snapshot consistency. + let capped_limit = limit.map(|l| l.saturating_add(1)); + let mut query = Query::new(); + query.insert_key(boundary_key); + query.insert_range_from(forward_start..); + let path_query = + PathQuery::new(path.clone(), SizedQuery::new(query, capped_limit, None)); + + self.grove_get_proved_path_query( + &path_query, + transaction, + &mut vec![], + &platform_version.drive, + ) + } + None => { + let mut forward_query = Query::new(); + forward_query.insert_range_from(forward_start..); + let forward_path_query = + PathQuery::new(path.clone(), SizedQuery::new(forward_query, limit, None)); + + self.grove_get_proved_path_query( + &forward_path_query, + transaction, + &mut vec![], + &platform_version.drive, + ) + } + } } } diff --git a/packages/rs-drive/src/drive/saved_block_transactions/queries.rs b/packages/rs-drive/src/drive/saved_block_transactions/queries.rs index 2b8556805d8..0e726fdda5b 100644 --- a/packages/rs-drive/src/drive/saved_block_transactions/queries.rs +++ b/packages/rs-drive/src/drive/saved_block_transactions/queries.rs @@ -7,11 +7,21 @@ pub const ADDRESS_BALANCES_KEY: &[u8; 1] = b"m"; /// The subtree key for address balances storage as u8 pub const ADDRESS_BALANCES_KEY_U8: u8 = b'm'; -/// The subtree key for address balances storage -pub const COMPACTED_ADDRESS_BALANCES_KEY: &[u8; 1] = b"c"; +/// The subtree key for compacted address balances storage. +/// +/// Derived from [`COMPACTED_ADDRESS_BALANCES_KEY_U8`] so the `b'c'` byte is +/// written in exactly one place (the `verify`-available `crate::util::common`) +/// and the slice form cannot drift from the u8. +pub const COMPACTED_ADDRESS_BALANCES_KEY: &[u8; 1] = &[COMPACTED_ADDRESS_BALANCES_KEY_U8]; -/// The subtree key for compacted address balances storage as u8 -pub const COMPACTED_ADDRESS_BALANCES_KEY_U8: u8 = b'c'; +/// The subtree key for compacted address balances storage as u8. +/// +/// Re-exported from the `verify`-available [`crate::util::common`] so the proof +/// verifier and this server-side storage path share a single definition of the +/// byte (it is part of the proof contract and must not drift). The downstream +/// import path (`saved_block_transactions::COMPACTED_ADDRESS_BALANCES_KEY_U8`, +/// via `pub use queries::*`) is unchanged. +pub use crate::util::common::COMPACTED_ADDRESS_BALANCES_KEY_U8; /// The subtree key for compacted addresses expiration time storage pub const COMPACTED_ADDRESSES_EXPIRATION_TIME_KEY: &[u8; 1] = b"e"; @@ -42,11 +52,13 @@ impl Drive { } /// Path to compacted address balances under saved block transactions. + /// + /// Delegates to the `verify`-available canonical + /// [`crate::util::common::compacted_address_balances_path`] so the + /// storage/fetch (server) path and the proof verifier (verify) path share a + /// single definition and cannot drift. pub fn saved_compacted_block_transactions_address_balances_path_vec() -> Vec> { - vec![ - vec![RootTree::SavedBlockTransactions as u8], - vec![COMPACTED_ADDRESS_BALANCES_KEY_U8], - ] + crate::util::common::compacted_address_balances_path() } /// Path to compacted address balances under saved block transactions. diff --git a/packages/rs-drive/src/util/common/mod.rs b/packages/rs-drive/src/util/common/mod.rs index 1c80d24e06b..beaf31b76ec 100644 --- a/packages/rs-drive/src/util/common/mod.rs +++ b/packages/rs-drive/src/util/common/mod.rs @@ -1,2 +1,40 @@ pub mod decode; pub mod encode; + +/// Builds the 16-byte big-endian compacted-block key `(start_block, end_block)` +/// shared by the shielded-nullifier and address-balance compacted trees. +/// +/// This 16-byte boundary-key encoding is part of the chained-proof contract: +/// every prover and its matching verifier MUST construct keys identically, or +/// chained verification silently breaks. It therefore lives in exactly one +/// place (the four compacted prove/verify modules import it) so the encoding +/// cannot drift between them. +pub(crate) fn compacted_key(start_block: u64, end_block: u64) -> Vec { + let mut key = Vec::with_capacity(16); + key.extend_from_slice(&start_block.to_be_bytes()); + key.extend_from_slice(&end_block.to_be_bytes()); + key +} + +/// Canonical subtree key for the compacted address-balance tree under +/// `SavedBlockTransactions`. +/// +/// Defined here (a `verify`-available module) rather than in the `server`-gated +/// `saved_block_transactions::queries` so the **verify-side** proof verifier and +/// the **server-side** storage/fetch path reference one definition — the subtree +/// location is part of the proof contract and must not drift between them. +/// `saved_block_transactions::queries` re-exports this constant for its +/// server-side callers, so the `b'c'` byte is written in exactly one place. +pub const COMPACTED_ADDRESS_BALANCES_KEY_U8: u8 = b'c'; + +/// Path to the compacted address-balance subtree under `SavedBlockTransactions`. +/// +/// Shared by the server-side storage/fetch path and the verify-side proof +/// verifier; both sides reach it through this one helper. See +/// [`COMPACTED_ADDRESS_BALANCES_KEY_U8`] for why the byte lives in this module. +pub(crate) fn compacted_address_balances_path() -> Vec> { + vec![ + vec![crate::drive::RootTree::SavedBlockTransactions as u8], + vec![COMPACTED_ADDRESS_BALANCES_KEY_U8], + ] +} diff --git a/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/v0/mod.rs b/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/v0/mod.rs index bd34a9d70e5..166cb9c9d89 100644 --- a/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/v0/mod.rs +++ b/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/v0/mod.rs @@ -1,58 +1,61 @@ use crate::drive::Drive; -use crate::drive::RootTree; +use crate::error::drive::DriveError; use crate::error::proof::ProofError; use crate::error::Error; +use crate::util::common::{compacted_address_balances_path, compacted_key}; use crate::verify::RootHash; use dpp::address_funds::PlatformAddress; -/// The subtree key for compacted address balances storage as u8 -const COMPACTED_ADDRESS_BALANCES_KEY_U8: u8 = b'c'; use dpp::balances::credits::BlockAwareCreditOperation; -use grovedb::operations::proof::{GroveDBProof, ProofBytes}; -use grovedb::{ - GroveDb, MerkProofDecoder, MerkProofNode, MerkProofOp, PathQuery, Query, SizedQuery, -}; +use grovedb::query_result_type::PathKeyOptionalElementTrio; +use grovedb::{GroveDb, PathQuery, Query, SizedQuery}; use platform_version::version::PlatformVersion; use std::collections::BTreeMap; use super::VerifiedCompactedAddressBalanceChanges; -/// Extract KV entries from merk proof bytes using the proper decoder. -#[allow(clippy::type_complexity)] -fn extract_kv_entries_from_merk_proof(merk_proof: &[u8]) -> Result, Vec)>, Error> { - let mut entries = Vec::new(); - - let decoder = MerkProofDecoder::new(merk_proof); - - for op in decoder { - match op { - Ok(MerkProofOp::Push(MerkProofNode::KV(key, value))) - | Ok(MerkProofOp::PushInverted(MerkProofNode::KV(key, value))) => { - entries.push((key, value)); - } - Err(e) => { - tracing::error!(%e, "merk proof decode error"); - return Err(Error::Proof(ProofError::CorruptedProof(format!( - "failed to decode merk proof op: {}", - e - )))); - } - _ => {} - } - } - - Ok(entries) -} - impl Drive { /// Verifies compacted address balance changes proof. /// - /// This verification works by: - /// 1. Decoding the GroveDBProof structure - /// 2. Navigating to the compacted address balances layer ('c') - /// 3. Extracting KV entries from the merk proof - /// 4. Filtering entries where the key range contains start_block_height - /// 5. Verifying the root hash using a subset query + /// # Soundness + /// + /// Compacted keys are 16 bytes `(start_block_be, end_block_be)`. A range + /// such as `(100, 200)` that *contains* a requested height `150` sorts + /// lexicographically **before** `(150, 150)`. This means the lower bound of + /// the forward range scan (`start_key`) cannot be trusted to come from the + /// caller-requested height alone — a malicious prover could prove + /// `range_from((150, 150)..)` directly and the containing range `(100, 200)` + /// would appear only as a hash-only boundary node, silently hiding a change. + /// + /// To close this hole we never derive `start_key` from un-authenticated + /// proof bytes. Instead we use GroveDB's chained path query verification: + /// + /// 1. A **boundary query** (descending `range_to_inclusive(..=(start, MAX))`, + /// limit 1) authenticates, against the real root hash, the single + /// greatest compacted key `<= (start_block_height, u64::MAX)`. A malicious + /// prover cannot substitute or omit this key without breaking the root + /// hash. + /// 2. A **generator** inspects that authenticated boundary key. If its + /// `end_block >= start_block_height` the range contains (or starts at) the + /// requested height, so we use that exact key as the forward `start_key`. + /// Otherwise no containing range exists and we fall back to + /// `(start_block_height, start_block_height)`. + /// 3. The chained **forward query** (`range_from(start_key..)`, caller limit) + /// is verified against the same root hash, and its authenticated results + /// are decoded into the returned changes. + /// + /// # KNOWN LIVENESS BUG (tracked in PR #3792 — fix deferred) + /// + /// Identical to the shielded-nullifier verifier: the boundary query is + /// **descending** while the forward query is **ascending**, and a single + /// GroveDB proof is one-directional, so when **two or more** compacted + /// address-balance ranges sort at/below `start_block_height` the honest proof + /// fails verification with "Cannot verify upper bound of queried range" (see + /// the `#[ignore]`d `multiple_ranges_below_query_height_verify` regression + /// test). The single-range and empty cases work. The planned fix is to re-key + /// compacted entries by `(end_block, start_block)` so retrieval becomes a + /// single ascending `range_from((H, 0)..)` — which also closes the original + /// absence-proof soundness hole structurally. pub(super) fn verify_compacted_address_balance_changes_v0( proof: &[u8], start_block_height: u64, @@ -63,103 +66,85 @@ impl Drive { .with_big_endian() .with_no_limit(); - // Decode the GroveDBProof to navigate its structure - let grovedb_proof: GroveDBProof = bincode::decode_from_slice(proof, bincode_config) - .map(|(p, _)| p) - .map_err(|e| { - Error::Proof(ProofError::CorruptedProof(format!( - "cannot decode GroveDBProof: {}", - e - ))) - })?; - - // Navigate to the compacted address balances layer - // Path: SavedBlockTransactions ('$' = 0x24) -> CompactedAddressBalances ('c' = 0x63) - let saved_block_key = vec![RootTree::SavedBlockTransactions as u8]; - let compacted_key = vec![COMPACTED_ADDRESS_BALANCES_KEY_U8]; - - // Extract KV entries from the compacted layer's merk proof to find - // if there's a containing range for start_block_height. - // V0 and V1 proofs have different layer types (MerkOnlyLayerProof vs LayerProof), - // so we handle them separately. - let kv_entries = match &grovedb_proof { - GroveDBProof::V0(v0) => { - let compacted_layer = v0 - .root_layer - .lower_layers - .get(&saved_block_key) - .and_then(|layer| layer.lower_layers.get(&compacted_key)); - compacted_layer - .map(|layer| extract_kv_entries_from_merk_proof(&layer.merk_proof)) - .transpose()? - .unwrap_or_default() - } - GroveDBProof::V1(v1) => { - let compacted_layer = v1 - .root_layer - .lower_layers - .get(&saved_block_key) - .and_then(|layer| layer.lower_layers.get(&compacted_key)); - compacted_layer - .map(|layer| match &layer.merk_proof { - ProofBytes::Merk(bytes) => extract_kv_entries_from_merk_proof(bytes), - other => Err(Error::Proof(ProofError::CorruptedProof(format!( - "unsupported V1 proof bytes variant for compacted address balances: {:?}", - std::mem::discriminant(other) - )))), + let path = compacted_address_balances_path(); + + // Step 1: boundary query — authenticate the single greatest compacted + // key <= (start_block_height, u64::MAX). Descending, limit 1. + let boundary_end_key = compacted_key(start_block_height, u64::MAX); + let mut boundary_inner = Query::new_with_direction(false); // descending + boundary_inner.insert_range_to_inclusive(..=boundary_end_key); + let boundary_query = + PathQuery::new(path.clone(), SizedQuery::new(boundary_inner, Some(1), None)); + + // Step 2: generator — derive the forward query's lower bound from the + // AUTHENTICATED boundary result (not from raw proof bytes). + let forward_path = path.clone(); + let generator = + move |boundary_results: Vec| -> Option { + // A non-16-byte authenticated key can't be parsed, so it is skipped + // here; the post-verification check below then rejects it as proof + // corruption rather than letting it silently degrade the forward + // lower bound to (start, start). + let start_key = boundary_results + .iter() + .find_map(|(_path, key, _element)| { + if key.len() != 16 { + return None; + } + let end_block = u64::from_be_bytes( + key[8..16].try_into().expect("len checked to be 16"), + ); + if end_block >= start_block_height { + Some(key.clone()) + } else { + None + } }) - .transpose()? - .unwrap_or_default() - } - }; - - // Look for a KV entry where the range contains start_block_height - // Keys are 16 bytes: (start_block, end_block), both big-endian - let containing_key = kv_entries.iter().find_map(|(key, _)| { - if key.len() != 16 { - return None; - } - let range_start = u64::from_be_bytes(key[0..8].try_into().unwrap()); - let range_end = u64::from_be_bytes(key[8..16].try_into().unwrap()); - - // Check if this range contains start_block_height - if range_start <= start_block_height && start_block_height <= range_end { - Some(key.clone()) - } else { - None - } - }); - - // Determine the start_key for the query - // Use the containing range's key if found, otherwise (start_block_height, start_block_height) - let start_key = containing_key.unwrap_or_else(|| { - let mut key = Vec::with_capacity(16); - key.extend_from_slice(&start_block_height.to_be_bytes()); - key.extend_from_slice(&start_block_height.to_be_bytes()); - key - }); - - // Verify the proof and get results using subset query - let path = vec![ - vec![RootTree::SavedBlockTransactions as u8], - vec![COMPACTED_ADDRESS_BALANCES_KEY_U8], - ]; - - let mut query = Query::new(); - query.insert_range_from(start_key..); - - let path_query = PathQuery::new(path, SizedQuery::new(query, limit, None)); + .unwrap_or_else(|| compacted_key(start_block_height, start_block_height)); + + let mut forward_inner = Query::new(); + forward_inner.insert_range_from(start_key..); + Some(PathQuery::new( + forward_path.clone(), + SizedQuery::new(forward_inner, limit, None), + )) + }; - let (root_hash, proved_key_values) = GroveDb::verify_subset_query( + // Step 3: verify the chained queries. GroveDB enforces that every + // sub-query binds to the SAME root hash. + let (root_hash, results) = GroveDb::verify_query_with_chained_path_queries( proof, - &path_query, + &boundary_query, + vec![generator], &platform_version.drive.grove_version, )?; - // Process the verified results + // The generator always returns `Some`, so on success GroveDB returns + // exactly two result sets: [boundary, forward]. A different cardinality + // here can only be a GroveDB-internal invariant break (the generator + // never declines), so classify it as an internal code-execution error + // rather than `CorruptedProof`. + let [boundary_results, forward_results]: [Vec; 2] = + results.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "chained verification invariant: expected [boundary, forward] result sets", + )) + })?; + + // The authenticated boundary key must be a 16-byte `(start, end)` compacted + // key. A non-16-byte key never occurs for honest state, so a proof + // presenting one is corrupt — reject it (the generator above skipped it and + // would otherwise have silently degraded the forward lower bound). + if boundary_results.iter().any(|(_p, key, _e)| key.len() != 16) { + return Err(Error::Proof(ProofError::CorruptedProof( + "authenticated compacted boundary key is not 16 bytes".to_string(), + ))); + } + + // Process the verified forward results. let mut compacted_changes = Vec::new(); - for (_path, key, maybe_element) in proved_key_values { + for (_path, key, maybe_element) in forward_results { let Some(element) = maybe_element else { continue; }; @@ -203,7 +188,7 @@ mod tests { use super::*; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::address_funds::PlatformAddress; - use dpp::balances::credits::CreditOperation; + use dpp::balances::credits::{BlockAwareCreditOperation, CreditOperation}; use platform_version::version::PlatformVersion; use std::collections::BTreeMap; @@ -298,86 +283,222 @@ mod tests { ); } + /// Stores a single compacted address-balance entry directly under the + /// compacted address balances path with the given `(start_block, + /// end_block)` key. Bypasses normal compaction so tests can build exact + /// tree shapes (e.g. a containing range). + fn store_compacted_entry( + drive: &Drive, + start_block: u64, + end_block: u64, + changes: BTreeMap, + platform_version: &PlatformVersion, + ) { + use grovedb::Element; + use grovedb_costs::CostContext; + use grovedb_path::SubtreePath; + + let config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + + let key = compacted_key(start_block, end_block); + let value = bincode::encode_to_vec(&changes, config).expect("encode changes"); + + let path = Drive::saved_compacted_block_transactions_address_balances_path(); + + let CostContext { value: result, .. } = drive.grove.insert( + SubtreePath::from(path.as_ref()), + key.as_slice(), + Element::new_item(value), + None, + None, + &platform_version.drive.grove_version, + ); + result.expect("insert compacted entry"); + } + + /// Honest path returns the containing range `(100, 200)` for a start height + /// inside it (`150`). #[test] - fn test_verify_compacted_address_balance_changes_proof() { - // This proof was generated with start_block_height = 329 - // Path: [[36], [99]] = [['$'], ['c']] = SavedBlockTransactions -> CompactedAddressBalances - // Query: RangeTo(..[0, 0, 0, 0, 0, 0, 1, 73, 0, 0, 0, 0, 0, 0, 1, 73]) = RangeTo(..(329, 329)) - let proof: Vec = vec![ - 0, 251, 1, 24, 1, 24, 215, 122, 183, 233, 12, 7, 14, 37, 119, 175, 74, 229, 6, 76, 88, - 209, 79, 175, 135, 230, 45, 89, 116, 17, 76, 49, 87, 242, 4, 40, 35, 2, 33, 229, 84, - 218, 198, 132, 136, 197, 212, 253, 180, 247, 125, 228, 176, 179, 248, 100, 19, 202, - 143, 20, 196, 132, 112, 114, 44, 43, 11, 174, 49, 96, 16, 4, 1, 36, 0, 5, 2, 1, 1, 101, - 0, 126, 152, 206, 30, 157, 147, 101, 232, 212, 68, 50, 242, 52, 170, 136, 72, 39, 136, - 235, 41, 105, 28, 199, 93, 222, 168, 227, 241, 80, 234, 2, 185, 2, 120, 2, 233, 247, - 175, 89, 203, 114, 37, 58, 187, 95, 169, 151, 247, 245, 82, 228, 73, 77, 131, 5, 100, - 241, 7, 152, 139, 156, 94, 138, 33, 173, 16, 2, 124, 156, 122, 25, 79, 47, 39, 233, 96, - 90, 9, 165, 232, 130, 103, 245, 113, 145, 31, 183, 102, 94, 40, 86, 140, 146, 128, 172, - 85, 184, 13, 220, 16, 1, 48, 30, 57, 216, 246, 121, 172, 45, 163, 129, 189, 9, 238, - 108, 64, 21, 231, 140, 164, 160, 37, 184, 182, 34, 151, 148, 194, 74, 83, 124, 199, 87, - 17, 17, 2, 199, 32, 12, 6, 52, 58, 219, 92, 42, 60, 69, 132, 209, 186, 193, 248, 18, - 33, 26, 78, 216, 110, 114, 103, 202, 56, 45, 175, 163, 68, 139, 6, 16, 1, 62, 159, 56, - 33, 169, 193, 143, 7, 131, 179, 169, 31, 163, 163, 50, 117, 235, 211, 94, 247, 244, 60, - 246, 149, 186, 142, 105, 187, 230, 247, 212, 141, 17, 1, 1, 36, 125, 4, 1, 99, 0, 20, - 2, 1, 16, 0, 0, 0, 0, 0, 0, 1, 4, 0, 0, 0, 0, 0, 0, 1, 8, 0, 97, 206, 71, 247, 121, 93, - 103, 7, 209, 119, 82, 59, 209, 145, 171, 254, 112, 14, 204, 81, 30, 98, 213, 203, 146, - 141, 32, 167, 232, 34, 0, 37, 2, 170, 200, 228, 36, 229, 197, 116, 242, 100, 137, 25, - 37, 45, 57, 56, 2, 38, 8, 75, 144, 250, 71, 108, 90, 106, 133, 2, 231, 236, 42, 149, - 206, 16, 1, 77, 52, 100, 69, 203, 109, 100, 190, 84, 2, 6, 238, 168, 74, 208, 99, 16, - 56, 200, 98, 181, 205, 24, 79, 120, 235, 223, 144, 61, 197, 8, 215, 17, 1, 1, 99, 220, - 1, 28, 113, 173, 87, 207, 150, 171, 166, 221, 201, 207, 122, 14, 62, 119, 8, 5, 100, - 182, 50, 112, 191, 244, 5, 125, 9, 161, 17, 66, 201, 126, 148, 2, 170, 62, 172, 42, - 117, 12, 62, 165, 78, 141, 84, 194, 75, 135, 140, 198, 85, 219, 214, 218, 149, 56, 32, - 251, 16, 134, 21, 44, 31, 22, 80, 69, 16, 1, 191, 139, 156, 120, 188, 0, 187, 111, 169, - 41, 135, 146, 156, 103, 102, 125, 235, 0, 70, 180, 94, 103, 134, 250, 135, 56, 55, 144, - 156, 185, 212, 67, 2, 223, 93, 226, 244, 94, 203, 160, 13, 145, 161, 22, 104, 133, 135, - 132, 239, 27, 61, 12, 167, 134, 237, 120, 58, 229, 208, 50, 55, 70, 139, 211, 234, 16, - 2, 58, 253, 249, 36, 12, 24, 122, 198, 7, 161, 13, 237, 229, 3, 224, 98, 176, 51, 237, - 101, 105, 33, 10, 45, 111, 96, 89, 201, 212, 82, 1, 141, 5, 16, 0, 0, 0, 0, 0, 0, 1, - 32, 0, 0, 0, 0, 0, 0, 1, 36, 77, 228, 149, 252, 55, 96, 22, 145, 235, 199, 188, 83, 13, - 88, 13, 241, 48, 191, 78, 152, 20, 50, 252, 186, 199, 105, 134, 73, 62, 136, 228, 196, - 17, 17, 17, 0, 1, - ]; - - let start_block_height = 329u64; + fn should_return_containing_range_for_start_inside_it() { + let drive = setup_drive_with_initial_state_structure(None); let platform_version = PlatformVersion::latest(); - let result = Drive::verify_compacted_address_balance_changes( - &proof, - start_block_height, + let address = PlatformAddress::P2pkh([7; 20]); + let mut changes = BTreeMap::new(); + changes.insert( + address, + BlockAwareCreditOperation::from_operation(150, &CreditOperation::AddToCredits(12_345)), + ); + store_compacted_entry(&drive, 100, 200, changes.clone(), platform_version); + + let proof = drive + .prove_compacted_address_balance_changes(150, None, None, platform_version) + .expect("should prove compacted address balance changes"); + + let (_root_hash, compacted_changes) = Drive::verify_compacted_address_balance_changes( + proof.as_slice(), + 150, None, platform_version, + ) + .expect("should verify proof"); + + assert_eq!( + compacted_changes.len(), + 1, + "the containing range (100, 200) must be surfaced for start=150" ); + let (start, end, returned_changes) = &compacted_changes[0]; + assert_eq!(*start, 100); + assert_eq!(*end, 200); + assert_eq!(returned_changes, &changes); + } - assert!( - result.is_ok(), - "proof verification failed: {:?}", - result.err() + /// PoC: a malicious prover that skips descending discovery and proves + /// `range_from((150, 150)..)` directly MUST NOT make the verifier silently + /// return zero changes while a containing range `(100, 200)` holds a change. + #[test] + fn malicious_skip_descending_proof_is_rejected() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let address = PlatformAddress::P2pkh([9; 20]); + let mut changes = BTreeMap::new(); + changes.insert( + address, + BlockAwareCreditOperation::from_operation(150, &CreditOperation::AddToCredits(99_999)), + ); + store_compacted_entry(&drive, 100, 200, changes, platform_version); + + // Craft the MALICIOUS proof the OLD (vulnerable) way: prove + // range_from((150, 150)..) directly. (100, 200) sorts before (150, 150) + // and so appears only as a hash-only boundary node. + let path = compacted_address_balances_path(); + let malicious_start_key = compacted_key(150, 150); + let mut malicious_inner = Query::new(); + malicious_inner.insert_range_from(malicious_start_key..); + let malicious_path_query = + PathQuery::new(path, SizedQuery::new(malicious_inner, None, None)); + + let grovedb_costs::CostContext { + value: malicious_proof_result, + .. + } = drive.grove.get_proved_path_query( + &malicious_path_query, + None, + None, + &platform_version.drive.grove_version, ); + let malicious_proof = malicious_proof_result + .expect("should produce a (malicious) proof for the direct forward query"); - let (root_hash, compacted_changes) = result.unwrap(); + let result = Drive::verify_compacted_address_balance_changes( + malicious_proof.as_slice(), + 150, + None, + platform_version, + ); - // Verify we got a valid root hash - assert!(!root_hash.is_empty(), "root hash should not be empty"); + match result { + Err(_) => { + // Expected: the boundary query authenticates (100, 200) as the + // greatest key <= (150, MAX); the malicious proof cannot satisfy + // it, so verification fails. + } + Ok((_root_hash, returned_changes)) => { + assert!( + returned_changes + .iter() + .any(|(start, end, _)| *start == 100 && *end == 200), + "malicious proof must not silently hide the containing range \ + (100, 200); got {} changes", + returned_changes.len() + ); + } + } + } + + /// Querying past the last compacted range: the boundary key `(100, 200)` has + /// `end_block < start_block_height`, so there is no containing range and the + /// forward scan finds nothing. This exercises the `find_map` fallback to + /// `(start, start)` on both prover and verifier — a single key `<= bound`, so + /// it is unaffected by the known multi-range liveness bug. + #[test] + fn query_past_last_range_returns_empty() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let address = PlatformAddress::P2pkh([7; 20]); + let mut changes = BTreeMap::new(); + changes.insert( + address, + BlockAwareCreditOperation::from_operation(150, &CreditOperation::AddToCredits(12_345)), + ); + store_compacted_entry(&drive, 100, 200, changes, platform_version); + + // Query at 300, strictly past the only range (100, 200). + let proof = drive + .prove_compacted_address_balance_changes(300, None, None, platform_version) + .expect("should prove"); + let (_root, compacted_changes) = Drive::verify_compacted_address_balance_changes( + proof.as_slice(), + 300, + None, + platform_version, + ) + .expect("should verify"); - // The proof shows entry (288, 292) is the rightmost in the tree. - // Since 292 < 329 (our start_block_height), there are no results. - // The KVDigest at the boundary proves nothing exists >= (329, 329). assert!( compacted_changes.is_empty(), - "expected empty results since start_block_height 329 > last entry end_block 292" + "querying past the last range must return zero changes, got {:?}", + compacted_changes + .iter() + .map(|(s, e, _)| (*s, *e)) + .collect::>() ); + } - // Log what we found for debugging - eprintln!("Root hash: {:?}", root_hash); - eprintln!("Number of compacted entries: {}", compacted_changes.len()); - for (start, end, changes) in &compacted_changes { - eprintln!( - " Blocks {}-{}: {} address changes", - start, - end, - changes.len() + /// ADVERSARIAL (mirror of the nullifier verifier): >=2 compacted ranges + /// at/below the query height. KNOWN-FAILING per PR #3792 — the chained + /// descending-boundary + ascending-forward scheme cannot be satisfied by one + /// one-directional GroveDB proof; fails with "Cannot verify upper bound of + /// queried range". Un-ignore once the compacted tree is re-keyed by + /// `(end_block, start_block)`. + #[ignore = "known liveness bug: chained descending-boundary + ascending-forward \ + cannot share one GroveDB proof; fix = re-key by end_block (see PR #3792)"] + #[test] + fn multiple_ranges_below_query_height_verify() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let address = PlatformAddress::P2pkh([7; 20]); + for (s, e) in [(1u64, 64u64), (65, 128), (129, 192)] { + let mut changes = BTreeMap::new(); + changes.insert( + address, + BlockAwareCreditOperation::from_operation(e, &CreditOperation::AddToCredits(e)), ); + store_compacted_entry(&drive, s, e, changes, platform_version); } + + // Query from 150: (1,64),(65,128),(129,192) all sort <= (150, MAX). + let proof = drive + .prove_compacted_address_balance_changes(150, None, None, platform_version) + .expect("should prove with multiple ranges below the query height"); + let (_root, changes) = Drive::verify_compacted_address_balance_changes( + proof.as_slice(), + 150, + None, + platform_version, + ) + .expect("VERIFY MUST NOT FAIL with multiple ranges <= bound"); + + assert!( + changes.iter().any(|(s, e, _)| *s == 129 && *e == 192), + "containing range (129,192) must be surfaced; got {:?}", + changes.iter().map(|(s, e, _)| (*s, *e)).collect::>() + ); } }