diff --git a/grovedb-version/src/version/grovedb_versions.rs b/grovedb-version/src/version/grovedb_versions.rs index 6f082b377..fa25a6497 100644 --- a/grovedb-version/src/version/grovedb_versions.rs +++ b/grovedb-version/src/version/grovedb_versions.rs @@ -116,6 +116,7 @@ pub struct GroveDBOperationsQueryVersions { pub query_item_value: FeatureVersion, pub query_item_value_or_sum: FeatureVersion, pub query_aggregate_sums: FeatureVersion, + pub query_aggregate_count_on_range: FeatureVersion, pub query_sums: FeatureVersion, pub query_raw: FeatureVersion, pub query_keys_optional: FeatureVersion, diff --git a/grovedb-version/src/version/v1.rs b/grovedb-version/src/version/v1.rs index ff3d9fb93..3e9a919b8 100644 --- a/grovedb-version/src/version/v1.rs +++ b/grovedb-version/src/version/v1.rs @@ -134,6 +134,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { query_item_value: 0, query_item_value_or_sum: 0, query_aggregate_sums: 0, + query_aggregate_count_on_range: 0, query_sums: 0, query_raw: 0, query_keys_optional: 0, diff --git a/grovedb-version/src/version/v2.rs b/grovedb-version/src/version/v2.rs index 99b7aea27..47a20b900 100644 --- a/grovedb-version/src/version/v2.rs +++ b/grovedb-version/src/version/v2.rs @@ -134,6 +134,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { query_item_value: 0, query_item_value_or_sum: 0, query_aggregate_sums: 0, + query_aggregate_count_on_range: 0, query_sums: 0, query_raw: 0, query_keys_optional: 0, diff --git a/grovedb-version/src/version/v3.rs b/grovedb-version/src/version/v3.rs index 3f5500a20..137379fbd 100644 --- a/grovedb-version/src/version/v3.rs +++ b/grovedb-version/src/version/v3.rs @@ -134,6 +134,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { query_item_value: 0, query_item_value_or_sum: 0, query_aggregate_sums: 0, + query_aggregate_count_on_range: 0, query_sums: 0, query_raw: 0, query_keys_optional: 0, diff --git a/grovedb/src/operations/get/query.rs b/grovedb/src/operations/get/query.rs index d6b5d1e03..bbbe7d371 100644 --- a/grovedb/src/operations/get/query.rs +++ b/grovedb/src/operations/get/query.rs @@ -19,6 +19,7 @@ use crate::{ use crate::{ query_result_type::{QueryResultElement, QueryResultElements, QueryResultType}, reference_path::ReferencePathType, + util::TxRef, Element, Error, GroveDb, PathQuery, TransactionArg, }; use grovedb_costs::cost_return_on_error_default; @@ -26,6 +27,8 @@ use grovedb_costs::cost_return_on_error_default; use grovedb_costs::{ cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, }; +#[cfg(feature = "minimal")] +use grovedb_path::SubtreePath; use grovedb_version::{check_grovedb_v0, check_grovedb_v0_with_cost, version::GroveVersion}; #[cfg(feature = "minimal")] use integer_encoding::VarInt; @@ -653,6 +656,85 @@ where { ) } + /// Execute an `AggregateCountOnRange` path query without producing a + /// proof, returning the in-range count directly. + /// + /// This is the no-proof counterpart of + /// [`Self::prove_query`] + + /// [`Self::verify_aggregate_count_query`](GroveDb::verify_aggregate_count_query) + /// for `AggregateCountOnRange` queries: it performs the same merk-level + /// boundary walk the prover does (using each internal node's stored + /// aggregate count to short-circuit Contained / Disjoint subtrees) but + /// skips proof generation, serialization, and verification entirely. + /// + /// `path_query` must satisfy + /// [`PathQuery::validate_aggregate_count_on_range`] — a single + /// `AggregateCountOnRange(_)` item, no subqueries, no pagination, and an + /// inner range that isn't `Key`, `RangeFull`, or another + /// `AggregateCountOnRange`. Any other shape is rejected up front with + /// `Error::InvalidQuery` before any merk reads happen. + /// + /// The subtree at `path_query.path` must be a `ProvableCountTree` or + /// `ProvableCountSumTree` — the merk-level walk rejects any other tree + /// type. If the subtree is missing (path does not resolve), this returns + /// the same `PathNotFound` / `PathParentLayerNotFound` errors as other + /// path-based reads. + /// + /// The returned count is **not** independently verifiable — callers are + /// trusting their own merk read path. For a verifiable count, use + /// [`Self::prove_query`] + + /// [`Self::verify_aggregate_count_query`](GroveDb::verify_aggregate_count_query). + pub fn query_aggregate_count( + &self, + path_query: &PathQuery, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { + check_grovedb_v0_with_cost!( + "query_aggregate_count", + grove_version + .grovedb_versions + .operations + .query + .query_aggregate_count_on_range + ); + + let mut cost = OperationCost::default(); + + // Up-front shape validation: same gate the prover and verifier use. + // Catches malformed ACOR queries (illegal inner range, ACOR-hidden-in- + // subquery, pagination, etc.) before any storage reads. + let inner_range = cost_return_on_error_no_add!( + cost, + path_query.validate_aggregate_count_on_range().cloned() + ); + + let tx = TxRef::new(&self.db, transaction); + + // Open the leaf merk and ask it for the count. The merk-level entry + // point enforces `tree_type ∈ {ProvableCountTree, ProvableCountSumTree}` + // and handles the empty-merk case (returns 0). + let path_slices: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); + let subtree = cost_return_on_error!( + &mut cost, + self.open_transactional_merk_at_path( + SubtreePath::from(path_slices.as_slice()), + tx.as_ref(), + None, + grove_version, + ) + ); + + let count = cost_return_on_error!( + &mut cost, + subtree + .count_aggregate_on_range(&inner_range, grove_version) + .map_err(Error::MerkError) + ); + + Ok(count).wrap_with_cost(cost) + } + /// Retrieves SumItem values that match a regular [`PathQuery`], returning /// a `Vec` of the raw sum values and the number of skipped elements. /// diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index f991e03fa..b812d740e 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -1230,4 +1230,319 @@ mod tests { other => panic!("expected InvalidProof, got {:?}", other), } } + + // ------------------------------------------------------------------- + // Tests for the no-proof variant: GroveDb::query_aggregate_count. + // + // The no-proof variant must return the same count as the proof + // variant for every valid PathQuery shape, but should not need to + // produce or verify any proof bytes. These tests mirror the proof + // round-trip tests above and additionally cover the failure modes + // unique to the no-proof path (missing path, non-provable-count + // tree type). + // ------------------------------------------------------------------- + + /// No-proof helper: build the path-query, call query_aggregate_count, + /// assert the returned count matches the expected value AND matches + /// what the proof round-trip returns. + fn no_proof_matches_proof( + db: &crate::tests::TempGroveDb, + path: Vec>, + inner_range: QueryItem, + expected_count: u64, + grove_version: &GroveVersion, + ) { + let path_query = PathQuery::new_aggregate_count_on_range(path, inner_range); + + let direct = db + .grove_db + .query_aggregate_count(&path_query, None, grove_version) + .unwrap() + .expect("query_aggregate_count should succeed"); + assert_eq!( + direct, expected_count, + "no-proof variant returned wrong count" + ); + + let proof = db + .grove_db + .prove_query(&path_query, None, grove_version) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = + GroveDb::verify_aggregate_count_query(&proof, &path_query, grove_version) + .expect("verify should succeed"); + assert_eq!( + direct, proved, + "no-proof variant disagrees with proof variant" + ); + } + + #[test] + fn no_proof_provable_count_tree_range_inclusive() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_tree(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + QueryItem::RangeInclusive(b"c".to_vec()..=b"l".to_vec()), + 10, + v, + ); + } + + #[test] + fn no_proof_provable_count_tree_range_exclusive() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_tree(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + QueryItem::Range(b"c".to_vec()..b"l".to_vec()), + 9, + v, + ); + } + + #[test] + fn no_proof_provable_count_tree_range_from() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_tree(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + QueryItem::RangeFrom(b"c".to_vec()..), + 13, + v, + ); + } + + #[test] + fn no_proof_provable_count_tree_range_after() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_tree(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + QueryItem::RangeAfter(b"b".to_vec()..), + 13, + v, + ); + } + + #[test] + fn no_proof_provable_count_tree_range_to_inclusive() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_tree(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + QueryItem::RangeToInclusive(..=b"e".to_vec()), + 5, + v, + ); + } + + #[test] + fn no_proof_range_disjoint_from_all_keys() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_tree(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + QueryItem::RangeInclusive(vec![0x00]..=vec![0x10]), + 0, + v, + ); + } + + #[test] + fn no_proof_provable_count_sum_tree_range_inclusive() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_sum_tree(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"cst".to_vec()], + QueryItem::RangeInclusive(b"c".to_vec()..=b"l".to_vec()), + 10, + v, + ); + } + + #[test] + fn no_proof_three_layer_path() { + let v = GroveVersion::latest(); + let (db, _) = setup_three_layer_provable_count_tree(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"outer".to_vec(), b"inner".to_vec()], + QueryItem::RangeInclusive(b"b".to_vec()..=b"d".to_vec()), + 3, + v, + ); + } + + #[test] + fn no_proof_rejects_invalid_inner_range() { + // Same shape check the prover/verifier use: Key inner is invalid for + // an aggregate-count-on-range query. + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_tree(v); + let path_query = PathQuery::new_aggregate_count_on_range( + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + QueryItem::Key(b"c".to_vec()), + ); + let err = db + .grove_db + .query_aggregate_count(&path_query, None, v) + .unwrap() + .expect_err("Key inner must be rejected before any storage reads"); + assert!( + matches!(err, crate::Error::InvalidQuery(_)), + "expected InvalidQuery, got {:?}", + err + ); + } + + #[test] + fn no_proof_rejects_against_normal_tree() { + // The merk-level entry point gates on + // `tree_type ∈ {ProvableCountTree, ProvableCountSumTree}`. A + // NormalTree must surface that as a MerkError. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"x", + Element::new_item(b"y".to_vec()), + None, + None, + v, + ) + .unwrap() + .expect("seed normal tree"); + let path_query = PathQuery::new_aggregate_count_on_range( + vec![TEST_LEAF.to_vec()], + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ); + let err = db + .grove_db + .query_aggregate_count(&path_query, None, v) + .unwrap() + .expect_err("NormalTree must be rejected by the merk-level entry"); + // The merk-level error gets wrapped in Error::MerkError; we just + // require *some* error rather than asserting on the exact variant + // since the merk layer's InvalidProofError formatting is internal. + match err { + crate::Error::MerkError(_) => {} + other => panic!("expected MerkError, got {:?}", other), + } + } + + #[test] + fn no_proof_uses_provided_transaction() { + // Exercise the TransactionArg = Some(&tx) path of query_aggregate_count + // and verify the transactional read actually observes uncommitted + // state. The base view must NOT see the in-transaction insert. + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_tree(v); + let path_query = PathQuery::new_aggregate_count_on_range( + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + QueryItem::RangeInclusive(b"c".to_vec()..=b"l".to_vec()), + ); + + // Sanity check: base view sees 10 keys in [c, l]. + let base_count = db + .grove_db + .query_aggregate_count(&path_query, None, v) + .unwrap() + .expect("base query should succeed"); + assert_eq!(base_count, 10, "base view should see 10 keys"); + + // Insert a new in-range key ("k2") inside a transaction. + let tx = db.start_transaction(); + db.insert( + [TEST_LEAF, b"ct"].as_ref(), + b"k2", + Element::new_item(b"k2".to_vec()), + None, + Some(&tx), + v, + ) + .unwrap() + .expect("transactional insert should succeed"); + + // Transactional read must include the uncommitted insert (11). + let tx_count = db + .grove_db + .query_aggregate_count(&path_query, Some(&tx), v) + .unwrap() + .expect("transactional query should succeed"); + assert_eq!( + tx_count, 11, + "transactional view must include uncommitted insert" + ); + + // Base view must still see 10 — the uncommitted insert is invisible + // to non-transactional reads. + let base_count_after = db + .grove_db + .query_aggregate_count(&path_query, None, v) + .unwrap() + .expect("base query should succeed after tx insert"); + assert_eq!( + base_count_after, 10, + "base view must not see uncommitted insert" + ); + } + + #[test] + fn no_proof_path_not_found_returns_error() { + // Querying a path whose parent layer doesn't exist must surface + // the same path-not-found error other reads produce — exercises + // the open_transactional_merk_at_path error arm. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + let path_query = PathQuery::new_aggregate_count_on_range( + vec![TEST_LEAF.to_vec(), b"does-not-exist".to_vec()], + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ); + let result = db + .grove_db + .query_aggregate_count(&path_query, None, v) + .unwrap(); + assert!( + result.is_err(), + "querying a non-existent path must fail, got Ok({:?})", + result.ok() + ); + } + + #[test] + fn no_proof_empty_provable_count_tree_returns_zero() { + // An empty provable-count tree should walk in O(1) and return 0 + // — no proof generation, no merk traversal beyond the root open. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"empty", + Element::empty_provable_count_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert empty provable count tree"); + let path_query = PathQuery::new_aggregate_count_on_range( + vec![TEST_LEAF.to_vec(), b"empty".to_vec()], + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ); + let count = db + .grove_db + .query_aggregate_count(&path_query, None, v) + .unwrap() + .expect("query_aggregate_count on empty tree should succeed"); + assert_eq!(count, 0, "empty tree must return 0"); + } } diff --git a/merk/src/merk/get.rs b/merk/src/merk/get.rs index f38b6fc7b..0950d184b 100644 --- a/merk/src/merk/get.rs +++ b/merk/src/merk/get.rs @@ -3,7 +3,8 @@ use grovedb_storage::StorageContext; use grovedb_version::version::GroveVersion; use crate::{ - tree::{kv::ValueDefinedCostType, TreeNode}, + proofs::query::QueryItem, + tree::{kv::ValueDefinedCostType, RefWalker, TreeNode}, CryptoHash, Error, Error::StorageError, Merk, TreeFeatureType, @@ -352,6 +353,51 @@ where } }) } + + /// Execute an `AggregateCountOnRange` query without producing a proof, + /// returning just the in-range count. + /// + /// This is the no-proof counterpart of + /// [`Self::prove_aggregate_count_on_range`]. It walks the same + /// classification path the proof emitter does — using each internal + /// node's stored aggregate count to short-circuit Contained / Disjoint + /// subtrees — but skips the proof-op emission and serialization. The + /// merk-level cost is O(log n) in the number of distinct keys, the same + /// as the proof variant. + /// + /// The merk's `tree_type` must be one of `ProvableCountTree` or + /// `ProvableCountSumTree`; any other tree type is rejected with + /// `Error::InvalidProofError` before any walking happens. On an empty + /// merk this returns `count = 0`. + /// + /// The returned count is **not** independently verifiable — callers + /// trust the merk's reads. Use `prove_aggregate_count_on_range` + + /// `verify_aggregate_count_on_range_proof` for a verifiable count. + pub fn count_aggregate_on_range( + &self, + inner_range: &QueryItem, + grove_version: &GroveVersion, + ) -> CostResult { + let tree_type = self.tree_type; + if !matches!( + tree_type, + crate::TreeType::ProvableCountTree | crate::TreeType::ProvableCountSumTree + ) { + return Err(Error::InvalidProofError(format!( + "AggregateCountOnRange is only valid against ProvableCountTree or \ + ProvableCountSumTree, got {:?}", + tree_type + ))) + .wrap_with_cost(Default::default()); + } + self.use_tree_mut(|maybe_tree| match maybe_tree { + None => Ok(0u64).wrap_with_cost(Default::default()), + Some(tree) => { + let mut ref_walker = RefWalker::new(tree, self.source()); + ref_walker.count_aggregate_on_range(inner_range, grove_version) + } + }) + } } #[cfg(test)] diff --git a/merk/src/proofs/query/aggregate_count.rs b/merk/src/proofs/query/aggregate_count.rs index 915de7223..7e5944ca5 100644 --- a/merk/src/proofs/query/aggregate_count.rs +++ b/merk/src/proofs/query/aggregate_count.rs @@ -15,7 +15,9 @@ #[cfg(feature = "minimal")] use std::collections::LinkedList; -use grovedb_costs::{cost_return_on_error, CostResult, CostsExt, OperationCost}; +use grovedb_costs::{ + cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, +}; #[cfg(feature = "minimal")] use grovedb_version::version::GroveVersion; @@ -192,6 +194,34 @@ where ); Ok((ops, count)).wrap_with_cost(cost) } + + /// Walk the tree for an `AggregateCountOnRange` query and return the + /// in-range count, **without** producing a proof. + /// + /// This is the no-proof counterpart of + /// [`Self::create_aggregate_count_on_range_proof`]. It performs the same + /// classification walk (Contained / Disjoint / Boundary) and reads each + /// node's aggregate count directly from the merk, so it is O(log n) in + /// the number of distinct keys under the indexed subtree — the same + /// complexity as the proof variant but without the proof-op allocations, + /// hash recomputations, or serialization round-trip. + /// + /// The caller (`Merk::count_aggregate_on_range`) is expected to have + /// already validated `tree_type` is `ProvableCountTree` or + /// `ProvableCountSumTree`; the per-node `provable_count_from_aggregate` + /// check inside the walk surfaces any disagreement between the declared + /// tree type and the in-memory aggregate. + /// + /// The result is **not** independently verifiable: the caller is trusting + /// their own merk read path. Callers that need a verifiable count must + /// use `prove_aggregate_count_on_range` + `verify_aggregate_count_on_range_proof`. + pub fn count_aggregate_on_range( + &mut self, + inner_range: &QueryItem, + grove_version: &GroveVersion, + ) -> CostResult { + walk_count_only(self, inner_range, None, None, grove_version) + } } /// Recursive proof emitter. Always called on a non-empty subtree. @@ -422,6 +452,167 @@ where Ok(total).wrap_with_cost(cost) } +/// Read the provable-count aggregate off the walker's current tree node. +/// Shared error-mapping helper used by [`walk_count_only`] at both the +/// Contained-leaf and Boundary positions. +#[cfg(feature = "minimal")] +fn provable_count_from_walker(walker: &RefWalker<'_, S>) -> Result +where + S: Fetch + Sized + Clone, +{ + let aggregate = walker + .tree() + .aggregate_data() + .map_err(|e| Error::InvalidProofError(format!("aggregate_data: {}", e)))?; + provable_count_from_aggregate(aggregate) +} + +/// No-proof variant of [`emit_count_proof`]: walks the same classification +/// path (Contained / Disjoint / Boundary) but only returns the running +/// in-range count. +/// +/// At entry, `subtree_lo_excl` / `subtree_hi_excl` are the inherited +/// exclusive key bounds for the subtree this walker points at (both `None` +/// at the root call). The walk reads each node's `aggregate_data()` and +/// each child link's `aggregate_data().as_count_u64()` exactly the same way +/// the proof emitter does, so the returned count is identical to the +/// `count` field returned by `create_aggregate_count_on_range_proof`. +#[cfg(feature = "minimal")] +fn walk_count_only( + walker: &mut RefWalker<'_, S>, + range: &QueryItem, + subtree_lo_excl: Option<&[u8]>, + subtree_hi_excl: Option<&[u8]>, + grove_version: &GroveVersion, +) -> CostResult +where + S: Fetch + Sized + Clone, +{ + let mut cost = OperationCost::default(); + + // Classify the current subtree against the inner range. + match classify_subtree(subtree_lo_excl, subtree_hi_excl, range) { + // Disjoint: subtree contributes 0 to the in-range count. + SubtreeClassification::Disjoint => Ok(0).wrap_with_cost(cost), + // Contained: subtree contributes its full stored aggregate + // (NonCounted entries are already excluded — their stored + // aggregate is 0). + SubtreeClassification::Contained => { + let count = cost_return_on_error_no_add!(cost, provable_count_from_walker(walker)); + Ok(count).wrap_with_cost(cost) + } + // Boundary: descend into both children and add own_count. + SubtreeClassification::Boundary => { + // Snapshot what we need from the current node before walking. + // walk(...) takes &mut self.tree, so we must drop any existing + // borrows on walker.tree() before calling it. + let node_key: Vec = walker.tree().key().to_vec(); + let node_count = cost_return_on_error_no_add!(cost, provable_count_from_walker(walker)); + let left_link_aggregate: u64 = walker + .tree() + .link(true) + .map(|l| l.aggregate_data().as_count_u64()) + .unwrap_or(0); + let right_link_aggregate: u64 = walker + .tree() + .link(false) + .map(|l| l.aggregate_data().as_count_u64()) + .unwrap_or(0); + let left_link_present = walker.tree().link(true).is_some(); + let right_link_present = walker.tree().link(false).is_some(); + + let mut total: u64 = 0; + + // LEFT child. If link is Some, walk(true) must yield Some; the + // proof variant has the verifier to catch silent inconsistencies, + // but this no-proof path returns the count straight to the + // caller — so we fail loudly on impossible state rather than + // silently undercounting. + if left_link_present { + let walked = cost_return_on_error!( + &mut cost, + walker.walk( + true, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + ); + let mut left_walker = match walked { + Some(lw) => lw, + None => { + return Err(Error::CorruptedState( + "tree.link(true) was Some but walk(true) returned None", + )) + .wrap_with_cost(cost); + } + }; + let n = cost_return_on_error!( + &mut cost, + walk_count_only( + &mut left_walker, + range, + subtree_lo_excl, + Some(node_key.as_slice()), + grove_version, + ) + ); + total = total.saturating_add(n); + } + + // Current node's own_count: 1 if in-range and counted, 0 for + // NonCounted-wrapped (which has stored aggregate 0, so the + // subtraction yields 0). `checked_sub` (not `saturating_sub`) + // because children claiming more keys than the parent's + // aggregate is corrupted state, not something to silently + // clamp to 0. + if range.contains(&node_key) { + let own_count = node_count + .checked_sub(left_link_aggregate) + .and_then(|n| n.checked_sub(right_link_aggregate)) + .ok_or(Error::CorruptedState( + "child structural counts exceed parent's aggregate count", + )); + let own_count = cost_return_on_error_no_add!(cost, own_count); + total = total.saturating_add(own_count); + } + + // RIGHT child — same fail-fast pattern as LEFT. + if right_link_present { + let walked = cost_return_on_error!( + &mut cost, + walker.walk( + false, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + ); + let mut right_walker = match walked { + Some(rw) => rw, + None => { + return Err(Error::CorruptedState( + "tree.link(false) was Some but walk(false) returned None", + )) + .wrap_with_cost(cost); + } + }; + let n = cost_return_on_error!( + &mut cost, + walk_count_only( + &mut right_walker, + range, + Some(node_key.as_slice()), + subtree_hi_excl, + grove_version, + ) + ); + total = total.saturating_add(n); + } + + Ok(total).wrap_with_cost(cost) + } + } +} + /// Verify a count-only proof for an `AggregateCountOnRange` query. /// /// `proof_bytes` is the encoded `Vec` produced by @@ -1110,6 +1301,176 @@ mod tests { ); } + // ---------- no-proof variant: count_aggregate_on_range ---------- + // + // The no-proof entry point must return exactly the same count as the + // proof path for every range shape, without producing any proof ops. + // These tests cross-check the two paths on the same merk. + + /// Cross-check: assert that `count_aggregate_on_range` and the count + /// returned by `prove_aggregate_count_on_range` agree for the given + /// range, and that both equal `expected_count`. + fn no_proof_matches_prover( + merk: &Merk>, + inner_range: QueryItem, + expected_count: u64, + grove_version: &GroveVersion, + ) { + let no_proof = merk + .count_aggregate_on_range(&inner_range, grove_version) + .unwrap() + .expect("count_aggregate_on_range should succeed"); + assert_eq!( + no_proof, expected_count, + "no-proof variant returned wrong count for range {:?}", + inner_range + ); + let (_ops, prover_count) = merk + .prove_aggregate_count_on_range(&inner_range, grove_version) + .unwrap() + .expect("prove should succeed"); + assert_eq!( + no_proof, prover_count, + "no-proof variant disagrees with prover count for range {:?}", + inner_range + ); + } + + #[test] + fn no_proof_matches_prover_closed_range_inclusive() { + let v = GroveVersion::latest(); + let (merk, _root) = make_15_key_provable_count_tree(v); + no_proof_matches_prover( + &merk, + QueryItem::RangeInclusive(b"c".to_vec()..=b"l".to_vec()), + 10, + v, + ); + } + + #[test] + fn no_proof_matches_prover_closed_range_exclusive() { + let v = GroveVersion::latest(); + let (merk, _root) = make_15_key_provable_count_tree(v); + no_proof_matches_prover(&merk, QueryItem::Range(b"c".to_vec()..b"l".to_vec()), 9, v); + } + + #[test] + fn no_proof_matches_prover_open_range_from() { + let v = GroveVersion::latest(); + let (merk, _root) = make_15_key_provable_count_tree(v); + no_proof_matches_prover(&merk, QueryItem::RangeFrom(b"c".to_vec()..), 13, v); + } + + #[test] + fn no_proof_matches_prover_range_below_all_keys() { + let v = GroveVersion::latest(); + let (merk, _root) = make_15_key_provable_count_tree(v); + no_proof_matches_prover( + &merk, + QueryItem::RangeInclusive(vec![0x00]..=vec![0x10]), + 0, + v, + ); + } + + #[test] + fn no_proof_empty_merk_returns_zero() { + let v = GroveVersion::latest(); + let merk = TempMerk::new_with_tree_type(v, TreeType::ProvableCountTree); + let count = merk + .count_aggregate_on_range(&QueryItem::Range(b"a".to_vec()..b"z".to_vec()), v) + .unwrap() + .expect("count_aggregate_on_range on empty merk should succeed"); + assert_eq!(count, 0); + } + + #[test] + fn no_proof_rejected_on_normal_tree() { + let v = GroveVersion::latest(); + let merk = TempMerk::new(v); // NormalTree + let result = merk + .count_aggregate_on_range(&QueryItem::Range(b"a".to_vec()..b"z".to_vec()), v) + .unwrap(); + assert!( + result.is_err(), + "expected InvalidProofError on NormalTree, got Ok({:?})", + result.ok() + ); + } + + #[test] + fn no_proof_matches_prover_range_after() { + // RangeAfter at the root pushes the left boundary exclusive to "b", + // which causes the walk to descend into the right subtree from the + // root — exercising the right-child arm of walk_count_only. + let v = GroveVersion::latest(); + let (merk, _root) = make_15_key_provable_count_tree(v); + no_proof_matches_prover(&merk, QueryItem::RangeAfter(b"b".to_vec()..), 13, v); + } + + #[test] + fn no_proof_matches_prover_range_to() { + let v = GroveVersion::latest(); + let (merk, _root) = make_15_key_provable_count_tree(v); + // RangeTo(..b"e") — exclusive upper, keys a..d (4 keys). + no_proof_matches_prover(&merk, QueryItem::RangeTo(..b"e".to_vec()), 4, v); + } + + #[test] + fn no_proof_matches_prover_range_to_inclusive() { + let v = GroveVersion::latest(); + let (merk, _root) = make_15_key_provable_count_tree(v); + // RangeToInclusive(..=b"e") — keys a..=e (5 keys). + no_proof_matches_prover(&merk, QueryItem::RangeToInclusive(..=b"e".to_vec()), 5, v); + } + + #[test] + fn no_proof_matches_prover_range_after_to_inclusive() { + let v = GroveVersion::latest(); + let (merk, _root) = make_15_key_provable_count_tree(v); + // RangeAfterToInclusive(("c", "l")) — keys d..=l (9 keys). + no_proof_matches_prover( + &merk, + QueryItem::RangeAfterToInclusive(b"c".to_vec()..=b"l".to_vec()), + 9, + v, + ); + } + + #[test] + fn no_proof_provable_count_sum_tree() { + // Exercise the ProvableCountSumTree branch of the tree-type gate — + // it should accept the walk and return the same count as a + // ProvableCountTree with the same key set. + let v = GroveVersion::latest(); + let mut merk = TempMerk::new_with_tree_type(v, TreeType::ProvableCountSumTree); + // ProvableCountedAndSummedMerkNode(count=1, sum=0): treats each + // entry as count-1 with sum-contribution 0. + let entries: Vec<(Vec, Op)> = (b'a'..=b'o') + .enumerate() + .map(|(i, c)| { + ( + vec![c], + Op::Put( + vec![i as u8], + crate::tree::TreeFeatureType::ProvableCountedSummedMerkNode(1, 0), + ), + ) + }) + .collect(); + merk.apply::<_, Vec<_>>(&entries, &[], None, v) + .unwrap() + .expect("apply ProvableCountSumTree entries"); + merk.commit(v); + + let count = merk + .count_aggregate_on_range(&QueryItem::RangeInclusive(b"c".to_vec()..=b"l".to_vec()), v) + .unwrap() + .expect("count_aggregate_on_range on ProvableCountSumTree should succeed"); + assert_eq!(count, 10, "c..=l should be 10 keys"); + } + // ---------- attack tests for the shape-walk verifier ---------- // // These three tests exercise attacks the old allowlist-only verifier let