From f5e39e30222a5d5bd7a1709dac53449a22b0c5be Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 04:18:42 +0700 Subject: [PATCH 01/15] feat(grovedb,query): allow AggregateCountOnRange as carrier subquery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow `AggregateCountOnRange` as a subquery item under an outer `Keys`/ `Range*` carrier query — the natural multi-outer-key extension of the existing single-leg ACOR. The carrier returns one `(outer_key, u64)` pair per matched outer key while leaving the leaf wire format and existing entry points byte-identical. Why: Dash Platform's count-query module needs to answer `SELECT brand, COUNT(*) ... WHERE brand IN (...) GROUP BY brand` against `byBrand / / color / ` layouts. Today grovedb rejects the nested shape at `Query::validate_*`, forcing callers to either fan out one proof per outer key (k× larger proofs) or fall back to the unproven path. With this change the same GroveDB proof contains all k counts and is verified in one pass. Changes: - `grovedb-query`: split validation into a leaf validator (renamed from the old `validate_aggregate_count_on_range`) and a new carrier validator that requires `Key`/`Range*` outer items, a leaf-ACOR subquery, and no conditional branches. The top-level `validate_aggregate_count_on_range` now dispatches by query shape and returns the leaf inner range either way. - `grovedb`: add `validate_leaf_aggregate_count_on_range` at SizedQuery/PathQuery for entry points that must reject the carrier. No prover changes needed — the existing recursion naturally walks outer items -> subquery_path -> leaf ACOR via `query_items_at_path`. - `grovedb`: add `verify_aggregate_count_query_per_key` returning `(RootHash, Vec<(Vec, u64)>)`. Leaf queries surface as a single entry with empty key + the same count `verify_aggregate_count_query` returns. The legacy `verify_aggregate_count_query` now strictly validates the leaf shape; carrier queries must use the new entry. - Tests: 10 new carrier-shape validation tests in grovedb-query and 7 end-to-end carrier proof tests in grovedb (two-outer-key happy path, absent-outer-key returns present-only, both-levels rejection, Range-outer carrier, leaf symmetry, non-ACOR rejection, and count forgery rejection through the carrier envelope). Asymptotic complexity for |outer matches| = k, outer tree B, leaf tree C: O(k * (log B + log C)). Out of scope (deferred): conditional-branch carriers, multi-In on prefix (IN x IN), drive-side integration. Co-Authored-By: Claude Opus 4.7 (1M context) --- grovedb-query/src/query.rs | 309 +++++++- grovedb/src/lib.rs | 2 + .../src/operations/proof/aggregate_count.rs | 688 ++++++++++++++++-- grovedb/src/query/mod.rs | 51 +- .../src/tests/aggregate_count_query_tests.rs | 402 +++++++++- 5 files changed, 1368 insertions(+), 84 deletions(-) diff --git a/grovedb-query/src/query.rs b/grovedb-query/src/query.rs index 8f1c3fc00..cb7c87f59 100644 --- a/grovedb-query/src/query.rs +++ b/grovedb-query/src/query.rs @@ -374,7 +374,42 @@ impl Query { /// Validates the Query-level constraints that apply when an /// `AggregateCountOnRange` is present. On success, returns a reference - /// to the inner `QueryItem` describing the range to count. + /// to the inner range `QueryItem` describing the keys being counted + /// (the same item regardless of whether the surrounding query is the + /// leaf shape or the carrier shape). + /// + /// Top-level dispatcher: classifies the query as either + /// - **leaf** (the query owns an `AggregateCountOnRange` item directly — + /// the original single-`u64` shape), or + /// - **carrier** (the query is an outer fan-out of `Key`/`Range` items + /// whose `default_subquery_branch.subquery` resolves to a leaf + /// `AggregateCountOnRange` — the new per-outer-key shape) + /// + /// and forwards to the corresponding rule set. See + /// [`Self::validate_leaf_aggregate_count_on_range`] and + /// [`Self::validate_carrier_aggregate_count_on_range`] for the precise + /// rules in each case. + /// + /// `SizedQuery::limit` / `SizedQuery::offset` checks live at the + /// `PathQuery` / `SizedQuery` layer. + pub fn validate_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { + if self.aggregate_count_on_range().is_some() { + // Owns an ACOR at this level → leaf shape. + self.validate_leaf_aggregate_count_on_range() + } else if self.has_aggregate_count_on_range_anywhere() { + // Doesn't own an ACOR but a nested subquery does → carrier shape. + self.validate_carrier_aggregate_count_on_range() + } else { + Err(Error::InvalidOperation( + "validate_aggregate_count_on_range called on a query without an \ + AggregateCountOnRange item", + )) + } + } + + /// Validates the leaf shape: a query whose single item is + /// `AggregateCountOnRange(_)` and whose surroundings carry no subquery + /// branches. Returns a reference to the inner range `QueryItem`. /// /// Rules enforced (matching the constraints documented in the GroveDB /// book chapter "Aggregate Count Queries"): @@ -390,11 +425,7 @@ impl Query { /// 6. `default_subquery_branch.subquery` and /// `default_subquery_branch.subquery_path` must both be `None`. /// 7. `conditional_subquery_branches` must be `None` or empty. - /// - /// `SizedQuery::limit` / `SizedQuery::offset` checks live at the - /// `PathQuery` / `SizedQuery` layer (see - /// [`SizedQuery::validate_aggregate_count_on_range`]). - pub fn validate_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { + pub fn validate_leaf_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { if self.items.len() != 1 { return Err(Error::InvalidOperation( "AggregateCountOnRange must be the only item in the query", @@ -446,6 +477,87 @@ impl Query { Ok(inner) } + /// Validates the carrier shape: an outer query whose items are + /// `Key`/`Range`-like (NOT `AggregateCountOnRange`), and whose + /// `default_subquery_branch.subquery` resolves to a valid leaf ACOR + /// query (possibly after walking a `subquery_path`). + /// + /// Returns a reference to the leaf's inner range `QueryItem` — the + /// same kind of value [`Self::validate_leaf_aggregate_count_on_range`] + /// returns for a leaf-shape query. + /// + /// Rules enforced: + /// 1. Items must be non-empty. + /// 2. Each item must be `Key(_)` or a `Range*(_)` variant — explicitly + /// NOT `AggregateCountOnRange` (those route through the leaf + /// validator) and NOT `RangeFull` (use a leaf ACOR on the parent + /// instead). + /// 3. `default_subquery_branch.subquery` must be `Some(_)`. Its target + /// query must itself validate as a leaf ACOR query. + /// 4. `default_subquery_branch.subquery_path` may be `Some(_)` + /// (typically names the path from each outer-key match to the leaf + /// subtree). When set, every element must be a non-empty key. + /// 5. `conditional_subquery_branches` must be `None` or empty + /// (out of scope for the initial implementation). + pub fn validate_carrier_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { + if self.items.is_empty() { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query must have at least one outer item", + )); + } + for item in &self.items { + match item { + QueryItem::Key(_) + | QueryItem::Range(_) + | QueryItem::RangeInclusive(_) + | QueryItem::RangeFrom(_) + | QueryItem::RangeTo(_) + | QueryItem::RangeToInclusive(_) + | QueryItem::RangeAfter(_) + | QueryItem::RangeAfterTo(_) + | QueryItem::RangeAfterToInclusive(_) => {} + QueryItem::RangeFull(_) => { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query may not have a RangeFull outer item", + )); + } + QueryItem::AggregateCountOnRange(_) => { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query may not own an \ + AggregateCountOnRange item — use the leaf shape instead", + )); + } + } + } + let subquery = match self.default_subquery_branch.subquery.as_deref() { + Some(sub) => sub, + None => { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query must set \ + default_subquery_branch.subquery to a leaf ACOR query", + )); + } + }; + if let Some(path) = &self.default_subquery_branch.subquery_path + && path.iter().any(|k| k.is_empty()) + { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query's subquery_path must contain non-empty keys", + )); + } + if let Some(branches) = &self.conditional_subquery_branches + && !branches.is_empty() + { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query may not carry conditional subquery \ + branches (out of scope for this feature)", + )); + } + // The subquery must validate as a leaf ACOR (which is what the + // proof descent will ultimately consume). + subquery.validate_leaf_aggregate_count_on_range() + } + /// Returns `true` if the given key would trigger a subquery (either via /// the default subquery branch or a matching conditional branch). pub fn has_subquery_on_key(&self, key: &[u8], in_path: bool) -> bool { @@ -1279,4 +1391,189 @@ mod tests { "ACOR hidden in conditional subquery branch must be detected" ); } + + // ---------- Carrier ACOR validation tests ---------- + // + // The carrier shape is an outer query with `Key`/`Range*` items whose + // `default_subquery_branch.subquery` resolves to a leaf ACOR query. + // It is the multi-outer-key extension of the leaf shape, returning one + // count per outer key. These tests verify the new + // `validate_carrier_aggregate_count_on_range` rules and the dispatcher + // behavior of the top-level `validate_aggregate_count_on_range`. + + fn make_leaf_acor_subquery() -> Query { + make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())) + } + + #[test] + fn validate_carrier_acor_happy_path_keys_outer_with_subquery_path() { + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"brand_000".to_vec())); + carrier.items.push(QueryItem::Key(b"brand_001".to_vec())); + carrier.set_subquery_path(vec![b"color".to_vec()]); + carrier.set_subquery(make_leaf_acor_subquery()); + // Top-level dispatcher accepts the carrier and returns the leaf's + // inner range. + let inner = carrier + .validate_aggregate_count_on_range() + .expect("carrier should validate"); + assert!(matches!(inner, QueryItem::Range(_))); + // And the dedicated carrier validator agrees. + carrier + .validate_carrier_aggregate_count_on_range() + .expect("carrier validator should accept"); + // Leaf validator must reject (carrier-level items aren't ACOR). + assert!(carrier.validate_leaf_aggregate_count_on_range().is_err()); + } + + #[test] + fn validate_carrier_acor_happy_path_no_subquery_path() { + // subquery_path is optional — the leaf ACOR may be directly under + // each outer match. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"a".to_vec())); + carrier.set_subquery(make_leaf_acor_subquery()); + carrier + .validate_aggregate_count_on_range() + .expect("carrier without subquery_path should validate"); + } + + #[test] + fn validate_carrier_acor_rejects_acor_at_both_levels() { + // Carrier itself owns an ACOR AND its subquery is also an ACOR. + // The "rule" of "no ACOR at carrier level" must fire — but the + // top-level dispatcher routes this to the LEAF validator first + // (because aggregate_count_on_range() returns Some at carrier + // level), so the leaf's "single item" rule catches the extra + // ACOR-in-subquery shape via the items-len check or the + // no-subquery rule. Either way the error fires. + let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + q.set_subquery(make_leaf_acor_subquery()); + let err = q + .validate_aggregate_count_on_range() + .expect_err("ACOR at both levels must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!( + msg.contains("AggregateCountOnRange") || msg.contains("subquery"), + "unexpected message: {msg}" + ); + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_rejects_range_full_outer() { + let mut carrier = Query::new(); + carrier + .items + .push(QueryItem::RangeFull(std::ops::RangeFull)); + carrier.set_subquery(make_leaf_acor_subquery()); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("RangeFull outer must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!(msg.contains("RangeFull"), "unexpected message: {msg}"); + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_rejects_acor_outer_item() { + // Both a Key and an AggregateCountOnRange item at the carrier + // level. The leaf validator's items-len check fires first (since + // there's an ACOR item in items, aggregate_count_on_range() + // returns Some, and len != 1). + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + carrier + .items + .push(QueryItem::AggregateCountOnRange(Box::new( + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ))); + carrier.set_subquery(make_leaf_acor_subquery()); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("ACOR + Key outer items must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_carrier_acor_rejects_carrier_with_missing_subquery() { + // Outer items present but no subquery → not a carrier (and not a + // leaf), so the top-level dispatcher routes to the + // "not an ACOR query" error. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("carrier without subquery must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_carrier_acor_rejects_non_acor_subquery() { + // Outer Keys + subquery that is NOT an ACOR (just a regular range + // query) → not a valid carrier ACOR. The top-level dispatcher + // sees `has_aggregate_count_on_range_anywhere() == false`, so it + // surfaces the "not an ACOR query" error rather than the carrier + // validator's "subquery must validate as leaf ACOR" error. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + let regular_sub = + Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + carrier.set_subquery(regular_sub); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("non-ACOR subquery must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_carrier_acor_rejects_conditional_branches() { + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + carrier.set_subquery(make_leaf_acor_subquery()); + carrier.add_conditional_subquery( + QueryItem::Key(b"k".to_vec()), + None, + Some(make_leaf_acor_subquery()), + ); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("carrier conditional branches must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!(msg.contains("conditional"), "unexpected message: {msg}") + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_rejects_empty_outer_items() { + // Empty items + leaf ACOR subquery → not a valid carrier. + // (Empty outer means no outer key to iterate; doesn't make sense.) + let mut carrier = Query::new(); + carrier.set_subquery(make_leaf_acor_subquery()); + let err = carrier + .validate_carrier_aggregate_count_on_range() + .expect_err("empty outer items must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_carrier_acor_accepts_range_outer_items() { + // A carrier may use Range outer items (the spec leaves room for + // this). Verify the validator agrees. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::RangeAfter(b"a".to_vec()..)); + carrier.set_subquery(make_leaf_acor_subquery()); + carrier + .validate_aggregate_count_on_range() + .expect("carrier with Range outer should validate"); + } } diff --git a/grovedb/src/lib.rs b/grovedb/src/lib.rs index 127503a5e..1d1b91fab 100644 --- a/grovedb/src/lib.rs +++ b/grovedb/src/lib.rs @@ -242,6 +242,8 @@ use grovedb_version::version::GroveVersion; #[cfg(feature = "minimal")] use grovedb_visualize::DebugByteVectors; #[cfg(any(feature = "minimal", feature = "verify"))] +pub(crate) use query::query_validation_error_to_invalid_query; +#[cfg(any(feature = "minimal", feature = "verify"))] pub use query::{ aggregate_sum_path_query::AggregateSumPathQuery, GroveBranchQueryResult, GroveTrunkQueryResult, LeafInfo, PathBranchChunkQuery, PathQuery, PathTrunkChunkQuery, SizedQuery, diff --git a/grovedb/src/operations/proof/aggregate_count.rs b/grovedb/src/operations/proof/aggregate_count.rs index 6920c78c5..6ff2109e2 100644 --- a/grovedb/src/operations/proof/aggregate_count.rs +++ b/grovedb/src/operations/proof/aggregate_count.rs @@ -11,6 +11,25 @@ //! The proof generator side is wired directly into //! [`GroveDb::prove_subqueries`] / [`GroveDb::prove_subqueries_v1`] — see //! the "Aggregate-count short-circuit" branches there. +//! +//! ## Two shapes +//! +//! `AggregateCountOnRange` queries come in two flavors: +//! +//! - **Leaf** — a single `AggregateCountOnRange(_)` item at the top level +//! of the inner `Query`. The proof descends `path_query.path` via +//! single-key existence checks and produces a single `u64` at the leaf +//! merk. Surfaced through [`GroveDb::verify_aggregate_count_query`]. +//! +//! - **Carrier** — an outer query whose items are `Key(_)` / `Range*(_)` +//! (one IN-style fan-out dimension) and whose +//! `default_subquery_branch.subquery` resolves to a leaf ACOR query. +//! The proof descends `path_query.path` via single-key checks, then at +//! the carrier merk it produces a multi-key proof over the outer items; +//! each matched outer key recurses through the `subquery_path` (if any) +//! to a leaf merk that produces its own count. The verifier returns one +//! `(outer_key, count)` pair per matched outer key. Surfaced through +//! [`GroveDb::verify_aggregate_count_query_per_key`]. use grovedb_merk::{ proofs::{ @@ -20,6 +39,7 @@ use grovedb_merk::{ tree::{combine_hash, value_hash}, CryptoHash, }; +use grovedb_query::QueryItem; use grovedb_version::{check_grovedb_v0, version::GroveVersion}; use crate::{ @@ -30,16 +50,17 @@ use crate::{ }; impl GroveDb { - /// Verify a serialized `prove_query` proof against an + /// Verify a serialized `prove_query` proof against a leaf /// `AggregateCountOnRange` `PathQuery`, returning the GroveDB root hash /// and the verified count. /// /// `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 bytes are decoded. + /// [`PathQuery::validate_aggregate_count_on_range`] and additionally must + /// be the **leaf** shape — a single `AggregateCountOnRange(_)` item, no + /// subqueries, no pagination, and an inner range that isn't `Key`, + /// `RangeFull`, or another `AggregateCountOnRange`. Carrier-shape ACOR + /// queries (outer `Keys` + ACOR subquery) must use + /// [`GroveDb::verify_aggregate_count_query_per_key`] instead. /// /// Returns: /// - `root_hash` — the reconstructed GroveDB root hash. The caller is @@ -71,63 +92,192 @@ impl GroveDb { .verify_query_with_options ); - let inner_range = path_query.validate_aggregate_count_on_range()?.clone(); - - // Decode the GroveDBProof envelope using the same config the prover - // uses on the way out (matches `prove_query`). - let config = bincode::config::standard() - .with_big_endian() - .with_limit::<{ 256 * 1024 * 1024 }>(); - let grovedb_proof: GroveDBProof = bincode::decode_from_slice(proof, config) - .map_err(|e| Error::CorruptedData(format!("unable to decode proof: {}", e)))? - .0; + let inner_range = path_query + .query + .query + .validate_leaf_aggregate_count_on_range() + .map_err(crate::query_validation_error_to_invalid_query)? + .clone(); + let grovedb_proof = decode_grovedb_proof(proof)?; let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); - match grovedb_proof { - GroveDBProof::V0(GroveDBProofV0 { root_layer, .. }) => verify_v0_layer( - &root_layer, + let (root_hash, results) = match &grovedb_proof { + GroveDBProof::V0(GroveDBProofV0 { root_layer, .. }) => verify_v0_leaf_chain( + root_layer, path_query, &path_keys, 0, &inner_range, grove_version, - ), - GroveDBProof::V1(GroveDBProofV1 { root_layer }) => verify_v1_layer( - &root_layer, + )?, + GroveDBProof::V1(GroveDBProofV1 { root_layer }) => verify_v1_leaf_chain( + root_layer, path_query, &path_keys, 0, &inner_range, grove_version, + )?, + }; + Ok((root_hash, results)) + } + + /// Verify a serialized `prove_query` proof against an ACOR `PathQuery` + /// in either the leaf or carrier shape, returning one + /// `(outer_key, count)` pair per matched outer key. + /// + /// For a **leaf** ACOR query the returned vector contains exactly one + /// entry whose key is an empty byte string and whose count is the same + /// `u64` [`GroveDb::verify_aggregate_count_query`] would have returned. + /// This makes carrier and leaf consumers symmetric: callers that always + /// process a `Vec<(Vec, u64)>` don't need to branch on the shape. + /// + /// For a **carrier** ACOR query the outer items must be `Key(_)` / + /// `Range*(_)`, the `default_subquery_branch.subquery` must validate as a + /// leaf ACOR, and the optional `subquery_path` is followed exactly + /// (single-key descent per element) before the count proof. The returned + /// vector has one entry per matched outer key in ascending lexicographic + /// order. Outer-key candidates that the prover proved as absent + /// contribute no entry; outer-key candidates that resolve to an + /// **empty** leaf subtree return `count = 0`. + /// + /// Cryptographic guarantees: + /// - Every layer is committed via the same `combine_hash(H(value), + /// lower_hash) == parent_proof_hash` chain check used by the leaf + /// verifier, so a forged path through the carrier or + /// `subquery_path` produces a root-hash mismatch. + /// - Each per-outer-key count is committed by the leaf + /// `HashWithCount` / `KVDigestCount` recomputation; + /// counts can't be tampered with independently. + pub fn verify_aggregate_count_query_per_key( + proof: &[u8], + path_query: &PathQuery, + grove_version: &GroveVersion, + ) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + check_grovedb_v0!( + "verify_aggregate_count_query_per_key", + grove_version + .grovedb_versions + .operations + .proof + .verify_query_with_options + ); + + // Classify the query and extract the leaf inner range plus the + // optional carrier subquery_path. For leaf queries the carrier + // descent below is skipped (carrier_outer_items is None). + let classification = classify_path_query(path_query)?; + + let grovedb_proof = decode_grovedb_proof(proof)?; + let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); + + match &grovedb_proof { + GroveDBProof::V0(GroveDBProofV0 { root_layer, .. }) => verify_v0_with_classification( + root_layer, + path_query, + &path_keys, + &classification, + grove_version, + ), + GroveDBProof::V1(GroveDBProofV1 { root_layer }) => verify_v1_with_classification( + root_layer, + path_query, + &path_keys, + &classification, + grove_version, ), } } } -/// Walk a V0 (`MerkOnlyLayerProof`) envelope. At each non-leaf depth we -/// verify the single-key existence proof for `path[depth]` and descend into -/// the matching lower layer; at the leaf depth we delegate to the merk -/// count verifier. -fn verify_v0_layer( +/// Classification of an ACOR `PathQuery`. Encodes either the leaf-only +/// inner range (no carrier descent) or the carrier outer items + leaf +/// inner range + optional subquery_path that the verifier must follow +/// per outer key. +struct AcorClassification { + /// The inner range that the leaf merk count proof must satisfy. + leaf_inner_range: QueryItem, + /// Carrier outer items. `None` for leaf-only queries. + carrier_outer_items: Option>, + /// Carrier subquery_path (the keys between each outer match and the + /// leaf merk). Empty `Vec` if no subquery_path was set. `None` for + /// leaf-only queries. + carrier_subquery_path: Option>>, + /// Whether the outer query is left-to-right. Affects which results the + /// merk_proof returns when the outer items are ranges. Always `true` + /// for leaf-only. + carrier_left_to_right: bool, +} + +fn classify_path_query(path_query: &PathQuery) -> Result { + let q = &path_query.query.query; + if q.aggregate_count_on_range().is_some() { + // Leaf shape: top-level ACOR item. + let inner = q + .validate_leaf_aggregate_count_on_range() + .map_err(crate::query_validation_error_to_invalid_query)? + .clone(); + return Ok(AcorClassification { + leaf_inner_range: inner, + carrier_outer_items: None, + carrier_subquery_path: None, + carrier_left_to_right: true, + }); + } + if !q.has_aggregate_count_on_range_anywhere() { + return Err(Error::InvalidQuery( + "verify_aggregate_count_query_per_key called on a non-ACOR path query", + )); + } + // Carrier shape. + let leaf_inner = q + .validate_carrier_aggregate_count_on_range() + .map_err(crate::query_validation_error_to_invalid_query)? + .clone(); + let outer_items = q.items.clone(); + let subquery_path = q + .default_subquery_branch + .subquery_path + .clone() + .unwrap_or_default(); + Ok(AcorClassification { + leaf_inner_range: leaf_inner, + carrier_outer_items: Some(outer_items), + carrier_subquery_path: Some(subquery_path), + carrier_left_to_right: q.left_to_right, + }) +} + +fn decode_grovedb_proof(proof: &[u8]) -> Result { + // Decode the GroveDBProof envelope using the same config the prover + // uses on the way out (matches `prove_query`). + let config = bincode::config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (proof, _) = bincode::decode_from_slice(proof, config) + .map_err(|e| Error::CorruptedData(format!("unable to decode proof: {}", e)))?; + Ok(proof) +} + +// ── V0 leaf-only chain (legacy entry point, kept byte-identical) ─────────── + +fn verify_v0_leaf_chain( layer: &MerkOnlyLayerProof, path_query: &PathQuery, path_keys: &[&[u8]], depth: usize, - inner_range: &grovedb_merk::proofs::query::QueryItem, + inner_range: &QueryItem, grove_version: &GroveVersion, ) -> Result<(CryptoHash, u64), Error> { if depth == path_keys.len() { - // Leaf layer: count proof. return verify_count_leaf(&layer.merk_proof, inner_range, path_query); } - // Non-leaf: build a single-key merk query and verify. let next_key = path_keys[depth].to_vec(); let (proven_value_bytes, parent_root_hash, parent_proof_hash) = verify_single_key_layer_proof_v0(&layer.merk_proof, &next_key, path_query)?; - // Descend. let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { Error::InvalidProof( path_query.clone(), @@ -137,7 +287,7 @@ fn verify_v0_layer( ), ) })?; - let (lower_hash, count) = verify_v0_layer( + let (lower_hash, count) = verify_v0_leaf_chain( lower_layer, path_query, path_keys, @@ -146,8 +296,6 @@ fn verify_v0_layer( grove_version, )?; - // Chain check: combine_hash(H(tree_value), lower_hash) must equal the - // value_hash recorded by the parent merk for this tree element. enforce_lower_chain( path_query, &next_key, @@ -160,30 +308,15 @@ fn verify_v0_layer( Ok((parent_root_hash, count)) } -/// Walk a V1 (`LayerProof`) envelope. Mirrors `verify_v0_layer`; the V1 -/// envelope wraps merk proof bytes in `ProofBytes::Merk(_)` and we reject -/// any other tree-specific proof variant for count queries (they're not -/// applicable to provable count trees). -fn verify_v1_layer( +fn verify_v1_leaf_chain( layer: &LayerProof, path_query: &PathQuery, path_keys: &[&[u8]], depth: usize, - inner_range: &grovedb_merk::proofs::query::QueryItem, + inner_range: &QueryItem, grove_version: &GroveVersion, ) -> Result<(CryptoHash, u64), Error> { - let merk_bytes = match &layer.merk_proof { - ProofBytes::Merk(b) => b.as_slice(), - other => { - return Err(Error::InvalidProof( - path_query.clone(), - format!( - "aggregate-count proof has unexpected non-merk leaf bytes: {:?}", - std::mem::discriminant(other) - ), - )); - } - }; + let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; if depth == path_keys.len() { return verify_count_leaf(merk_bytes, inner_range, path_query); @@ -202,7 +335,7 @@ fn verify_v1_layer( ), ) })?; - let (lower_hash, count) = verify_v1_layer( + let (lower_hash, count) = verify_v1_leaf_chain( lower_layer, path_query, path_keys, @@ -223,11 +356,380 @@ fn verify_v1_layer( Ok((parent_root_hash, count)) } +// ── per-key entry-point traversal (leaf or carrier) ──────────────────────── + +fn verify_v0_with_classification( + layer: &MerkOnlyLayerProof, + path_query: &PathQuery, + path_keys: &[&[u8]], + classification: &AcorClassification, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + verify_v0_per_key( + layer, + path_query, + path_keys, + 0, + classification, + grove_version, + ) +} + +fn verify_v0_per_key( + layer: &MerkOnlyLayerProof, + path_query: &PathQuery, + path_keys: &[&[u8]], + depth: usize, + classification: &AcorClassification, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + if depth < path_keys.len() { + // Path-prefix layer: same single-key descent both shapes use. + let next_key = path_keys[depth].to_vec(); + let (proven_value_bytes, parent_root_hash, parent_proof_hash) = + verify_single_key_layer_proof_v0(&layer.merk_proof, &next_key, path_query)?; + + let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "aggregate-count proof missing lower layer for path key {}", + hex::encode(&next_key) + ), + ) + })?; + let (lower_hash, results) = verify_v0_per_key( + lower_layer, + path_query, + path_keys, + depth + 1, + classification, + grove_version, + )?; + enforce_lower_chain( + path_query, + &next_key, + &proven_value_bytes, + &lower_hash, + &parent_proof_hash, + grove_version, + )?; + return Ok((parent_root_hash, results)); + } + + // depth == path_keys.len(): we are at the carrier (or, in the leaf + // shape, at the leaf merk). + match &classification.carrier_outer_items { + None => { + // Leaf shape: single u64 → one entry with empty key. + let (root, count) = verify_count_leaf( + &layer.merk_proof, + &classification.leaf_inner_range, + path_query, + )?; + Ok((root, vec![(Vec::new(), count)])) + } + Some(outer_items) => verify_v0_carrier_layer( + layer, + path_query, + outer_items, + classification, + grove_version, + ), + } +} + +fn verify_v0_carrier_layer( + layer: &MerkOnlyLayerProof, + path_query: &PathQuery, + outer_items: &[QueryItem], + classification: &AcorClassification, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + // Execute the outer multi-key merk proof to discover which outer keys + // matched (and recover their value_hash commitments). + let (carrier_root, matched) = execute_carrier_layer_proof( + &layer.merk_proof, + outer_items, + classification.carrier_left_to_right, + path_query, + )?; + + let subquery_path = classification + .carrier_subquery_path + .as_ref() + .expect("carrier subquery_path is set when carrier_outer_items is Some"); + + let mut results = Vec::with_capacity(matched.len()); + for OuterMatch { + outer_key, + value_bytes, + commitment_hash, + } in matched + { + let lower_layer = layer.lower_layers.get(&outer_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "carrier ACOR proof missing lower layer for outer key {}", + hex::encode(&outer_key) + ), + ) + })?; + + let (lower_root, count) = verify_v0_subquery_path( + lower_layer, + path_query, + subquery_path, + 0, + &classification.leaf_inner_range, + grove_version, + )?; + + enforce_lower_chain( + path_query, + &outer_key, + &value_bytes, + &lower_root, + &commitment_hash, + grove_version, + )?; + + results.push((outer_key, count)); + } + + Ok((carrier_root, results)) +} + +fn verify_v0_subquery_path( + layer: &MerkOnlyLayerProof, + path_query: &PathQuery, + subquery_path: &[Vec], + depth: usize, + inner_range: &QueryItem, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, u64), Error> { + if depth == subquery_path.len() { + // Leaf merk: count proof. + return verify_count_leaf(&layer.merk_proof, inner_range, path_query); + } + let next_key = subquery_path[depth].clone(); + let (proven_value_bytes, parent_root_hash, parent_proof_hash) = + verify_single_key_layer_proof_v0(&layer.merk_proof, &next_key, path_query)?; + let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "carrier ACOR proof missing subquery_path layer for key {}", + hex::encode(&next_key) + ), + ) + })?; + let (lower_hash, count) = verify_v0_subquery_path( + lower_layer, + path_query, + subquery_path, + depth + 1, + inner_range, + grove_version, + )?; + enforce_lower_chain( + path_query, + &next_key, + &proven_value_bytes, + &lower_hash, + &parent_proof_hash, + grove_version, + )?; + Ok((parent_root_hash, count)) +} + +fn verify_v1_with_classification( + layer: &LayerProof, + path_query: &PathQuery, + path_keys: &[&[u8]], + classification: &AcorClassification, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + verify_v1_per_key( + layer, + path_query, + path_keys, + 0, + classification, + grove_version, + ) +} + +fn verify_v1_per_key( + layer: &LayerProof, + path_query: &PathQuery, + path_keys: &[&[u8]], + depth: usize, + classification: &AcorClassification, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; + + if depth < path_keys.len() { + let next_key = path_keys[depth].to_vec(); + let (proven_value_bytes, parent_root_hash, parent_proof_hash) = + verify_single_key_layer_proof_v0(merk_bytes, &next_key, path_query)?; + let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "aggregate-count proof missing lower layer for path key {}", + hex::encode(&next_key) + ), + ) + })?; + let (lower_hash, results) = verify_v1_per_key( + lower_layer, + path_query, + path_keys, + depth + 1, + classification, + grove_version, + )?; + enforce_lower_chain( + path_query, + &next_key, + &proven_value_bytes, + &lower_hash, + &parent_proof_hash, + grove_version, + )?; + return Ok((parent_root_hash, results)); + } + + match &classification.carrier_outer_items { + None => { + let (root, count) = + verify_count_leaf(merk_bytes, &classification.leaf_inner_range, path_query)?; + Ok((root, vec![(Vec::new(), count)])) + } + Some(outer_items) => verify_v1_carrier_layer( + layer, + merk_bytes, + path_query, + outer_items, + classification, + grove_version, + ), + } +} + +fn verify_v1_carrier_layer( + layer: &LayerProof, + merk_bytes: &[u8], + path_query: &PathQuery, + outer_items: &[QueryItem], + classification: &AcorClassification, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + let (carrier_root, matched) = execute_carrier_layer_proof( + merk_bytes, + outer_items, + classification.carrier_left_to_right, + path_query, + )?; + + let subquery_path = classification + .carrier_subquery_path + .as_ref() + .expect("carrier subquery_path is set when carrier_outer_items is Some"); + + let mut results = Vec::with_capacity(matched.len()); + for OuterMatch { + outer_key, + value_bytes, + commitment_hash, + } in matched + { + let lower_layer = layer.lower_layers.get(&outer_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "carrier ACOR proof missing lower layer for outer key {}", + hex::encode(&outer_key) + ), + ) + })?; + + let (lower_root, count) = verify_v1_subquery_path( + lower_layer, + path_query, + subquery_path, + 0, + &classification.leaf_inner_range, + grove_version, + )?; + + enforce_lower_chain( + path_query, + &outer_key, + &value_bytes, + &lower_root, + &commitment_hash, + grove_version, + )?; + results.push((outer_key, count)); + } + + Ok((carrier_root, results)) +} + +fn verify_v1_subquery_path( + layer: &LayerProof, + path_query: &PathQuery, + subquery_path: &[Vec], + depth: usize, + inner_range: &QueryItem, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, u64), Error> { + let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; + if depth == subquery_path.len() { + return verify_count_leaf(merk_bytes, inner_range, path_query); + } + let next_key = subquery_path[depth].clone(); + let (proven_value_bytes, parent_root_hash, parent_proof_hash) = + verify_single_key_layer_proof_v0(merk_bytes, &next_key, path_query)?; + let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "carrier ACOR proof missing subquery_path layer for key {}", + hex::encode(&next_key) + ), + ) + })?; + let (lower_hash, count) = verify_v1_subquery_path( + lower_layer, + path_query, + subquery_path, + depth + 1, + inner_range, + grove_version, + )?; + enforce_lower_chain( + path_query, + &next_key, + &proven_value_bytes, + &lower_hash, + &parent_proof_hash, + grove_version, + )?; + Ok((parent_root_hash, count)) +} + +// ── shared helpers ───────────────────────────────────────────────────────── + /// Verify the leaf layer: bytes are the encoded count-proof Op stream; /// the inner range is the same one the prover counted over. fn verify_count_leaf( leaf_bytes: &[u8], - inner_range: &grovedb_merk::proofs::query::QueryItem, + inner_range: &QueryItem, path_query: &PathQuery, ) -> Result<(CryptoHash, u64), Error> { let (root_hash, count) = verify_aggregate_count_on_range_proof(leaf_bytes, inner_range) @@ -241,6 +743,22 @@ fn verify_count_leaf( Ok((root_hash, count)) } +fn expect_merk_bytes<'a>( + proof_bytes: &'a ProofBytes, + path_query: &PathQuery, +) -> Result<&'a [u8], Error> { + match proof_bytes { + ProofBytes::Merk(b) => Ok(b.as_slice()), + other => Err(Error::InvalidProof( + path_query.clone(), + format!( + "aggregate-count proof has unexpected non-merk layer bytes: {:?}", + std::mem::discriminant(other) + ), + )), + } +} + /// Verify a non-leaf layer that should contain a single-key proof for /// `target_key`. Returns `(proven_value_bytes, this_layer_root_hash, /// proof_hash_recorded_for_target)`. @@ -276,7 +794,6 @@ fn verify_single_key_layer_proof_v0( ) })?; - // Find the result row for our target key and pull the value + proof_hash. let proved = merk_result .result_set .iter() @@ -304,6 +821,67 @@ fn verify_single_key_layer_proof_v0( Ok((value_bytes, root_hash, proved.proof)) } +/// One matched outer key in the carrier layer's multi-key merk proof. +struct OuterMatch { + /// The matched outer key bytes. + outer_key: Vec, + /// The serialized tree element bytes for the matched outer key (a + /// non-empty tree element of some flavor). + value_bytes: Vec, + /// The value_hash the parent merk committed for this outer key — the + /// hash that must equal `combine_hash(H(value), lower_layer_root)`. + commitment_hash: CryptoHash, +} + +/// Execute the carrier-layer multi-key merk proof for `outer_items`, +/// returning `(carrier_merk_root_hash, matched_outer_keys)`. Each +/// `OuterMatch` carries the value bytes and the parent-recorded value_hash +/// that the chain check will validate. +fn execute_carrier_layer_proof( + merk_bytes: &[u8], + outer_items: &[QueryItem], + left_to_right: bool, + path_query: &PathQuery, +) -> Result<(CryptoHash, Vec), Error> { + // The grovedb_query::QueryItem and grovedb_merk::proofs::query::QueryItem + // types are identical (the merk crate re-exports the grovedb-query one). + let level_query = MerkQuery { + items: outer_items.to_vec(), + left_to_right, + ..Default::default() + }; + + let (root_hash, merk_result) = level_query + .execute_proof(merk_bytes, None, true, 0) + .unwrap() + .map_err(|e| { + Error::InvalidProof( + path_query.clone(), + format!("carrier ACOR multi-key proof failed to verify: {}", e), + ) + })?; + + let mut matched = Vec::with_capacity(merk_result.result_set.len()); + for proved in &merk_result.result_set { + let value = proved.value.clone().ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "carrier ACOR proof returned a result row without value bytes for key {}", + hex::encode(&proved.key) + ), + ) + })?; + matched.push(OuterMatch { + outer_key: proved.key.clone(), + value_bytes: value, + commitment_hash: proved.proof, + }); + } + + Ok((root_hash, matched)) +} + /// Enforce the layer-chain hash equality: the parent merk's recorded /// value_hash for the tree element must equal `combine_hash(H(value), /// lower_layer_root_hash)`. This is what makes the count cryptographically diff --git a/grovedb/src/query/mod.rs b/grovedb/src/query/mod.rs index 2c1c0c585..a8940e6ce 100644 --- a/grovedb/src/query/mod.rs +++ b/grovedb/src/query/mod.rs @@ -116,14 +116,37 @@ impl SizedQuery { } /// Validates that this sized query is a well-formed - /// `AggregateCountOnRange` query. On success, returns a reference to the - /// inner range item (the `QueryItem` wrapped by `AggregateCountOnRange`). + /// `AggregateCountOnRange` query in either the **leaf** or **carrier** + /// shape. On success, returns a reference to the leaf inner range item + /// (the `QueryItem` wrapped by the underlying `AggregateCountOnRange`, + /// whether at this level for leaf queries or inside the + /// `default_subquery_branch.subquery` for carrier queries). /// /// This is the `SizedQuery`-level entry point: it forwards to /// [`Query::validate_aggregate_count_on_range`] and additionally rejects /// any non-`None` `limit` or `offset` (counting is an aggregate over the /// full match set — pagination would silently change the answer). pub fn validate_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { + self.check_aggregate_count_size_constraints()?; + self.query + .validate_aggregate_count_on_range() + .map_err(query_validation_error_to_static_str) + .map_err(Error::InvalidQuery) + } + + /// Strict variant of [`Self::validate_aggregate_count_on_range`] that + /// only accepts the **leaf** shape (single `AggregateCountOnRange(_)` + /// item, no subqueries). Used by entry points that produce a single + /// `u64` and need to reject the carrier shape up front. + pub fn validate_leaf_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { + self.check_aggregate_count_size_constraints()?; + self.query + .validate_leaf_aggregate_count_on_range() + .map_err(query_validation_error_to_static_str) + .map_err(Error::InvalidQuery) + } + + fn check_aggregate_count_size_constraints(&self) -> Result<(), Error> { if self.limit.is_some() { return Err(Error::InvalidQuery( "AggregateCountOnRange queries may not set SizedQuery::limit", @@ -134,10 +157,7 @@ impl SizedQuery { "AggregateCountOnRange queries may not set SizedQuery::offset", )); } - self.query - .validate_aggregate_count_on_range() - .map_err(query_validation_error_to_static_str) - .map_err(Error::InvalidQuery) + Ok(()) } } @@ -146,13 +166,19 @@ impl SizedQuery { /// `grovedb_query::error::Error::InvalidOperation(&'static str)`, so this is /// just a projection of that variant; any other error variant (which would /// indicate an unrelated bug) is forwarded as a generic catch-all label. -fn query_validation_error_to_static_str(e: grovedb_query::error::Error) -> &'static str { +pub(crate) fn query_validation_error_to_static_str(e: grovedb_query::error::Error) -> &'static str { match e { grovedb_query::error::Error::InvalidOperation(msg) => msg, _ => "AggregateCountOnRange query validation failed", } } +/// Convenience wrapper: project a `grovedb_query::error::Error` from the +/// validation helpers onto [`Error::InvalidQuery`]. +pub(crate) fn query_validation_error_to_invalid_query(e: grovedb_query::error::Error) -> Error { + Error::InvalidQuery(query_validation_error_to_static_str(e)) +} + impl PathQuery { /// New path query pub const fn new(path: Vec>, query: SizedQuery) -> Self { @@ -190,14 +216,21 @@ impl PathQuery { } /// Validates that this `PathQuery` is a well-formed - /// `AggregateCountOnRange` query. On success, returns a reference to the - /// inner range item. + /// `AggregateCountOnRange` query in either the leaf or carrier shape. + /// On success, returns a reference to the leaf inner range item. /// /// Forwards to [`SizedQuery::validate_aggregate_count_on_range`]. pub fn validate_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { self.query.validate_aggregate_count_on_range() } + /// Strict variant of [`Self::validate_aggregate_count_on_range`] that + /// only accepts the **leaf** shape (single `AggregateCountOnRange(_)` + /// item, no subqueries). + pub fn validate_leaf_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { + self.query.validate_leaf_aggregate_count_on_range() + } + /// Returns `true` if this `PathQuery`'s underlying query carries an /// `AggregateCountOnRange` item (whether well-formed or not). Use /// [`Self::validate_aggregate_count_on_range`] when you also need diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index b812d740e..ece99fa36 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -763,22 +763,24 @@ mod tests { } #[test] - fn aggregate_count_hidden_in_subquery_branch_is_rejected_at_entry() { - // Codex's broader concern: an `AggregateCountOnRange` smuggled - // inside a `default_subquery_branch.subquery` is also invalid (ACOR - // is terminal — it cannot be reached via a normal subquery path) - // and must be rejected up front. The recursive detector - // `has_aggregate_count_on_range_anywhere` finds the hidden ACOR; - // top-level `validate_aggregate_count_on_range` then rejects - // because the surrounding query isn't the canonical single-ACOR - // shape. + fn aggregate_count_hidden_in_subquery_branch_with_invalid_inner_is_rejected_at_entry() { + // After the carrier-ACOR feature landed, an `AggregateCountOnRange` + // smuggled inside a `default_subquery_branch.subquery` is **valid** + // when the surrounding query satisfies the carrier rules — that is + // the whole point of the carrier shape. + // + // What this test still guards is the *other* malformed case: a + // carrier whose subquery is itself a malformed leaf ACOR (here, an + // ACOR wrapping `Key` — leaf rule 3). The carrier validator + // delegates to `validate_leaf_aggregate_count_on_range`, which + // surfaces the malformed-inner error, and the prove-entry gate + // refuses to run the query. let v = GroveVersion::latest(); let db = make_test_grovedb(v); - let inner_acor = QueryItem::AggregateCountOnRange(Box::new(QueryItem::Range( - b"a".to_vec()..b"z".to_vec(), - ))); + let bad_inner_acor = + QueryItem::AggregateCountOnRange(Box::new(QueryItem::Key(b"k".to_vec()))); let mut sub_query = grovedb_merk::proofs::Query::new(); - sub_query.insert_item(inner_acor); + sub_query.insert_item(bad_inner_acor); let mut top_query = grovedb_merk::proofs::Query::new(); top_query.insert_range_inclusive(b"a".to_vec()..=b"z".to_vec()); top_query.set_subquery(sub_query); @@ -789,7 +791,7 @@ mod tests { let prove_result = db.grove_db.prove_query(&path_query, None, v).unwrap(); assert!( matches!(prove_result, Err(crate::Error::InvalidQuery(_))), - "ACOR hidden in subquery branch must be rejected at entry, got {:?}", + "carrier ACOR with malformed leaf-inner Key must be rejected at entry, got {:?}", prove_result.map(|b| b.len()) ); } @@ -1545,4 +1547,376 @@ mod tests { .expect("query_aggregate_count on empty tree should succeed"); assert_eq!(count, 0, "empty tree must return 0"); } + + // ---------- Carrier ACOR end-to-end tests ---------- + // + // A "carrier" ACOR query is an outer fan-out — the outer query items + // are `Key`/`Range*` and the `default_subquery_branch.subquery` + // resolves (after walking the optional `subquery_path`) to a leaf + // ACOR. The verifier returns one `(outer_key, u64)` pair per matched + // outer key. These tests exercise the full prove → encode → decode → + // verify pipeline. + + /// Build a 3-deep tree shaped like the Dash Platform GROUP BY use + /// case: `TEST_LEAF / "byBrand" / / "color" / + /// `. + /// + /// Each brand subtree has a `color` child that is a + /// `ProvableCountTree` populated with `colors_per_brand` keys of the + /// form `color_`. + fn setup_brand_color_carrier_tree( + grove_version: &GroveVersion, + brands: &[&[u8]], + colors_per_brand: u32, + ) -> (crate::tests::TempGroveDb, [u8; 32]) { + let db = make_test_grovedb(grove_version); + db.insert( + [TEST_LEAF].as_ref(), + b"byBrand", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert byBrand"); + for brand in brands { + db.insert( + [TEST_LEAF, b"byBrand"].as_ref(), + brand, + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert brand"); + db.insert( + [TEST_LEAF, b"byBrand", brand].as_ref(), + b"color", + Element::empty_provable_count_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert color subtree"); + for i in 0..colors_per_brand { + let key = format!("color_{:05}", i); + db.insert( + [TEST_LEAF, b"byBrand", brand, b"color"].as_ref(), + key.as_bytes(), + Element::new_item(key.as_bytes().to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert color leaf"); + } + } + let root = db + .grove_db + .root_hash(None, grove_version) + .unwrap() + .expect("root_hash"); + (db, root) + } + + /// Build a carrier ACOR `PathQuery` rooted at + /// `[TEST_LEAF, "byBrand"]`, fanning out across `outer_keys` and + /// counting elements in each brand's `color` subtree matching the + /// inner range. + fn carrier_acor_path_query(outer_keys: &[&[u8]], inner_range: QueryItem) -> PathQuery { + use grovedb_query::Query; + + let mut carrier = Query::new(); + for k in outer_keys { + carrier.items.push(QueryItem::Key(k.to_vec())); + } + carrier.set_subquery_path(vec![b"color".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_on_range(inner_range)); + + PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, None), + ) + } + + #[test] + fn acor_subquery_two_outer_keys_succeeds() { + // Carrier with two outer brand keys, range on the color subtree. + // Expected: two (key, count) pairs in lex-asc order with the + // correct per-brand aggregate. + let v = GroveVersion::latest(); + let (db, expected_root) = + setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 1_000); + // Pick a range that drops the lower 500 elements (`color_00000` + // through `color_00499`). + let path_query = carrier_acor_path_query( + &[b"brand_000", b"brand_001"], + QueryItem::RangeAfter(b"color_00499".to_vec()..), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query (carrier ACOR) should succeed"); + let (got_root, results) = + GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect("verify carrier ACOR should succeed"); + assert_eq!(got_root, expected_root, "root must match GroveDB root"); + assert_eq!(results.len(), 2, "expected one result per outer key"); + // Lex-asc order: brand_000 then brand_001. + assert_eq!(results[0].0, b"brand_000".to_vec()); + assert_eq!(results[1].0, b"brand_001".to_vec()); + // Each brand has 1 000 colors; range_after `color_00499` leaves + // the upper 500 (`color_00500` .. `color_00999`). + assert_eq!(results[0].1, 500); + assert_eq!(results[1].1, 500); + } + + #[test] + fn acor_subquery_with_unknown_outer_key_returns_present_keys_only() { + // Spec acceptance criterion 2: an outer-key match that doesn't + // exist contributes no entry to the result vector (it's an + // absence, not an error). The prover doesn't emit a lower layer + // for keys that don't exist in the carrier subtree, so the + // verifier sees only the matched keys. + let v = GroveVersion::latest(); + let (db, expected_root) = setup_brand_color_carrier_tree(v, &[b"brand_000"], 1_000); + // Ask for two brands — one present, one absent. + let path_query = carrier_acor_path_query( + &[b"brand_000", b"brand_999_missing"], + QueryItem::RangeAfter(b"color_00499".to_vec()..), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (got_root, results) = + GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(got_root, expected_root); + // Only the present brand contributes a result row. + assert_eq!( + results.len(), + 1, + "absent outer keys must not contribute an entry" + ); + assert_eq!(results[0].0, b"brand_000".to_vec()); + assert_eq!(results[0].1, 500); + } + + #[test] + fn acor_subquery_rejects_acor_at_both_levels() { + // Try to build a query where the carrier ITSELF has an ACOR item + // AND its subquery is also an ACOR. The validator must reject up + // front at prove time. + use grovedb_query::Query; + + let mut q = + Query::new_aggregate_count_on_range(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + q.set_subquery(Query::new_aggregate_count_on_range(QueryItem::Range( + b"a".to_vec()..b"z".to_vec(), + ))); + let pq = PathQuery::new(vec![TEST_LEAF.to_vec()], SizedQuery::new(q, None, None)); + let v = GroveVersion::latest(); + // Validation catches it. + assert!( + pq.validate_aggregate_count_on_range().is_err(), + "ACOR + subquery ACOR must fail validation" + ); + // The prove_query entry-point gate must also reject it. + let prove_result = make_test_grovedb(v).grove_db.prove_query(&pq, None, v); + match prove_result.value() { + Err(crate::Error::InvalidQuery(_)) => {} + other => panic!("expected InvalidQuery, got {:?}", other), + } + } + + #[test] + fn acor_leaf_unchanged_under_per_key_verifier() { + // The leaf shape — a single-ACOR query — produces exactly the + // same proof bytes it did before this feature. Verifying it via + // the new per-key entry point returns a one-entry Vec with an + // empty key and the same count `verify_aggregate_count_query` + // returns. This is the leaf-symmetry contract. + let v = GroveVersion::latest(); + let (db, expected_root) = 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()), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + // Existing single-u64 entry point still works. + let (root_one, count_one) = GroveDb::verify_aggregate_count_query(&proof, &path_query, v) + .expect("legacy leaf verifier must still accept legacy leaf proof"); + // New per-key entry point also accepts leaf and returns a + // one-entry Vec with an empty key. + let (root_many, results) = + GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect("per-key verifier must accept leaf proofs"); + assert_eq!(root_one, expected_root); + assert_eq!(root_one, root_many); + assert_eq!(count_one, 10); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, Vec::::new()); + assert_eq!(results[0].1, count_one); + } + + #[test] + fn acor_subquery_carrier_with_range_outer_succeeds() { + // The carrier supports a Range outer item (the per-spec + // "decide-or-defer" case). With an outer `RangeAfter`, the + // matched outer keys come back in lex-asc order and each + // contributes its own count. + use grovedb_query::Query; + let v = GroveVersion::latest(); + let (db, expected_root) = + setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001", b"brand_002"], 100); + + let mut carrier = Query::new(); + // Take everything strictly after brand_000 → brand_001, brand_002. + carrier + .items + .push(QueryItem::RangeAfter(b"brand_000".to_vec()..)); + carrier.set_subquery_path(vec![b"color".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_on_range(QueryItem::RangeAfter( + b"color_00049".to_vec().., + ))); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, None), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query (carrier with Range outer) should succeed"); + let (got_root, results) = + GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect("verify carrier with Range outer should succeed"); + assert_eq!(got_root, expected_root); + assert_eq!(results.len(), 2); + assert_eq!(results[0].0, b"brand_001".to_vec()); + assert_eq!(results[1].0, b"brand_002".to_vec()); + for (_, count) in results { + // 100 colors per brand; > color_00049 leaves 50. + assert_eq!(count, 50); + } + } + + #[test] + fn acor_per_key_rejects_non_acor_path_query() { + // The per-key entry point rejects path queries that aren't ACOR + // queries at all — neither leaf nor carrier — before decoding + // proof bytes. + let v = GroveVersion::latest(); + let bad_query = PathQuery::new_single_query_item( + vec![TEST_LEAF.to_vec()], + QueryItem::Key(b"k".to_vec()), + ); + let dummy_proof = vec![0u8; 16]; + let err = GroveDb::verify_aggregate_count_query_per_key(&dummy_proof, &bad_query, v) + .expect_err("non-ACOR path_query must be rejected up front"); + match err { + crate::Error::InvalidQuery(_) => {} + other => panic!("expected InvalidQuery, got {:?}", other), + } + } + + #[test] + fn acor_subquery_count_forgery_is_caught() { + // Same spirit as `count_forgery_is_caught_at_grovedb_level` but + // against a carrier proof: pick the first leaf merk + // `HashWithCount` op in any of the per-outer-key sub-proofs and + // bump its count. The verifier must reject. + use bincode::config; + use grovedb_merk::proofs::{encoding::encode_into, Decoder, Node, Op}; + + use crate::operations::proof::{ + GroveDBProof, GroveDBProofV0, GroveDBProofV1, LayerProof, MerkOnlyLayerProof, + ProofBytes, + }; + + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 100); + let path_query = carrier_acor_path_query( + &[b"brand_000", b"brand_001"], + QueryItem::RangeAfter(b"color_00049".to_vec()..), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + + let cfg = config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (mut decoded, _): (GroveDBProof, _) = + bincode::decode_from_slice(&proof, cfg).expect("decode envelope"); + + // Walk to the first leaf merk proof bytes via depth-first + // descent: we expect the path `TEST_LEAF -> byBrand -> brand_000 + // -> color`. The "leaf" is the deepest layer (a leaf has no + // further lower_layers; in our test setup that's the count proof + // at the color subtree). + fn first_leaf_v0(mut layer: &mut MerkOnlyLayerProof) -> &mut Vec { + while let Some((_, child)) = layer.lower_layers.iter_mut().next() { + layer = child; + } + &mut layer.merk_proof + } + fn first_leaf_v1(mut layer: &mut LayerProof) -> &mut Vec { + while let Some((_, child)) = layer.lower_layers.iter_mut().next() { + layer = child; + } + match &mut layer.merk_proof { + ProofBytes::Merk(b) => b, + _ => panic!("expected Merk leaf bytes"), + } + } + let leaf_bytes: &mut Vec = match &mut decoded { + GroveDBProof::V0(GroveDBProofV0 { root_layer, .. }) => first_leaf_v0(root_layer), + GroveDBProof::V1(GroveDBProofV1 { root_layer }) => first_leaf_v1(root_layer), + }; + let mut ops: Vec = Decoder::new(leaf_bytes) + .map(|r| r.expect("decode op")) + .collect(); + let mut tampered = false; + for op in ops.iter_mut() { + match op { + Op::Push(Node::HashWithCount(_, _, _, count)) + | Op::PushInverted(Node::HashWithCount(_, _, _, count)) => { + *count = count.wrapping_add(1); + tampered = true; + break; + } + _ => {} + } + } + assert!(tampered, "expected a HashWithCount in the leaf proof"); + let mut new_leaf = Vec::new(); + encode_into(ops.iter(), &mut new_leaf); + *leaf_bytes = new_leaf; + let new_proof = bincode::encode_to_vec( + decoded, + config::standard().with_big_endian().with_no_limit(), + ) + .expect("re-encode"); + + let result = GroveDb::verify_aggregate_count_query_per_key(&new_proof, &path_query, v); + assert!( + result.is_err(), + "tampered carrier count must be rejected, got {:?}", + result.map(|(_, c)| c) + ); + } } From 73ccbc2f492a257577420168bcd40cdaecdb9242 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 04:42:48 +0700 Subject: [PATCH 02/15] fix(grovedb,query): address review feedback on carrier ACOR - Move ACOR construction/validation out of `grovedb-query/src/query.rs` (it had grown past 1500 lines) into a focused `grovedb-query/src/aggregate_count.rs` with its own `impl Query` block and tests. `query.rs` shrinks from ~1580 to ~910 lines. - Per CodeRabbit major: route verifier validation through the PathQuery layer so `SizedQuery::limit`/`offset` are rejected up front for both leaf and carrier shapes. New tests pin the pagination rejection. - V0 proof envelopes can't carry the carrier shape (they're produced only by pre-feature grove versions); the new `verify_v0_with_classification` rejects carrier classification with `InvalidProof("only supported on V1 proof envelopes")` instead of attempting a never-emitted carrier walk. Removes the unused `verify_v0_carrier_layer` / `verify_v0_subquery_path`. - Per CodeRabbit minor: ordering doc on `verify_aggregate_count_query_per_key` now says "query-direction order" (matches the merk walker) instead of asserting strict ascending lex, since the carrier honors `Query::left_to_right`. - Per CodeRabbit nitpick: pin the malformed-leaf-inner-Key rejection message in the carrier path so future refactors can't silently re-route the error. - New end-to-end tests: V0+carrier rejection, long subquery_path (2 intermediate single-key layers), corrupted outer-layer merk bytes, undecodable envelope, legacy `verify_aggregate_count_query` rejection of carrier shapes, carrier pagination rejection. Co-Authored-By: Claude Opus 4.7 (1M context) --- grovedb-query/src/aggregate_count.rs | 767 ++++++++++++++++++ grovedb-query/src/lib.rs | 5 + grovedb-query/src/query.rs | 669 --------------- grovedb/src/lib.rs | 2 - .../src/operations/proof/aggregate_count.rs | 242 +----- grovedb/src/query/mod.rs | 6 - .../src/tests/aggregate_count_query_tests.rs | 269 +++++- 7 files changed, 1077 insertions(+), 883 deletions(-) create mode 100644 grovedb-query/src/aggregate_count.rs diff --git a/grovedb-query/src/aggregate_count.rs b/grovedb-query/src/aggregate_count.rs new file mode 100644 index 000000000..14476c783 --- /dev/null +++ b/grovedb-query/src/aggregate_count.rs @@ -0,0 +1,767 @@ +//! `AggregateCountOnRange` (ACOR) Query helpers and validation. +//! +//! This module owns the Query-level construction, detection, and validation +//! of `AggregateCountOnRange` queries. ACOR comes in two shapes: +//! +//! - **Leaf** — a query whose single item is `AggregateCountOnRange(_)`. +//! Produces a single `u64` count over the inner range. +//! +//! - **Carrier** — a query whose items are `Key(_)` / `Range*(_)` and whose +//! `default_subquery_branch.subquery` resolves (after walking the optional +//! `subquery_path`) to a valid leaf ACOR. Produces one `u64` per matched +//! outer key — the natural per-outer-key extension of the leaf shape. +//! +//! All ACOR validation lives in this file so the much larger `Query` core +//! in `query.rs` stays focused on the general-purpose query plumbing. + +use crate::{error::Error, query::Query, query_item::QueryItem}; + +impl Query { + /// Creates an aggregate-count-on-range query that counts the elements + /// matched by `range`. The resulting query has `AggregateCountOnRange(range)` + /// as its sole item, no subquery branches, and `left_to_right = true` + /// (counting is direction-agnostic). + /// + /// `range` must be a true range variant (`Range`, `RangeInclusive`, + /// `RangeFrom`, `RangeTo`, `RangeToInclusive`, `RangeAfter`, `RangeAfterTo`, + /// or `RangeAfterToInclusive`). Passing `Key`, `RangeFull`, or another + /// `AggregateCountOnRange` is allowed at construction time but will be + /// rejected by [`Self::validate_aggregate_count_on_range`]. + pub fn new_aggregate_count_on_range(range: QueryItem) -> Self { + Self { + items: vec![QueryItem::AggregateCountOnRange(Box::new(range))], + left_to_right: true, + ..Self::default() + } + } + + /// If this query contains an `AggregateCountOnRange` item *anywhere* in + /// its `items` vec, returns a reference to the first such item (whether + /// the surrounding query is well-formed or not). Returns `None` only + /// when no item is an `AggregateCountOnRange`. + /// + /// This is intentionally a **detection-only** helper: malformed queries + /// like `items: [Key(...), AggregateCountOnRange(...)]` still report + /// `Some(...)` here so callers don't accidentally route them through + /// the regular-query path. Use + /// [`Self::validate_aggregate_count_on_range`] when you also need to + /// enforce the well-formedness rules (single item, allowed inner kind, + /// no subqueries, etc.). + pub fn aggregate_count_on_range(&self) -> Option<&QueryItem> { + self.items + .iter() + .find(|item| item.is_aggregate_count_on_range()) + } + + /// Returns `true` if any item in this query — including items inside + /// nested subquery branches — is an `AggregateCountOnRange`. + /// + /// `AggregateCountOnRange` is a *terminal* item: a well-formed query + /// either contains exactly one `AggregateCountOnRange` at the top + /// level and nothing else (leaf shape) or contains + /// `Key`/`Range*` items at the top level with an ACOR nested in the + /// `default_subquery_branch.subquery` (carrier shape). This recursive + /// detector exists so the prover can validate up front: if any ACOR + /// is present anywhere, the query as a whole must satisfy + /// [`Self::validate_aggregate_count_on_range`] — otherwise a malformed + /// shape could slip past a top-level-only check and be silently routed + /// through the regular-proof path. + pub fn has_aggregate_count_on_range_anywhere(&self) -> bool { + if self.aggregate_count_on_range().is_some() { + return true; + } + if let Some(sub) = self.default_subquery_branch.subquery.as_deref() + && sub.has_aggregate_count_on_range_anywhere() + { + return true; + } + if let Some(branches) = &self.conditional_subquery_branches { + for branch in branches.values() { + if let Some(sub) = branch.subquery.as_deref() + && sub.has_aggregate_count_on_range_anywhere() + { + return true; + } + } + } + false + } + + /// Validates the Query-level constraints that apply when an + /// `AggregateCountOnRange` is present. On success, returns a reference + /// to the inner range `QueryItem` describing the keys being counted + /// (the same item regardless of whether the surrounding query is the + /// leaf shape or the carrier shape). + /// + /// Top-level dispatcher: classifies the query as either + /// - **leaf** (the query owns an `AggregateCountOnRange` item directly — + /// the original single-`u64` shape), or + /// - **carrier** (the query is an outer fan-out of `Key`/`Range` items + /// whose `default_subquery_branch.subquery` resolves to a leaf + /// `AggregateCountOnRange` — the per-outer-key shape) + /// + /// and forwards to the corresponding rule set. See + /// [`Self::validate_leaf_aggregate_count_on_range`] and + /// [`Self::validate_carrier_aggregate_count_on_range`] for the precise + /// rules in each case. + /// + /// `SizedQuery::limit` / `SizedQuery::offset` checks live at the + /// `PathQuery` / `SizedQuery` layer. + pub fn validate_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { + if self.aggregate_count_on_range().is_some() { + // Owns an ACOR at this level → leaf shape. + self.validate_leaf_aggregate_count_on_range() + } else if self.has_aggregate_count_on_range_anywhere() { + // Doesn't own an ACOR but a nested subquery does → carrier shape. + self.validate_carrier_aggregate_count_on_range() + } else { + Err(Error::InvalidOperation( + "validate_aggregate_count_on_range called on a query without an \ + AggregateCountOnRange item", + )) + } + } + + /// Validates the leaf shape: a query whose single item is + /// `AggregateCountOnRange(_)` and whose surroundings carry no subquery + /// branches. Returns a reference to the inner range `QueryItem`. + /// + /// Rules enforced (matching the constraints documented in the GroveDB + /// book chapter "Aggregate Count Queries"): + /// + /// 1. The query must contain exactly one item. + /// 2. That item must be `AggregateCountOnRange(_)`. + /// 3. The inner item must not be `Key` (use `has_raw` / `get_raw` for + /// existence tests). + /// 4. The inner item must not be `RangeFull` (read the parent + /// `Element::ProvableCountTree` / `Element::ProvableCountSumTree` + /// bytes directly for the unconditional total). + /// 5. The inner item must not itself be `AggregateCountOnRange`. + /// 6. `default_subquery_branch.subquery` and + /// `default_subquery_branch.subquery_path` must both be `None`. + /// 7. `conditional_subquery_branches` must be `None` or empty. + pub fn validate_leaf_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { + if self.items.len() != 1 { + return Err(Error::InvalidOperation( + "AggregateCountOnRange must be the only item in the query", + )); + } + let inner = match &self.items[0] { + QueryItem::AggregateCountOnRange(inner) => inner.as_ref(), + _ => { + return Err(Error::InvalidOperation( + "validate_aggregate_count_on_range called on a query without an \ + AggregateCountOnRange item", + )); + } + }; + match inner { + QueryItem::Key(_) => { + return Err(Error::InvalidOperation( + "AggregateCountOnRange may not wrap Key — use has_raw / get_raw for \ + existence tests", + )); + } + QueryItem::RangeFull(_) => { + return Err(Error::InvalidOperation( + "AggregateCountOnRange may not wrap RangeFull — read the parent \ + ProvableCountTree element for the unconditional total", + )); + } + QueryItem::AggregateCountOnRange(_) => { + return Err(Error::InvalidOperation( + "AggregateCountOnRange may not wrap another AggregateCountOnRange", + )); + } + _ => {} + } + if self.default_subquery_branch.subquery.is_some() + || self.default_subquery_branch.subquery_path.is_some() + { + return Err(Error::InvalidOperation( + "AggregateCountOnRange queries may not carry a default subquery branch", + )); + } + if let Some(branches) = &self.conditional_subquery_branches + && !branches.is_empty() + { + return Err(Error::InvalidOperation( + "AggregateCountOnRange queries may not carry conditional subquery branches", + )); + } + Ok(inner) + } + + /// Validates the carrier shape: an outer query whose items are + /// `Key`/`Range`-like (NOT `AggregateCountOnRange`), and whose + /// `default_subquery_branch.subquery` resolves to a valid leaf ACOR + /// query (possibly after walking a `subquery_path`). + /// + /// Returns a reference to the leaf's inner range `QueryItem` — the + /// same kind of value [`Self::validate_leaf_aggregate_count_on_range`] + /// returns for a leaf-shape query. + /// + /// Rules enforced: + /// 1. Items must be non-empty. + /// 2. Each item must be `Key(_)` or a `Range*(_)` variant — explicitly + /// NOT `AggregateCountOnRange` (those route through the leaf + /// validator) and NOT `RangeFull` (use a leaf ACOR on the parent + /// instead). + /// 3. `default_subquery_branch.subquery` must be `Some(_)`. Its target + /// query must itself validate as a leaf ACOR query. + /// 4. `default_subquery_branch.subquery_path` may be `Some(_)` + /// (typically names the path from each outer-key match to the leaf + /// subtree). When set, every element must be a non-empty key. + /// 5. `conditional_subquery_branches` must be `None` or empty + /// (out of scope for the initial implementation). + pub fn validate_carrier_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { + if self.items.is_empty() { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query must have at least one outer item", + )); + } + for item in &self.items { + match item { + QueryItem::Key(_) + | QueryItem::Range(_) + | QueryItem::RangeInclusive(_) + | QueryItem::RangeFrom(_) + | QueryItem::RangeTo(_) + | QueryItem::RangeToInclusive(_) + | QueryItem::RangeAfter(_) + | QueryItem::RangeAfterTo(_) + | QueryItem::RangeAfterToInclusive(_) => {} + QueryItem::RangeFull(_) => { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query may not have a RangeFull outer item", + )); + } + QueryItem::AggregateCountOnRange(_) => { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query may not own an \ + AggregateCountOnRange item — use the leaf shape instead", + )); + } + } + } + let subquery = match self.default_subquery_branch.subquery.as_deref() { + Some(sub) => sub, + None => { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query must set \ + default_subquery_branch.subquery to a leaf ACOR query", + )); + } + }; + if let Some(path) = &self.default_subquery_branch.subquery_path + && path.iter().any(|k| k.is_empty()) + { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query's subquery_path must contain non-empty keys", + )); + } + if let Some(branches) = &self.conditional_subquery_branches + && !branches.is_empty() + { + return Err(Error::InvalidOperation( + "carrier AggregateCountOnRange query may not carry conditional subquery \ + branches (out of scope for this feature)", + )); + } + // The subquery must validate as a leaf ACOR (which is what the + // proof descent will ultimately consume). + subquery.validate_leaf_aggregate_count_on_range() + } +} + +#[cfg(test)] +mod tests { + use indexmap::IndexMap; + + use crate::{query_item::QueryItem, Query, SubqueryBranch}; + + // ---------- Leaf-ACOR validation tests ---------- + // + // These hit each numbered rule in + // `Query::validate_leaf_aggregate_count_on_range` independently. The + // happy path is also covered to ensure the success arm returns the + // inner range. + + fn make_acor_query(inner: QueryItem) -> Query { + Query::new_aggregate_count_on_range(inner) + } + + #[test] + fn validate_acor_happy_path_returns_inner() { + let q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + let inner = q + .validate_aggregate_count_on_range() + .expect("happy path should validate"); + match inner { + QueryItem::Range(r) => { + assert_eq!(r.start, b"a".to_vec()); + assert_eq!(r.end, b"z".to_vec()); + } + _ => panic!("expected inner Range"), + } + } + + #[test] + fn validate_acor_rejects_extra_items() { + let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + q.items.push(QueryItem::Key(b"extra".to_vec())); + let err = q + .validate_aggregate_count_on_range() + .expect_err("two-item query must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_acor_rejects_non_acor_only_item() { + // A query with one item that isn't AggregateCountOnRange triggers the + // "validate called on a query without an AggregateCountOnRange item" + // branch. + let q = Query::new_single_query_item(QueryItem::Key(b"k".to_vec())); + let err = q + .validate_aggregate_count_on_range() + .expect_err("non-ACOR-only item must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_acor_rejects_inner_key() { + let q = make_acor_query(QueryItem::Key(b"k".to_vec())); + let err = q + .validate_aggregate_count_on_range() + .expect_err("inner Key must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => assert!(msg.contains("Key")), + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_acor_rejects_inner_range_full() { + let q = make_acor_query(QueryItem::RangeFull(std::ops::RangeFull)); + let err = q + .validate_aggregate_count_on_range() + .expect_err("inner RangeFull must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => assert!(msg.contains("RangeFull")), + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_acor_rejects_nested_acor() { + // AggregateCountOnRange wrapping another AggregateCountOnRange. + let inner_acor = QueryItem::AggregateCountOnRange(Box::new(QueryItem::Range( + b"a".to_vec()..b"z".to_vec(), + ))); + let q = make_acor_query(inner_acor); + let err = q + .validate_aggregate_count_on_range() + .expect_err("nested ACOR must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!(msg.contains("AggregateCountOnRange")) + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_acor_rejects_default_subquery_branch() { + let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + q.default_subquery_branch = SubqueryBranch { + subquery_path: None, + subquery: Some(Box::new(Query::new())), + }; + let err = q + .validate_aggregate_count_on_range() + .expect_err("default subquery branch must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => assert!(msg.contains("subquery")), + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_acor_rejects_default_subquery_path() { + let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + q.default_subquery_branch = SubqueryBranch { + subquery_path: Some(vec![b"x".to_vec()]), + subquery: None, + }; + let err = q + .validate_aggregate_count_on_range() + .expect_err("subquery_path must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => assert!(msg.contains("subquery")), + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_acor_rejects_conditional_subquery_branches() { + let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + let mut branches = IndexMap::new(); + branches.insert( + QueryItem::Key(b"k".to_vec()), + SubqueryBranch { + subquery_path: None, + subquery: Some(Box::new(Query::new())), + }, + ); + q.conditional_subquery_branches = Some(branches); + let err = q + .validate_aggregate_count_on_range() + .expect_err("conditional branches must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!(msg.contains("conditional")); + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_acor_accepts_empty_conditional_branches_map() { + // An empty `Some(IndexMap::new())` is treated as "no branches" by the + // validator (the rule enforces non-empty rejection only). + let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + q.conditional_subquery_branches = Some(IndexMap::new()); + let inner = q + .validate_aggregate_count_on_range() + .expect("empty conditional map must validate"); + assert!(matches!(inner, QueryItem::Range(_))); + } + + #[test] + fn aggregate_count_on_range_helper_detects_acor_anywhere_in_items() { + // Well-formed shape — single ACOR item. + let q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + assert!(q.aggregate_count_on_range().is_some()); + + // Two items including ACOR → still detected, so the routing layer + // can hand the malformed query to validate_aggregate_count_on_range + // for a precise error rather than silently treating it as a regular + // query. + let mut q2 = q.clone(); + q2.items.push(QueryItem::Key(b"x".to_vec())); + assert!( + q2.aggregate_count_on_range().is_some(), + "ACOR + extra item must still be detected as ACOR-bearing" + ); + + // ACOR not at index 0 — also detected. + let mut q3 = Query::new_single_query_item(QueryItem::Key(b"x".to_vec())); + q3.items.push(QueryItem::AggregateCountOnRange(Box::new( + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ))); + assert!(q3.aggregate_count_on_range().is_some()); + + // No ACOR anywhere → None. + let q4 = Query::new_single_query_item(QueryItem::Key(b"x".to_vec())); + assert!(q4.aggregate_count_on_range().is_none()); + + // Empty items → None. + let q5 = Query::new(); + assert!(q5.aggregate_count_on_range().is_none()); + } + + #[test] + fn has_aggregate_count_on_range_anywhere_walks_subqueries() { + // No ACOR anywhere → false. + let plain = Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + assert!(!plain.has_aggregate_count_on_range_anywhere()); + + // Top-level ACOR → true (covered by `aggregate_count_on_range` too). + let top = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + assert!(top.has_aggregate_count_on_range_anywhere()); + + // ACOR hidden inside `default_subquery_branch.subquery` — the + // top-level-only `aggregate_count_on_range` would miss it, but the + // recursive helper finds it. This is the surface that the + // prove_query entry-point gate uses to refuse to run any + // ACOR-bearing query that isn't a canonical leaf-or-carrier shape. + let inner_acor = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + let mut hidden = + Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + hidden.set_subquery(inner_acor); + assert!(hidden.aggregate_count_on_range().is_none()); + assert!( + hidden.has_aggregate_count_on_range_anywhere(), + "ACOR hidden in default subquery branch must be detected" + ); + + // ACOR hidden in a conditional subquery branch. + let inner_acor2 = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + let mut conditional = + Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + conditional.add_conditional_subquery( + QueryItem::Key(b"k".to_vec()), + None, + Some(inner_acor2), + ); + assert!( + conditional.has_aggregate_count_on_range_anywhere(), + "ACOR hidden in conditional subquery branch must be detected" + ); + } + + // ---------- Carrier ACOR validation tests ---------- + // + // The carrier shape is an outer query with `Key`/`Range*` items whose + // `default_subquery_branch.subquery` resolves to a leaf ACOR query. + // It is the multi-outer-key extension of the leaf shape, returning one + // count per outer key. These tests verify the + // `validate_carrier_aggregate_count_on_range` rules and the dispatcher + // behavior of the top-level `validate_aggregate_count_on_range`. + + fn make_leaf_acor_subquery() -> Query { + make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())) + } + + #[test] + fn validate_carrier_acor_happy_path_keys_outer_with_subquery_path() { + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"brand_000".to_vec())); + carrier.items.push(QueryItem::Key(b"brand_001".to_vec())); + carrier.set_subquery_path(vec![b"color".to_vec()]); + carrier.set_subquery(make_leaf_acor_subquery()); + // Top-level dispatcher accepts the carrier and returns the leaf's + // inner range. + let inner = carrier + .validate_aggregate_count_on_range() + .expect("carrier should validate"); + assert!(matches!(inner, QueryItem::Range(_))); + // And the dedicated carrier validator agrees. + carrier + .validate_carrier_aggregate_count_on_range() + .expect("carrier validator should accept"); + // Leaf validator must reject (carrier-level items aren't ACOR). + assert!(carrier.validate_leaf_aggregate_count_on_range().is_err()); + } + + #[test] + fn validate_carrier_acor_happy_path_no_subquery_path() { + // subquery_path is optional — the leaf ACOR may be directly under + // each outer match. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"a".to_vec())); + carrier.set_subquery(make_leaf_acor_subquery()); + carrier + .validate_aggregate_count_on_range() + .expect("carrier without subquery_path should validate"); + } + + #[test] + fn validate_carrier_acor_rejects_acor_at_both_levels() { + // Carrier itself owns an ACOR AND its subquery is also an ACOR. + // The top-level dispatcher routes to the LEAF validator first + // (because aggregate_count_on_range() returns Some at carrier + // level), so the leaf's "single item" rule catches the + // ACOR-in-subquery shape via the items-len check or the + // no-subquery rule. Either way the error fires. + let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + q.set_subquery(make_leaf_acor_subquery()); + let err = q + .validate_aggregate_count_on_range() + .expect_err("ACOR at both levels must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!( + msg.contains("AggregateCountOnRange") || msg.contains("subquery"), + "unexpected message: {msg}" + ); + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_rejects_range_full_outer() { + let mut carrier = Query::new(); + carrier + .items + .push(QueryItem::RangeFull(std::ops::RangeFull)); + carrier.set_subquery(make_leaf_acor_subquery()); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("RangeFull outer must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!(msg.contains("RangeFull"), "unexpected message: {msg}"); + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_rejects_acor_outer_item() { + // Both a Key and an AggregateCountOnRange item at the carrier + // level. The leaf validator's items-len check fires first (since + // there's an ACOR item in items, aggregate_count_on_range() + // returns Some, and len != 1). + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + carrier + .items + .push(QueryItem::AggregateCountOnRange(Box::new( + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ))); + carrier.set_subquery(make_leaf_acor_subquery()); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("ACOR + Key outer items must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_carrier_acor_rejects_carrier_with_missing_subquery() { + // Outer items present but no subquery → not a carrier (and not a + // leaf), so the top-level dispatcher routes to the + // "not an ACOR query" error. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("carrier without subquery must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_carrier_acor_rejects_non_acor_subquery() { + // Outer Keys + subquery that is NOT an ACOR (just a regular range + // query) → not a valid carrier ACOR. The top-level dispatcher + // sees `has_aggregate_count_on_range_anywhere() == false`, so it + // surfaces the "not an ACOR query" error rather than the carrier + // validator's "subquery must validate as leaf ACOR" error. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + let regular_sub = + Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + carrier.set_subquery(regular_sub); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("non-ACOR subquery must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_carrier_acor_rejects_conditional_branches() { + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + carrier.set_subquery(make_leaf_acor_subquery()); + carrier.add_conditional_subquery( + QueryItem::Key(b"k".to_vec()), + None, + Some(make_leaf_acor_subquery()), + ); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("carrier conditional branches must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!(msg.contains("conditional"), "unexpected message: {msg}") + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_rejects_empty_outer_items() { + // Empty items + leaf ACOR subquery → not a valid carrier. + // (Empty outer means no outer key to iterate; doesn't make sense.) + let mut carrier = Query::new(); + carrier.set_subquery(make_leaf_acor_subquery()); + let err = carrier + .validate_carrier_aggregate_count_on_range() + .expect_err("empty outer items must fail"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + + #[test] + fn validate_carrier_acor_rejects_carrier_subquery_with_invalid_inner() { + // The carrier validator delegates to the leaf validator for the + // subquery, so a malformed leaf ACOR (e.g. wrapping `Key`) is + // surfaced via the carrier path. Pin the exact rejection message + // so a refactor that re-routes the rejection through a different + // arm doesn't silently accept the malformed shape. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + carrier.set_subquery(make_acor_query(QueryItem::Key(b"k".to_vec()))); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("malformed inner Key in subquery ACOR must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => assert!( + msg.contains("may not wrap Key"), + "unexpected message: {msg}" + ), + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_rejects_empty_subquery_path_element() { + // A carrier's subquery_path may not contain empty keys — those + // would point at "no key" in the intermediate descent, which the + // merk single-key prover can't satisfy. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::Key(b"k".to_vec())); + carrier.set_subquery_path(vec![b"".to_vec()]); + carrier.set_subquery(make_leaf_acor_subquery()); + let err = carrier + .validate_aggregate_count_on_range() + .expect_err("empty subquery_path key must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!(msg.contains("non-empty keys"), "unexpected message: {msg}") + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_accepts_range_outer_items() { + // A carrier may use Range outer items (the spec leaves room for + // this). Verify the validator agrees for every Range* variant + // the rule whitelists. + for outer in [ + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + QueryItem::RangeInclusive(b"a".to_vec()..=b"z".to_vec()), + QueryItem::RangeFrom(b"a".to_vec()..), + QueryItem::RangeTo(..b"z".to_vec()), + QueryItem::RangeToInclusive(..=b"z".to_vec()), + QueryItem::RangeAfter(b"a".to_vec()..), + QueryItem::RangeAfterTo(b"a".to_vec()..b"z".to_vec()), + QueryItem::RangeAfterToInclusive(b"a".to_vec()..=b"z".to_vec()), + ] { + let mut carrier = Query::new(); + carrier.items.push(outer); + carrier.set_subquery(make_leaf_acor_subquery()); + carrier + .validate_aggregate_count_on_range() + .expect("carrier with Range* outer should validate"); + } + } + + #[test] + fn validate_acor_dispatcher_rejects_non_acor_query() { + // The top-level dispatcher returns the "not an ACOR" error when + // neither shape matches. + let q = Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + let err = q + .validate_aggregate_count_on_range() + .expect_err("non-ACOR query must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => assert!(msg.contains( + "validate_aggregate_count_on_range called on a query \ + without an AggregateCountOnRange item" + )), + _ => panic!("expected InvalidOperation"), + } + } +} diff --git a/grovedb-query/src/lib.rs b/grovedb-query/src/lib.rs index 3bc3f834d..69605f03f 100644 --- a/grovedb-query/src/lib.rs +++ b/grovedb-query/src/lib.rs @@ -9,6 +9,11 @@ /// Error types for query operations. pub mod error; +/// `AggregateCountOnRange` (ACOR) construction and validation. The +/// shape, detection, and leaf/carrier rule sets live here; the proof +/// generation and verification live in the `grovedb` crate. +mod aggregate_count; + /// Aggregate sum query for sum-up-to style queries. pub mod aggregate_sum_query; diff --git a/grovedb-query/src/query.rs b/grovedb-query/src/query.rs index cb7c87f59..2ab979994 100644 --- a/grovedb-query/src/query.rs +++ b/grovedb-query/src/query.rs @@ -303,261 +303,6 @@ impl Query { } } - /// Creates an aggregate-count-on-range query that counts the elements - /// matched by `range`. The resulting query has `AggregateCountOnRange(range)` - /// as its sole item, no subquery branches, and `left_to_right = true` - /// (counting is direction-agnostic). - /// - /// `range` must be a true range variant (`Range`, `RangeInclusive`, - /// `RangeFrom`, `RangeTo`, `RangeToInclusive`, `RangeAfter`, `RangeAfterTo`, - /// or `RangeAfterToInclusive`). Passing `Key`, `RangeFull`, or another - /// `AggregateCountOnRange` is allowed at construction time but will be - /// rejected by [`validate_aggregate_count_on_range`]. - pub fn new_aggregate_count_on_range(range: QueryItem) -> Self { - Self { - items: vec![QueryItem::AggregateCountOnRange(Box::new(range))], - left_to_right: true, - ..Self::default() - } - } - - /// If this query contains an `AggregateCountOnRange` item *anywhere* in - /// its `items` vec, returns a reference to the first such item (whether - /// the surrounding query is well-formed or not). Returns `None` only - /// when no item is an `AggregateCountOnRange`. - /// - /// This is intentionally a **detection-only** helper: malformed queries - /// like `items: [Key(...), AggregateCountOnRange(...)]` still report - /// `Some(...)` here so callers don't accidentally route them through - /// the regular-query path. Use - /// [`Self::validate_aggregate_count_on_range`] when you also need to - /// enforce the well-formedness rules (single item, allowed inner kind, - /// no subqueries, etc.). - pub fn aggregate_count_on_range(&self) -> Option<&QueryItem> { - self.items - .iter() - .find(|item| item.is_aggregate_count_on_range()) - } - - /// Returns `true` if any item in this query — including items inside - /// nested subquery branches — is an `AggregateCountOnRange`. - /// - /// `AggregateCountOnRange` is a *terminal* item: the canonical - /// well-formed query contains exactly one `AggregateCountOnRange` at - /// the top level and nothing else. This recursive detector exists so - /// the prover can validate up front: if any ACOR is present anywhere, - /// the query as a whole must satisfy - /// [`Self::validate_aggregate_count_on_range`] — otherwise a malformed - /// shape (e.g. ACOR hidden inside `default_subquery_branch.subquery`) - /// could slip past a top-level-only check and be silently routed - /// through the regular-proof path. - pub fn has_aggregate_count_on_range_anywhere(&self) -> bool { - if self.aggregate_count_on_range().is_some() { - return true; - } - if let Some(sub) = self.default_subquery_branch.subquery.as_deref() - && sub.has_aggregate_count_on_range_anywhere() - { - return true; - } - if let Some(branches) = &self.conditional_subquery_branches { - for branch in branches.values() { - if let Some(sub) = branch.subquery.as_deref() - && sub.has_aggregate_count_on_range_anywhere() - { - return true; - } - } - } - false - } - - /// Validates the Query-level constraints that apply when an - /// `AggregateCountOnRange` is present. On success, returns a reference - /// to the inner range `QueryItem` describing the keys being counted - /// (the same item regardless of whether the surrounding query is the - /// leaf shape or the carrier shape). - /// - /// Top-level dispatcher: classifies the query as either - /// - **leaf** (the query owns an `AggregateCountOnRange` item directly — - /// the original single-`u64` shape), or - /// - **carrier** (the query is an outer fan-out of `Key`/`Range` items - /// whose `default_subquery_branch.subquery` resolves to a leaf - /// `AggregateCountOnRange` — the new per-outer-key shape) - /// - /// and forwards to the corresponding rule set. See - /// [`Self::validate_leaf_aggregate_count_on_range`] and - /// [`Self::validate_carrier_aggregate_count_on_range`] for the precise - /// rules in each case. - /// - /// `SizedQuery::limit` / `SizedQuery::offset` checks live at the - /// `PathQuery` / `SizedQuery` layer. - pub fn validate_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { - if self.aggregate_count_on_range().is_some() { - // Owns an ACOR at this level → leaf shape. - self.validate_leaf_aggregate_count_on_range() - } else if self.has_aggregate_count_on_range_anywhere() { - // Doesn't own an ACOR but a nested subquery does → carrier shape. - self.validate_carrier_aggregate_count_on_range() - } else { - Err(Error::InvalidOperation( - "validate_aggregate_count_on_range called on a query without an \ - AggregateCountOnRange item", - )) - } - } - - /// Validates the leaf shape: a query whose single item is - /// `AggregateCountOnRange(_)` and whose surroundings carry no subquery - /// branches. Returns a reference to the inner range `QueryItem`. - /// - /// Rules enforced (matching the constraints documented in the GroveDB - /// book chapter "Aggregate Count Queries"): - /// - /// 1. The query must contain exactly one item. - /// 2. That item must be `AggregateCountOnRange(_)`. - /// 3. The inner item must not be `Key` (use `has_raw` / `get_raw` for - /// existence tests). - /// 4. The inner item must not be `RangeFull` (read the parent - /// `Element::ProvableCountTree` / `Element::ProvableCountSumTree` - /// bytes directly for the unconditional total). - /// 5. The inner item must not itself be `AggregateCountOnRange`. - /// 6. `default_subquery_branch.subquery` and - /// `default_subquery_branch.subquery_path` must both be `None`. - /// 7. `conditional_subquery_branches` must be `None` or empty. - pub fn validate_leaf_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { - if self.items.len() != 1 { - return Err(Error::InvalidOperation( - "AggregateCountOnRange must be the only item in the query", - )); - } - let inner = match &self.items[0] { - QueryItem::AggregateCountOnRange(inner) => inner.as_ref(), - _ => { - return Err(Error::InvalidOperation( - "validate_aggregate_count_on_range called on a query without an \ - AggregateCountOnRange item", - )); - } - }; - match inner { - QueryItem::Key(_) => { - return Err(Error::InvalidOperation( - "AggregateCountOnRange may not wrap Key — use has_raw / get_raw for \ - existence tests", - )); - } - QueryItem::RangeFull(_) => { - return Err(Error::InvalidOperation( - "AggregateCountOnRange may not wrap RangeFull — read the parent \ - ProvableCountTree element for the unconditional total", - )); - } - QueryItem::AggregateCountOnRange(_) => { - return Err(Error::InvalidOperation( - "AggregateCountOnRange may not wrap another AggregateCountOnRange", - )); - } - _ => {} - } - if self.default_subquery_branch.subquery.is_some() - || self.default_subquery_branch.subquery_path.is_some() - { - return Err(Error::InvalidOperation( - "AggregateCountOnRange queries may not carry a default subquery branch", - )); - } - if let Some(branches) = &self.conditional_subquery_branches - && !branches.is_empty() - { - return Err(Error::InvalidOperation( - "AggregateCountOnRange queries may not carry conditional subquery branches", - )); - } - Ok(inner) - } - - /// Validates the carrier shape: an outer query whose items are - /// `Key`/`Range`-like (NOT `AggregateCountOnRange`), and whose - /// `default_subquery_branch.subquery` resolves to a valid leaf ACOR - /// query (possibly after walking a `subquery_path`). - /// - /// Returns a reference to the leaf's inner range `QueryItem` — the - /// same kind of value [`Self::validate_leaf_aggregate_count_on_range`] - /// returns for a leaf-shape query. - /// - /// Rules enforced: - /// 1. Items must be non-empty. - /// 2. Each item must be `Key(_)` or a `Range*(_)` variant — explicitly - /// NOT `AggregateCountOnRange` (those route through the leaf - /// validator) and NOT `RangeFull` (use a leaf ACOR on the parent - /// instead). - /// 3. `default_subquery_branch.subquery` must be `Some(_)`. Its target - /// query must itself validate as a leaf ACOR query. - /// 4. `default_subquery_branch.subquery_path` may be `Some(_)` - /// (typically names the path from each outer-key match to the leaf - /// subtree). When set, every element must be a non-empty key. - /// 5. `conditional_subquery_branches` must be `None` or empty - /// (out of scope for the initial implementation). - pub fn validate_carrier_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { - if self.items.is_empty() { - return Err(Error::InvalidOperation( - "carrier AggregateCountOnRange query must have at least one outer item", - )); - } - for item in &self.items { - match item { - QueryItem::Key(_) - | QueryItem::Range(_) - | QueryItem::RangeInclusive(_) - | QueryItem::RangeFrom(_) - | QueryItem::RangeTo(_) - | QueryItem::RangeToInclusive(_) - | QueryItem::RangeAfter(_) - | QueryItem::RangeAfterTo(_) - | QueryItem::RangeAfterToInclusive(_) => {} - QueryItem::RangeFull(_) => { - return Err(Error::InvalidOperation( - "carrier AggregateCountOnRange query may not have a RangeFull outer item", - )); - } - QueryItem::AggregateCountOnRange(_) => { - return Err(Error::InvalidOperation( - "carrier AggregateCountOnRange query may not own an \ - AggregateCountOnRange item — use the leaf shape instead", - )); - } - } - } - let subquery = match self.default_subquery_branch.subquery.as_deref() { - Some(sub) => sub, - None => { - return Err(Error::InvalidOperation( - "carrier AggregateCountOnRange query must set \ - default_subquery_branch.subquery to a leaf ACOR query", - )); - } - }; - if let Some(path) = &self.default_subquery_branch.subquery_path - && path.iter().any(|k| k.is_empty()) - { - return Err(Error::InvalidOperation( - "carrier AggregateCountOnRange query's subquery_path must contain non-empty keys", - )); - } - if let Some(branches) = &self.conditional_subquery_branches - && !branches.is_empty() - { - return Err(Error::InvalidOperation( - "carrier AggregateCountOnRange query may not carry conditional subquery \ - branches (out of scope for this feature)", - )); - } - // The subquery must validate as a leaf ACOR (which is what the - // proof descent will ultimately consume). - subquery.validate_leaf_aggregate_count_on_range() - } - /// Returns `true` if the given key would trigger a subquery (either via /// the default subquery branch or a matching conditional branch). pub fn has_subquery_on_key(&self, key: &[u8], in_path: bool) -> bool { @@ -1162,418 +907,4 @@ mod tests { "innermost query should have no further subquery" ); } - - // ---------- AggregateCountOnRange validation tests ---------- - // - // These hit each numbered rule in `Query::validate_aggregate_count_on_range` - // independently. The happy path is also covered to ensure the success - // arm returns the inner range. - - fn make_acor_query(inner: QueryItem) -> Query { - Query::new_aggregate_count_on_range(inner) - } - - #[test] - fn validate_acor_happy_path_returns_inner() { - let q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - let inner = q - .validate_aggregate_count_on_range() - .expect("happy path should validate"); - match inner { - QueryItem::Range(r) => { - assert_eq!(r.start, b"a".to_vec()); - assert_eq!(r.end, b"z".to_vec()); - } - _ => panic!("expected inner Range"), - } - } - - #[test] - fn validate_acor_rejects_extra_items() { - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - q.items.push(QueryItem::Key(b"extra".to_vec())); - let err = q - .validate_aggregate_count_on_range() - .expect_err("two-item query must fail"); - assert!(matches!(err, crate::error::Error::InvalidOperation(_))); - } - - #[test] - fn validate_acor_rejects_non_acor_only_item() { - // A query with one item that isn't AggregateCountOnRange triggers the - // "validate called on a query without an AggregateCountOnRange item" - // branch. - let q = Query::new_single_query_item(QueryItem::Key(b"k".to_vec())); - let err = q - .validate_aggregate_count_on_range() - .expect_err("non-ACOR-only item must fail"); - assert!(matches!(err, crate::error::Error::InvalidOperation(_))); - } - - #[test] - fn validate_acor_rejects_inner_key() { - let q = make_acor_query(QueryItem::Key(b"k".to_vec())); - let err = q - .validate_aggregate_count_on_range() - .expect_err("inner Key must fail"); - match err { - crate::error::Error::InvalidOperation(msg) => assert!(msg.contains("Key")), - _ => panic!("expected InvalidOperation"), - } - } - - #[test] - fn validate_acor_rejects_inner_range_full() { - let q = make_acor_query(QueryItem::RangeFull(std::ops::RangeFull)); - let err = q - .validate_aggregate_count_on_range() - .expect_err("inner RangeFull must fail"); - match err { - crate::error::Error::InvalidOperation(msg) => assert!(msg.contains("RangeFull")), - _ => panic!("expected InvalidOperation"), - } - } - - #[test] - fn validate_acor_rejects_nested_acor() { - // AggregateCountOnRange wrapping another AggregateCountOnRange. - let inner_acor = QueryItem::AggregateCountOnRange(Box::new(QueryItem::Range( - b"a".to_vec()..b"z".to_vec(), - ))); - let q = make_acor_query(inner_acor); - let err = q - .validate_aggregate_count_on_range() - .expect_err("nested ACOR must fail"); - match err { - crate::error::Error::InvalidOperation(msg) => { - assert!(msg.contains("AggregateCountOnRange")) - } - _ => panic!("expected InvalidOperation"), - } - } - - #[test] - fn validate_acor_rejects_default_subquery_branch() { - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - q.default_subquery_branch = SubqueryBranch { - subquery_path: None, - subquery: Some(Box::new(Query::new())), - }; - let err = q - .validate_aggregate_count_on_range() - .expect_err("default subquery branch must fail"); - match err { - crate::error::Error::InvalidOperation(msg) => assert!(msg.contains("subquery")), - _ => panic!("expected InvalidOperation"), - } - } - - #[test] - fn validate_acor_rejects_default_subquery_path() { - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - q.default_subquery_branch = SubqueryBranch { - subquery_path: Some(vec![b"x".to_vec()]), - subquery: None, - }; - let err = q - .validate_aggregate_count_on_range() - .expect_err("subquery_path must fail"); - match err { - crate::error::Error::InvalidOperation(msg) => assert!(msg.contains("subquery")), - _ => panic!("expected InvalidOperation"), - } - } - - #[test] - fn validate_acor_rejects_conditional_subquery_branches() { - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - let mut branches = IndexMap::new(); - branches.insert( - QueryItem::Key(b"k".to_vec()), - SubqueryBranch { - subquery_path: None, - subquery: Some(Box::new(Query::new())), - }, - ); - q.conditional_subquery_branches = Some(branches); - let err = q - .validate_aggregate_count_on_range() - .expect_err("conditional branches must fail"); - match err { - crate::error::Error::InvalidOperation(msg) => { - assert!(msg.contains("conditional")); - } - _ => panic!("expected InvalidOperation"), - } - } - - #[test] - fn validate_acor_accepts_empty_conditional_branches_map() { - // An empty `Some(IndexMap::new())` is treated as "no branches" by the - // validator (the rule enforces non-empty rejection only). - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - q.conditional_subquery_branches = Some(IndexMap::new()); - let inner = q - .validate_aggregate_count_on_range() - .expect("empty conditional map must validate"); - assert!(matches!(inner, QueryItem::Range(_))); - } - - #[test] - fn aggregate_count_on_range_helper_detects_acor_anywhere_in_items() { - // Well-formed shape — single ACOR item. - let q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - assert!(q.aggregate_count_on_range().is_some()); - - // Two items including ACOR → still detected, so the routing layer - // can hand the malformed query to validate_aggregate_count_on_range - // for a precise error rather than silently treating it as a regular - // query. - let mut q2 = q.clone(); - q2.items.push(QueryItem::Key(b"x".to_vec())); - assert!( - q2.aggregate_count_on_range().is_some(), - "ACOR + extra item must still be detected as ACOR-bearing" - ); - - // ACOR not at index 0 — also detected. - let mut q3 = Query::new_single_query_item(QueryItem::Key(b"x".to_vec())); - q3.items.push(QueryItem::AggregateCountOnRange(Box::new( - QueryItem::Range(b"a".to_vec()..b"z".to_vec()), - ))); - assert!(q3.aggregate_count_on_range().is_some()); - - // No ACOR anywhere → None. - let q4 = Query::new_single_query_item(QueryItem::Key(b"x".to_vec())); - assert!(q4.aggregate_count_on_range().is_none()); - - // Empty items → None. - let q5 = Query::new(); - assert!(q5.aggregate_count_on_range().is_none()); - } - - #[test] - fn has_aggregate_count_on_range_anywhere_walks_subqueries() { - // No ACOR anywhere → false. - let plain = Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - assert!(!plain.has_aggregate_count_on_range_anywhere()); - - // Top-level ACOR → true (covered by `aggregate_count_on_range` too). - let top = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - assert!(top.has_aggregate_count_on_range_anywhere()); - - // ACOR hidden inside `default_subquery_branch.subquery` — the - // top-level-only `aggregate_count_on_range` would miss it, but the - // recursive helper finds it. This is the surface that the - // prove_query entry-point gate uses to refuse to run any - // ACOR-bearing query that isn't the canonical single-ACOR shape. - let inner_acor = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - let mut hidden = - Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - hidden.set_subquery(inner_acor); - assert!(hidden.aggregate_count_on_range().is_none()); - assert!( - hidden.has_aggregate_count_on_range_anywhere(), - "ACOR hidden in default subquery branch must be detected" - ); - - // ACOR hidden in a conditional subquery branch. - let inner_acor2 = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - let mut conditional = - Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - conditional.add_conditional_subquery( - QueryItem::Key(b"k".to_vec()), - None, - Some(inner_acor2), - ); - assert!( - conditional.has_aggregate_count_on_range_anywhere(), - "ACOR hidden in conditional subquery branch must be detected" - ); - } - - // ---------- Carrier ACOR validation tests ---------- - // - // The carrier shape is an outer query with `Key`/`Range*` items whose - // `default_subquery_branch.subquery` resolves to a leaf ACOR query. - // It is the multi-outer-key extension of the leaf shape, returning one - // count per outer key. These tests verify the new - // `validate_carrier_aggregate_count_on_range` rules and the dispatcher - // behavior of the top-level `validate_aggregate_count_on_range`. - - fn make_leaf_acor_subquery() -> Query { - make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())) - } - - #[test] - fn validate_carrier_acor_happy_path_keys_outer_with_subquery_path() { - let mut carrier = Query::new(); - carrier.items.push(QueryItem::Key(b"brand_000".to_vec())); - carrier.items.push(QueryItem::Key(b"brand_001".to_vec())); - carrier.set_subquery_path(vec![b"color".to_vec()]); - carrier.set_subquery(make_leaf_acor_subquery()); - // Top-level dispatcher accepts the carrier and returns the leaf's - // inner range. - let inner = carrier - .validate_aggregate_count_on_range() - .expect("carrier should validate"); - assert!(matches!(inner, QueryItem::Range(_))); - // And the dedicated carrier validator agrees. - carrier - .validate_carrier_aggregate_count_on_range() - .expect("carrier validator should accept"); - // Leaf validator must reject (carrier-level items aren't ACOR). - assert!(carrier.validate_leaf_aggregate_count_on_range().is_err()); - } - - #[test] - fn validate_carrier_acor_happy_path_no_subquery_path() { - // subquery_path is optional — the leaf ACOR may be directly under - // each outer match. - let mut carrier = Query::new(); - carrier.items.push(QueryItem::Key(b"a".to_vec())); - carrier.set_subquery(make_leaf_acor_subquery()); - carrier - .validate_aggregate_count_on_range() - .expect("carrier without subquery_path should validate"); - } - - #[test] - fn validate_carrier_acor_rejects_acor_at_both_levels() { - // Carrier itself owns an ACOR AND its subquery is also an ACOR. - // The "rule" of "no ACOR at carrier level" must fire — but the - // top-level dispatcher routes this to the LEAF validator first - // (because aggregate_count_on_range() returns Some at carrier - // level), so the leaf's "single item" rule catches the extra - // ACOR-in-subquery shape via the items-len check or the - // no-subquery rule. Either way the error fires. - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - q.set_subquery(make_leaf_acor_subquery()); - let err = q - .validate_aggregate_count_on_range() - .expect_err("ACOR at both levels must fail"); - match err { - crate::error::Error::InvalidOperation(msg) => { - assert!( - msg.contains("AggregateCountOnRange") || msg.contains("subquery"), - "unexpected message: {msg}" - ); - } - _ => panic!("expected InvalidOperation"), - } - } - - #[test] - fn validate_carrier_acor_rejects_range_full_outer() { - let mut carrier = Query::new(); - carrier - .items - .push(QueryItem::RangeFull(std::ops::RangeFull)); - carrier.set_subquery(make_leaf_acor_subquery()); - let err = carrier - .validate_aggregate_count_on_range() - .expect_err("RangeFull outer must fail"); - match err { - crate::error::Error::InvalidOperation(msg) => { - assert!(msg.contains("RangeFull"), "unexpected message: {msg}"); - } - _ => panic!("expected InvalidOperation"), - } - } - - #[test] - fn validate_carrier_acor_rejects_acor_outer_item() { - // Both a Key and an AggregateCountOnRange item at the carrier - // level. The leaf validator's items-len check fires first (since - // there's an ACOR item in items, aggregate_count_on_range() - // returns Some, and len != 1). - let mut carrier = Query::new(); - carrier.items.push(QueryItem::Key(b"k".to_vec())); - carrier - .items - .push(QueryItem::AggregateCountOnRange(Box::new( - QueryItem::Range(b"a".to_vec()..b"z".to_vec()), - ))); - carrier.set_subquery(make_leaf_acor_subquery()); - let err = carrier - .validate_aggregate_count_on_range() - .expect_err("ACOR + Key outer items must fail"); - assert!(matches!(err, crate::error::Error::InvalidOperation(_))); - } - - #[test] - fn validate_carrier_acor_rejects_carrier_with_missing_subquery() { - // Outer items present but no subquery → not a carrier (and not a - // leaf), so the top-level dispatcher routes to the - // "not an ACOR query" error. - let mut carrier = Query::new(); - carrier.items.push(QueryItem::Key(b"k".to_vec())); - let err = carrier - .validate_aggregate_count_on_range() - .expect_err("carrier without subquery must fail"); - assert!(matches!(err, crate::error::Error::InvalidOperation(_))); - } - - #[test] - fn validate_carrier_acor_rejects_non_acor_subquery() { - // Outer Keys + subquery that is NOT an ACOR (just a regular range - // query) → not a valid carrier ACOR. The top-level dispatcher - // sees `has_aggregate_count_on_range_anywhere() == false`, so it - // surfaces the "not an ACOR query" error rather than the carrier - // validator's "subquery must validate as leaf ACOR" error. - let mut carrier = Query::new(); - carrier.items.push(QueryItem::Key(b"k".to_vec())); - let regular_sub = - Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - carrier.set_subquery(regular_sub); - let err = carrier - .validate_aggregate_count_on_range() - .expect_err("non-ACOR subquery must fail"); - assert!(matches!(err, crate::error::Error::InvalidOperation(_))); - } - - #[test] - fn validate_carrier_acor_rejects_conditional_branches() { - let mut carrier = Query::new(); - carrier.items.push(QueryItem::Key(b"k".to_vec())); - carrier.set_subquery(make_leaf_acor_subquery()); - carrier.add_conditional_subquery( - QueryItem::Key(b"k".to_vec()), - None, - Some(make_leaf_acor_subquery()), - ); - let err = carrier - .validate_aggregate_count_on_range() - .expect_err("carrier conditional branches must fail"); - match err { - crate::error::Error::InvalidOperation(msg) => { - assert!(msg.contains("conditional"), "unexpected message: {msg}") - } - _ => panic!("expected InvalidOperation"), - } - } - - #[test] - fn validate_carrier_acor_rejects_empty_outer_items() { - // Empty items + leaf ACOR subquery → not a valid carrier. - // (Empty outer means no outer key to iterate; doesn't make sense.) - let mut carrier = Query::new(); - carrier.set_subquery(make_leaf_acor_subquery()); - let err = carrier - .validate_carrier_aggregate_count_on_range() - .expect_err("empty outer items must fail"); - assert!(matches!(err, crate::error::Error::InvalidOperation(_))); - } - - #[test] - fn validate_carrier_acor_accepts_range_outer_items() { - // A carrier may use Range outer items (the spec leaves room for - // this). Verify the validator agrees. - let mut carrier = Query::new(); - carrier.items.push(QueryItem::RangeAfter(b"a".to_vec()..)); - carrier.set_subquery(make_leaf_acor_subquery()); - carrier - .validate_aggregate_count_on_range() - .expect("carrier with Range outer should validate"); - } } diff --git a/grovedb/src/lib.rs b/grovedb/src/lib.rs index 1d1b91fab..127503a5e 100644 --- a/grovedb/src/lib.rs +++ b/grovedb/src/lib.rs @@ -242,8 +242,6 @@ use grovedb_version::version::GroveVersion; #[cfg(feature = "minimal")] use grovedb_visualize::DebugByteVectors; #[cfg(any(feature = "minimal", feature = "verify"))] -pub(crate) use query::query_validation_error_to_invalid_query; -#[cfg(any(feature = "minimal", feature = "verify"))] pub use query::{ aggregate_sum_path_query::AggregateSumPathQuery, GroveBranchQueryResult, GroveTrunkQueryResult, LeafInfo, PathBranchChunkQuery, PathQuery, PathTrunkChunkQuery, SizedQuery, diff --git a/grovedb/src/operations/proof/aggregate_count.rs b/grovedb/src/operations/proof/aggregate_count.rs index 6ff2109e2..6ac13a5c4 100644 --- a/grovedb/src/operations/proof/aggregate_count.rs +++ b/grovedb/src/operations/proof/aggregate_count.rs @@ -92,12 +92,10 @@ impl GroveDb { .verify_query_with_options ); - let inner_range = path_query - .query - .query - .validate_leaf_aggregate_count_on_range() - .map_err(crate::query_validation_error_to_invalid_query)? - .clone(); + // Validate at the PathQuery level so SizedQuery::limit / offset + // (which ACOR explicitly forbids) are enforced alongside the + // inner-Query shape rules. + let inner_range = path_query.validate_leaf_aggregate_count_on_range()?.clone(); let grovedb_proof = decode_grovedb_proof(proof)?; let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); @@ -137,10 +135,13 @@ impl GroveDb { /// `Range*(_)`, the `default_subquery_branch.subquery` must validate as a /// leaf ACOR, and the optional `subquery_path` is followed exactly /// (single-key descent per element) before the count proof. The returned - /// vector has one entry per matched outer key in ascending lexicographic - /// order. Outer-key candidates that the prover proved as absent - /// contribute no entry; outer-key candidates that resolve to an - /// **empty** leaf subtree return `count = 0`. + /// vector has one entry per matched outer key in **query-direction + /// order**: when the carrier's `left_to_right` is `true` (the default, + /// matching the merk prover's natural walk) entries come back in + /// ascending lexicographic key order; when `left_to_right` is `false` + /// they come back in descending order, mirroring the merk proof's own + /// emission order. Outer-key candidates that the prover proved as + /// absent contribute no entry. /// /// Cryptographic guarantees: /// - Every layer is committed via the same `combine_hash(H(value), @@ -211,30 +212,27 @@ struct AcorClassification { } fn classify_path_query(path_query: &PathQuery) -> Result { + // Validate at the PathQuery level so SizedQuery::limit / offset + // (which ACOR explicitly forbids) are enforced alongside the + // inner-Query shape rules — for both the leaf and the carrier branch + // below. + let leaf_inner = path_query.validate_aggregate_count_on_range()?.clone(); let q = &path_query.query.query; if q.aggregate_count_on_range().is_some() { - // Leaf shape: top-level ACOR item. - let inner = q - .validate_leaf_aggregate_count_on_range() - .map_err(crate::query_validation_error_to_invalid_query)? - .clone(); + // Leaf shape: top-level ACOR item. The top-level + // `validate_aggregate_count_on_range` dispatcher above routed + // through the leaf validator, so we already know `leaf_inner` is + // the inner range of the top-level ACOR item. return Ok(AcorClassification { - leaf_inner_range: inner, + leaf_inner_range: leaf_inner, carrier_outer_items: None, carrier_subquery_path: None, carrier_left_to_right: true, }); } - if !q.has_aggregate_count_on_range_anywhere() { - return Err(Error::InvalidQuery( - "verify_aggregate_count_query_per_key called on a non-ACOR path query", - )); - } - // Carrier shape. - let leaf_inner = q - .validate_carrier_aggregate_count_on_range() - .map_err(crate::query_validation_error_to_invalid_query)? - .clone(); + // Carrier shape: validation above routed through the carrier + // validator, so `leaf_inner` is the *subquery's* inner range. We just + // need to extract the outer items and the optional subquery_path. let outer_items = q.items.clone(); let subquery_path = q .default_subquery_branch @@ -358,6 +356,16 @@ fn verify_v1_leaf_chain( // ── per-key entry-point traversal (leaf or carrier) ──────────────────────── +/// V0 per-key dispatch: the V0 envelope (`MerkOnlyLayerProof`) is the +/// legacy proof format used only by older grove versions that pre-date +/// the carrier-ACOR feature. The prover for those versions never emits a +/// carrier-shaped proof, so V0 per-key is a strict alias for the +/// existing leaf-only chain — collapsed into a one-entry result vector. +/// +/// Carrier-shaped path queries paired with a V0 envelope are rejected +/// up front so a forged envelope can't sneak past the leaf chain and +/// have its multi-key merk proof reinterpreted as a single-key count +/// proof. fn verify_v0_with_classification( layer: &MerkOnlyLayerProof, path_query: &PathQuery, @@ -365,183 +373,23 @@ fn verify_v0_with_classification( classification: &AcorClassification, grove_version: &GroveVersion, ) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { - verify_v0_per_key( + if classification.carrier_outer_items.is_some() { + return Err(Error::InvalidProof( + path_query.clone(), + "carrier AggregateCountOnRange queries are only supported on V1 proof envelopes; \ + upgrade the grove version producing the proof" + .to_string(), + )); + } + let (root_hash, count) = verify_v0_leaf_chain( layer, path_query, path_keys, 0, - classification, + &classification.leaf_inner_range, grove_version, - ) -} - -fn verify_v0_per_key( - layer: &MerkOnlyLayerProof, - path_query: &PathQuery, - path_keys: &[&[u8]], - depth: usize, - classification: &AcorClassification, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { - if depth < path_keys.len() { - // Path-prefix layer: same single-key descent both shapes use. - let next_key = path_keys[depth].to_vec(); - let (proven_value_bytes, parent_root_hash, parent_proof_hash) = - verify_single_key_layer_proof_v0(&layer.merk_proof, &next_key, path_query)?; - - let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "aggregate-count proof missing lower layer for path key {}", - hex::encode(&next_key) - ), - ) - })?; - let (lower_hash, results) = verify_v0_per_key( - lower_layer, - path_query, - path_keys, - depth + 1, - classification, - grove_version, - )?; - enforce_lower_chain( - path_query, - &next_key, - &proven_value_bytes, - &lower_hash, - &parent_proof_hash, - grove_version, - )?; - return Ok((parent_root_hash, results)); - } - - // depth == path_keys.len(): we are at the carrier (or, in the leaf - // shape, at the leaf merk). - match &classification.carrier_outer_items { - None => { - // Leaf shape: single u64 → one entry with empty key. - let (root, count) = verify_count_leaf( - &layer.merk_proof, - &classification.leaf_inner_range, - path_query, - )?; - Ok((root, vec![(Vec::new(), count)])) - } - Some(outer_items) => verify_v0_carrier_layer( - layer, - path_query, - outer_items, - classification, - grove_version, - ), - } -} - -fn verify_v0_carrier_layer( - layer: &MerkOnlyLayerProof, - path_query: &PathQuery, - outer_items: &[QueryItem], - classification: &AcorClassification, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { - // Execute the outer multi-key merk proof to discover which outer keys - // matched (and recover their value_hash commitments). - let (carrier_root, matched) = execute_carrier_layer_proof( - &layer.merk_proof, - outer_items, - classification.carrier_left_to_right, - path_query, )?; - - let subquery_path = classification - .carrier_subquery_path - .as_ref() - .expect("carrier subquery_path is set when carrier_outer_items is Some"); - - let mut results = Vec::with_capacity(matched.len()); - for OuterMatch { - outer_key, - value_bytes, - commitment_hash, - } in matched - { - let lower_layer = layer.lower_layers.get(&outer_key).ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "carrier ACOR proof missing lower layer for outer key {}", - hex::encode(&outer_key) - ), - ) - })?; - - let (lower_root, count) = verify_v0_subquery_path( - lower_layer, - path_query, - subquery_path, - 0, - &classification.leaf_inner_range, - grove_version, - )?; - - enforce_lower_chain( - path_query, - &outer_key, - &value_bytes, - &lower_root, - &commitment_hash, - grove_version, - )?; - - results.push((outer_key, count)); - } - - Ok((carrier_root, results)) -} - -fn verify_v0_subquery_path( - layer: &MerkOnlyLayerProof, - path_query: &PathQuery, - subquery_path: &[Vec], - depth: usize, - inner_range: &QueryItem, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, u64), Error> { - if depth == subquery_path.len() { - // Leaf merk: count proof. - return verify_count_leaf(&layer.merk_proof, inner_range, path_query); - } - let next_key = subquery_path[depth].clone(); - let (proven_value_bytes, parent_root_hash, parent_proof_hash) = - verify_single_key_layer_proof_v0(&layer.merk_proof, &next_key, path_query)?; - let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "carrier ACOR proof missing subquery_path layer for key {}", - hex::encode(&next_key) - ), - ) - })?; - let (lower_hash, count) = verify_v0_subquery_path( - lower_layer, - path_query, - subquery_path, - depth + 1, - inner_range, - grove_version, - )?; - enforce_lower_chain( - path_query, - &next_key, - &proven_value_bytes, - &lower_hash, - &parent_proof_hash, - grove_version, - )?; - Ok((parent_root_hash, count)) + Ok((root_hash, vec![(Vec::new(), count)])) } fn verify_v1_with_classification( diff --git a/grovedb/src/query/mod.rs b/grovedb/src/query/mod.rs index a8940e6ce..40af8e37a 100644 --- a/grovedb/src/query/mod.rs +++ b/grovedb/src/query/mod.rs @@ -173,12 +173,6 @@ pub(crate) fn query_validation_error_to_static_str(e: grovedb_query::error::Erro } } -/// Convenience wrapper: project a `grovedb_query::error::Error` from the -/// validation helpers onto [`Error::InvalidQuery`]. -pub(crate) fn query_validation_error_to_invalid_query(e: grovedb_query::error::Error) -> Error { - Error::InvalidQuery(query_validation_error_to_static_str(e)) -} - impl PathQuery { /// New path query pub const fn new(path: Vec>, query: SizedQuery) -> Self { diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index ece99fa36..0815a002f 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -789,11 +789,21 @@ mod tests { SizedQuery::new(top_query, None, None), ); let prove_result = db.grove_db.prove_query(&path_query, None, v).unwrap(); - assert!( - matches!(prove_result, Err(crate::Error::InvalidQuery(_))), - "carrier ACOR with malformed leaf-inner Key must be rejected at entry, got {:?}", - prove_result.map(|b| b.len()) - ); + // Pin the specific reason (the leaf validator's "wrap Key" + // rejection delegated through the carrier validator) so a + // future refactor that re-routes the rejection through a + // different but still-`InvalidQuery` arm doesn't silently + // accept the malformed shape. + match prove_result { + Err(crate::Error::InvalidQuery(msg)) => assert!( + msg.contains("AggregateCountOnRange may not wrap Key"), + "expected malformed-inner-Key rejection, got: {msg}" + ), + other => panic!( + "carrier ACOR with malformed leaf-inner Key must be rejected at entry, got {:?}", + other.map(|b| b.len()) + ), + } } #[test] @@ -1632,7 +1642,10 @@ mod tests { let mut carrier = Query::new(); for k in outer_keys { - carrier.items.push(QueryItem::Key(k.to_vec())); + // Use `insert_key` (not `items.push`) so items end up in + // sorted-ascending order — the merk multi-key walker + // expects that invariant. + carrier.insert_key(k.to_vec()); } carrier.set_subquery_path(vec![b"color".to_vec()]); carrier.set_subquery(Query::new_aggregate_count_on_range(inner_range)); @@ -1646,8 +1659,9 @@ mod tests { #[test] fn acor_subquery_two_outer_keys_succeeds() { // Carrier with two outer brand keys, range on the color subtree. - // Expected: two (key, count) pairs in lex-asc order with the - // correct per-brand aggregate. + // Expected: two (key, count) pairs in query-direction order with + // the correct per-brand aggregate. The carrier defaults to + // `left_to_right=true`, so output is ascending lex. let v = GroveVersion::latest(); let (db, expected_root) = setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 1_000); @@ -1667,7 +1681,6 @@ mod tests { .expect("verify carrier ACOR should succeed"); assert_eq!(got_root, expected_root, "root must match GroveDB root"); assert_eq!(results.len(), 2, "expected one result per outer key"); - // Lex-asc order: brand_000 then brand_001. assert_eq!(results[0].0, b"brand_000".to_vec()); assert_eq!(results[1].0, b"brand_001".to_vec()); // Each brand has 1 000 colors; range_after `color_00499` leaves @@ -1919,4 +1932,242 @@ mod tests { result.map(|(_, c)| c) ); } + + #[test] + fn acor_carrier_rejects_v0_envelope() { + // V0 proof envelopes are produced only by older grove versions + // (pre-carrier) and cannot carry the carrier shape. The per-key + // verifier must reject the combination up front so a forged V0 + // envelope can't be reinterpreted as a leaf proof. We construct + // a real carrier path query against a tree but force GROVE_V2 + // (V0 prover) to produce the envelope, then verify the + // verifier's rejection. + let v2 = &GROVE_V2; + // Set up the brand/color tree under V2 (V2's prover handles + // the regular subquery walks; the rejection happens at verifier + // entry). + let (db, _root) = setup_brand_color_carrier_tree(v2, &[b"brand_000"], 100); + let path_query = carrier_acor_path_query( + &[b"brand_000"], + QueryItem::RangeAfter(b"color_00049".to_vec()..), + ); + // V2 prover refuses to emit a carrier ACOR proof; the + // existing classify-then-dispatch path means we get a clean + // validation error from the prover entry gate. Either the + // prover errors (preferred) or the verifier errors when given + // the V0 envelope — both close the surface. + match db.grove_db.prove_query(&path_query, None, v2).unwrap() { + Ok(proof) => { + let err = GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v2) + .expect_err( + "V0 envelope with carrier path query must be rejected at verify time", + ); + match err { + crate::Error::InvalidProof(_, msg) => { + assert!(msg.contains("V1 proof envelope"), "unexpected error: {msg}") + } + other => panic!("expected InvalidProof, got {:?}", other), + } + } + Err(_) => { + // V2 prover refused to emit the proof — also acceptable. + } + } + } + + #[test] + fn acor_carrier_with_long_subquery_path_succeeds() { + // Exercises a non-trivial `subquery_path` (length > 1) in the + // carrier shape: TEST_LEAF / "outer" / / "level1" / + // "level2" / . The verifier must walk both + // intermediate single-key layers between each outer-key match + // and the leaf merk. + use grovedb_query::Query; + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"outer", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert outer"); + for brand in [b"a".as_ref(), b"b".as_ref()] { + db.insert( + [TEST_LEAF, b"outer"].as_ref(), + brand, + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert brand"); + db.insert( + [TEST_LEAF, b"outer", brand].as_ref(), + b"level1", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert level1"); + db.insert( + [TEST_LEAF, b"outer", brand, b"level1"].as_ref(), + b"level2", + Element::empty_provable_count_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert level2"); + for c in b'a'..=b'e' { + db.insert( + [TEST_LEAF, b"outer", brand, b"level1", b"level2"].as_ref(), + &[c], + Element::new_item(vec![c]), + None, + None, + v, + ) + .unwrap() + .expect("insert leaf"); + } + } + let expected_root = db.grove_db.root_hash(None, v).unwrap().expect("root_hash"); + + // Carrier path query: walks "level1" → "level2" between each + // outer-brand match and the leaf count proof. + let mut carrier = Query::new(); + carrier.insert_key(b"a".to_vec()); + carrier.insert_key(b"b".to_vec()); + carrier.set_subquery_path(vec![b"level1".to_vec(), b"level2".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_on_range( + QueryItem::RangeInclusive(b"b".to_vec()..=b"d".to_vec()), + )); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"outer".to_vec()], + SizedQuery::new(carrier, None, None), + ); + + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query (carrier with long subquery_path) should succeed"); + let (got_root, results) = + GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect("verify carrier (long subquery_path) should succeed"); + assert_eq!(got_root, expected_root); + assert_eq!(results.len(), 2); + assert_eq!(results[0].0, b"a".to_vec()); + assert_eq!(results[1].0, b"b".to_vec()); + assert_eq!(results[0].1, 3, "{{b, c, d}} expected for brand a"); + assert_eq!(results[1].1, 3, "{{b, c, d}} expected for brand b"); + } + + #[test] + fn acor_carrier_corrupted_outer_layer_byte_is_rejected() { + // Flip a byte deep inside the carrier-layer merk proof bytes + // (which encode the outer-Keys multi-key proof). Either the + // merk-level execute_proof rejects the bytes, or the chain + // check downstream rejects the resulting hash mismatch. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 100); + let path_query = carrier_acor_path_query( + &[b"brand_000", b"brand_001"], + QueryItem::RangeAfter(b"color_00049".to_vec()..), + ); + let mut proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + // Flip a byte ~3/4 through the proof — far enough into the + // envelope to land inside the carrier-layer merk_proof bytes. + let target = (proof.len() * 3) / 4; + proof[target] ^= 0x55; + let result = GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v); + assert!( + result.is_err(), + "tampered carrier-layer byte must be rejected, got {:?}", + result.map(|(_, c)| c) + ); + } + + #[test] + fn acor_carrier_undecodable_proof_is_rejected() { + // Send garbage bytes — the bincode decoder rejects the + // envelope up front with `Error::CorruptedData`. + let v = GroveVersion::latest(); + let path_query = carrier_acor_path_query( + &[b"brand_000"], + QueryItem::RangeAfter(b"color_00049".to_vec()..), + ); + let garbage = vec![0xffu8; 32]; + let err = GroveDb::verify_aggregate_count_query_per_key(&garbage, &path_query, v) + .expect_err("undecodable proof must be rejected"); + match err { + crate::Error::CorruptedData(_) => {} + other => panic!("expected CorruptedData, got {:?}", other), + } + } + + #[test] + fn acor_carrier_legacy_verifier_rejects_carrier_query() { + // The legacy single-`u64` `verify_aggregate_count_query` strictly + // validates the leaf shape and rejects carrier queries — even + // though the proof bytes themselves are well-formed. Callers + // must use `verify_aggregate_count_query_per_key` for carriers. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000"], 50); + let path_query = carrier_acor_path_query( + &[b"brand_000"], + QueryItem::Range(b"color_00010".to_vec()..b"color_00020".to_vec()), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let err = GroveDb::verify_aggregate_count_query(&proof, &path_query, v) + .expect_err("legacy leaf verifier must reject carrier shape"); + match err { + crate::Error::InvalidQuery(_) => {} + other => panic!("expected InvalidQuery, got {:?}", other), + } + } + + #[test] + fn acor_carrier_pagination_is_rejected_at_entry() { + // Carriers (like leaves) forbid SizedQuery::limit and offset. + // The PathQuery-level validator surfaces this before any proof + // bytes are decoded. + use grovedb_query::Query; + let v = GroveVersion::latest(); + let mut carrier = Query::new(); + carrier.insert_key(b"brand_000".to_vec()); + carrier.set_subquery_path(vec![b"color".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_on_range(QueryItem::Range( + b"color_00010".to_vec()..b"color_00020".to_vec(), + ))); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, Some(10), None), + ); + let dummy_proof = vec![0u8; 8]; + let err = GroveDb::verify_aggregate_count_query_per_key(&dummy_proof, &path_query, v) + .expect_err("carrier ACOR with limit must be rejected at entry"); + match err { + crate::Error::InvalidQuery(msg) => { + assert!(msg.contains("limit"), "unexpected message: {msg}") + } + other => panic!("expected InvalidQuery, got {:?}", other), + } + } } From ae7962dd2b2344faa52d05b904f1c8d421cc92cc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 04:51:36 +0700 Subject: [PATCH 03/15] test(grovedb,query): boost carrier ACOR patch coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch coverage was 89.86% (just under codecov's 90% target). The biggest gaps were untested error paths in the carrier verifier and a few carrier-validator branches the top-level dispatcher masks. New tests: grovedb (carrier proof verifier error paths): - `acor_carrier_missing_outer_lower_layer_is_rejected` — drops one `lower_layers[outer_key]` entry from a real envelope; verifier must surface "missing lower layer for outer key". - `acor_carrier_missing_subquery_path_layer_is_rejected` — drops the subquery_path "color" layer; exercises `verify_v1_subquery_path`'s missing-layer branch. - `acor_carrier_non_merk_proof_bytes_is_rejected` — swaps a `ProofBytes::Merk(...)` layer for `ProofBytes::MMR(...)`; the `expect_merk_bytes` helper rejects. grovedb-query (direct carrier-validator branches): - `validate_carrier_acor_direct_rejects_missing_subquery` - `validate_carrier_acor_direct_rejects_acor_outer_item` - `validate_carrier_acor_direct_rejects_range_full_outer` These call `validate_carrier_aggregate_count_on_range` directly to hit branches the top-level dispatcher routes around (the dispatcher classifies first and sends ACOR-outer-item shapes to the leaf validator). Co-Authored-By: Claude Opus 4.7 (1M context) --- grovedb-query/src/aggregate_count.rs | 72 +++++++ .../src/tests/aggregate_count_query_tests.rs | 189 ++++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/grovedb-query/src/aggregate_count.rs b/grovedb-query/src/aggregate_count.rs index 14476c783..69f29c9c5 100644 --- a/grovedb-query/src/aggregate_count.rs +++ b/grovedb-query/src/aggregate_count.rs @@ -764,4 +764,76 @@ mod tests { _ => panic!("expected InvalidOperation"), } } + + // ---------- Direct carrier-validator branch coverage ---------- + // + // The top-level dispatcher classifies first and routes to either + // `validate_leaf_aggregate_count_on_range` or + // `validate_carrier_aggregate_count_on_range`. Some carrier rules + // (no subquery, ACOR outer item) get masked by classification — + // calling the carrier validator directly is the only way to exercise + // them. These tests pin those branches. + + #[test] + fn validate_carrier_acor_direct_rejects_missing_subquery() { + // Carrier-shaped items but no subquery — the carrier + // validator's "subquery must be Some" branch fires. + let mut carrier = Query::new(); + carrier.insert_key(b"k".to_vec()); + let err = carrier + .validate_carrier_aggregate_count_on_range() + .expect_err("missing subquery must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!(msg.contains("must set"), "unexpected message: {msg}") + } + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_direct_rejects_acor_outer_item() { + // ACOR appears in outer items + a leaf subquery is set. The + // top-level dispatcher routes to the leaf validator (because + // `aggregate_count_on_range()` returns Some at the carrier + // level); calling the carrier validator directly is the only + // way to hit the carrier-side rule. + let mut carrier = Query::new(); + carrier + .items + .push(QueryItem::AggregateCountOnRange(Box::new( + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + ))); + carrier.set_subquery(make_leaf_acor_subquery()); + let err = carrier + .validate_carrier_aggregate_count_on_range() + .expect_err("ACOR outer item via direct carrier validator must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => assert!( + msg.contains("may not own an") || msg.contains("AggregateCountOnRange"), + "unexpected message: {msg}" + ), + _ => panic!("expected InvalidOperation"), + } + } + + #[test] + fn validate_carrier_acor_direct_rejects_range_full_outer() { + // RangeFull outer + leaf subquery — exercise the carrier + // validator's `RangeFull` arm directly. + let mut carrier = Query::new(); + carrier + .items + .push(QueryItem::RangeFull(std::ops::RangeFull)); + carrier.set_subquery(make_leaf_acor_subquery()); + let err = carrier + .validate_carrier_aggregate_count_on_range() + .expect_err("RangeFull outer via direct carrier validator must fail"); + match err { + crate::error::Error::InvalidOperation(msg) => { + assert!(msg.contains("RangeFull"), "unexpected message: {msg}") + } + _ => panic!("expected InvalidOperation"), + } + } } diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index 0815a002f..0aee0fa18 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -2143,6 +2143,195 @@ mod tests { } } + #[test] + fn acor_carrier_missing_outer_lower_layer_is_rejected() { + // Decode the carrier proof envelope, drop one of the + // `lower_layers[outer_key]` entries, re-encode, and verify the + // verifier rejects with "missing lower layer for outer key". + // Exercises the `lower_layers.get(&outer_key).ok_or_else(...)` + // branch in `verify_v1_carrier_layer`. + use bincode::config; + + use crate::operations::proof::{GroveDBProof, GroveDBProofV1}; + + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 100); + let path_query = carrier_acor_path_query( + &[b"brand_000", b"brand_001"], + QueryItem::RangeAfter(b"color_00049".to_vec()..), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + + let cfg = config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (mut decoded, _): (GroveDBProof, _) = + bincode::decode_from_slice(&proof, cfg).expect("decode envelope"); + let GroveDBProof::V1(GroveDBProofV1 { root_layer }) = &mut decoded else { + panic!("expected V1 envelope"); + }; + // Walk to the carrier layer: TEST_LEAF -> byBrand. + let carrier_layer = root_layer + .lower_layers + .get_mut(&TEST_LEAF.to_vec()) + .expect("TEST_LEAF layer") + .lower_layers + .get_mut(&b"byBrand".to_vec()) + .expect("byBrand carrier layer"); + // Drop brand_001's lower_layer — its row will still be in the + // multi-key proof but the descent will fail. + let removed = carrier_layer.lower_layers.remove(&b"brand_001".to_vec()); + assert!( + removed.is_some(), + "test setup: expected brand_001 in carrier lower_layers" + ); + let new_proof = bincode::encode_to_vec( + decoded, + config::standard().with_big_endian().with_no_limit(), + ) + .expect("re-encode"); + + let err = GroveDb::verify_aggregate_count_query_per_key(&new_proof, &path_query, v) + .expect_err("missing outer lower_layer must be rejected"); + match err { + crate::Error::InvalidProof(_, msg) => assert!( + msg.contains("missing lower layer for outer key"), + "unexpected message: {msg}" + ), + other => panic!("expected InvalidProof, got {:?}", other), + } + } + + #[test] + fn acor_carrier_missing_subquery_path_layer_is_rejected() { + // Same idea as the previous test but one level deeper: drop the + // `subquery_path` layer ("color") that sits between the outer + // brand match and the leaf merk. Exercises the + // `verify_v1_subquery_path` "missing subquery_path layer" branch. + use bincode::config; + + use crate::operations::proof::{GroveDBProof, GroveDBProofV1}; + + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000"], 100); + let path_query = carrier_acor_path_query( + &[b"brand_000"], + QueryItem::RangeAfter(b"color_00049".to_vec()..), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + + let cfg = config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (mut decoded, _): (GroveDBProof, _) = + bincode::decode_from_slice(&proof, cfg).expect("decode envelope"); + let GroveDBProof::V1(GroveDBProofV1 { root_layer }) = &mut decoded else { + panic!("expected V1 envelope"); + }; + // Walk to brand_000 layer and drop the "color" subquery_path + // descent. + let brand_layer = root_layer + .lower_layers + .get_mut(&TEST_LEAF.to_vec()) + .expect("TEST_LEAF layer") + .lower_layers + .get_mut(&b"byBrand".to_vec()) + .expect("byBrand layer") + .lower_layers + .get_mut(&b"brand_000".to_vec()) + .expect("brand_000 layer"); + let removed = brand_layer.lower_layers.remove(&b"color".to_vec()); + assert!( + removed.is_some(), + "test setup: expected color in brand_000 lower_layers" + ); + let new_proof = bincode::encode_to_vec( + decoded, + config::standard().with_big_endian().with_no_limit(), + ) + .expect("re-encode"); + + let err = GroveDb::verify_aggregate_count_query_per_key(&new_proof, &path_query, v) + .expect_err("missing subquery_path layer must be rejected"); + match err { + crate::Error::InvalidProof(_, msg) => assert!( + msg.contains("missing subquery_path layer"), + "unexpected message: {msg}" + ), + other => panic!("expected InvalidProof, got {:?}", other), + } + } + + #[test] + fn acor_carrier_non_merk_proof_bytes_is_rejected() { + // Replace the subquery_path layer's `ProofBytes::Merk(...)` with + // a `ProofBytes::MMR(...)` variant. The verifier rejects the + // mismatched proof-bytes flavor through `expect_merk_bytes`. + use bincode::config; + + use crate::operations::proof::{GroveDBProof, GroveDBProofV1, ProofBytes}; + + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000"], 100); + let path_query = carrier_acor_path_query( + &[b"brand_000"], + QueryItem::RangeAfter(b"color_00049".to_vec()..), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let cfg = config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (mut decoded, _): (GroveDBProof, _) = + bincode::decode_from_slice(&proof, cfg).expect("decode envelope"); + let GroveDBProof::V1(GroveDBProofV1 { root_layer }) = &mut decoded else { + panic!("expected V1 envelope"); + }; + // Swap the subquery_path "color" layer's proof bytes from Merk + // to MMR — `verify_v1_subquery_path` will refuse via + // `expect_merk_bytes`. + let color_layer = root_layer + .lower_layers + .get_mut(&TEST_LEAF.to_vec()) + .expect("TEST_LEAF layer") + .lower_layers + .get_mut(&b"byBrand".to_vec()) + .expect("byBrand layer") + .lower_layers + .get_mut(&b"brand_000".to_vec()) + .expect("brand_000 layer") + .lower_layers + .get_mut(&b"color".to_vec()) + .expect("color layer"); + color_layer.merk_proof = ProofBytes::MMR(vec![]); + let new_proof = bincode::encode_to_vec( + decoded, + config::standard().with_big_endian().with_no_limit(), + ) + .expect("re-encode"); + + let err = GroveDb::verify_aggregate_count_query_per_key(&new_proof, &path_query, v) + .expect_err("non-Merk proof bytes must be rejected"); + match err { + crate::Error::InvalidProof(_, msg) => assert!( + msg.contains("unexpected non-merk layer bytes"), + "unexpected message: {msg}" + ), + other => panic!("expected InvalidProof, got {:?}", other), + } + } + #[test] fn acor_carrier_pagination_is_rejected_at_entry() { // Carriers (like leaves) forbid SizedQuery::limit and offset. From f31fcb20cd184ef9f9e0ee2c90f268cf6e8e2896 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 05:05:40 +0700 Subject: [PATCH 04/15] refactor(grovedb): reject V0 envelopes for AggregateCountOnRange entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V0 proof envelopes (`GroveDBProofV0` / `MerkOnlyLayerProof`) predate the ACOR feature — ACOR was introduced in #656, well after V1 envelopes became the default for grove versions used by Dash Platform v12+. The V0+ACOR combination is impossible in any deployed Platform release, so the previous compatibility shims are dead code. This change makes that explicit: - Prover entry: `prove_query_non_serialized` rejects ACOR queries paired with grove versions that dispatch to V0 with `Error::NotSupported("requires V1 proof envelopes")`. The check fires before any work happens, so callers can't accidentally emit a V0 ACOR proof. - Verifier entries: both `verify_aggregate_count_query` (leaf) and `verify_aggregate_count_query_per_key` (leaf or carrier) refuse `GroveDBProof::V0` envelopes via a shared `require_v1_envelope` helper, surfacing `Error::InvalidProof`. A forged V0 envelope carrying ACOR-looking bytes can't even reach the verification logic. - Removed dead code: `verify_v0_leaf_chain` and `verify_v0_with_classification` are deleted. `MerkOnlyLayerProof` and `GroveDBProofV0` are no longer imported by `aggregate_count.rs`. - Tests updated: `provable_count_tree_works_on_grove_v2_envelope` inverts to `aggregate_count_rejects_grove_v2_envelope`, asserting the prover refuses the combination. `acor_carrier_rejects_v0_envelope` similarly tightens its assertion to expect `NotSupported` from the prover instead of `InvalidProof` from the verifier. The V0 short-circuit in `prove_subqueries` (the v0 path) stays as defense-in-depth — unreachable through the entry gate but a safety net for any future internal caller that bypasses `prove_query_non_serialized`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/operations/proof/aggregate_count.rs | 175 ++++++------------ grovedb/src/operations/proof/generate.rs | 29 ++- .../src/tests/aggregate_count_query_tests.rs | 76 ++++---- 3 files changed, 105 insertions(+), 175 deletions(-) diff --git a/grovedb/src/operations/proof/aggregate_count.rs b/grovedb/src/operations/proof/aggregate_count.rs index 6ac13a5c4..b741363f8 100644 --- a/grovedb/src/operations/proof/aggregate_count.rs +++ b/grovedb/src/operations/proof/aggregate_count.rs @@ -43,9 +43,7 @@ use grovedb_query::QueryItem; use grovedb_version::{check_grovedb_v0, version::GroveVersion}; use crate::{ - operations::proof::{ - GroveDBProof, GroveDBProofV0, GroveDBProofV1, LayerProof, MerkOnlyLayerProof, ProofBytes, - }, + operations::proof::{GroveDBProof, GroveDBProofV1, LayerProof, ProofBytes}, Element, Error, GroveDb, PathQuery, }; @@ -62,6 +60,12 @@ impl GroveDb { /// queries (outer `Keys` + ACOR subquery) must use /// [`GroveDb::verify_aggregate_count_query_per_key`] instead. /// + /// `AggregateCountOnRange` requires **V1 proof envelopes** + /// (`GroveDBProofV1`). V0 (`GroveDBProofV0` / `MerkOnlyLayerProof`) + /// envelopes predate the ACOR feature and are only produced by grove + /// versions older than the one used by Dash Platform v12; this entry + /// point rejects them with `Error::InvalidProof`. + /// /// Returns: /// - `root_hash` — the reconstructed GroveDB root hash. The caller is /// responsible for comparing this against their trusted root hash. @@ -100,25 +104,16 @@ impl GroveDb { let grovedb_proof = decode_grovedb_proof(proof)?; let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); - let (root_hash, results) = match &grovedb_proof { - GroveDBProof::V0(GroveDBProofV0 { root_layer, .. }) => verify_v0_leaf_chain( - root_layer, - path_query, - &path_keys, - 0, - &inner_range, - grove_version, - )?, - GroveDBProof::V1(GroveDBProofV1 { root_layer }) => verify_v1_leaf_chain( - root_layer, - path_query, - &path_keys, - 0, - &inner_range, - grove_version, - )?, - }; - Ok((root_hash, results)) + let root_layer = require_v1_envelope(&grovedb_proof, path_query)?; + let (root_hash, count) = verify_v1_leaf_chain( + root_layer, + path_query, + &path_keys, + 0, + &inner_range, + grove_version, + )?; + Ok((root_hash, count)) } /// Verify a serialized `prove_query` proof against an ACOR `PathQuery` @@ -143,6 +138,10 @@ impl GroveDb { /// emission order. Outer-key candidates that the prover proved as /// absent contribute no entry. /// + /// Like [`GroveDb::verify_aggregate_count_query`], this entry point + /// requires **V1 proof envelopes**. V0 envelopes predate ACOR and are + /// rejected with `Error::InvalidProof`. + /// /// Cryptographic guarantees: /// - Every layer is committed via the same `combine_hash(H(value), /// lower_hash) == parent_proof_hash` chain check used by the leaf @@ -173,22 +172,34 @@ impl GroveDb { let grovedb_proof = decode_grovedb_proof(proof)?; let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); - match &grovedb_proof { - GroveDBProof::V0(GroveDBProofV0 { root_layer, .. }) => verify_v0_with_classification( - root_layer, - path_query, - &path_keys, - &classification, - grove_version, - ), - GroveDBProof::V1(GroveDBProofV1 { root_layer }) => verify_v1_with_classification( - root_layer, - path_query, - &path_keys, - &classification, - grove_version, - ), - } + let root_layer = require_v1_envelope(&grovedb_proof, path_query)?; + verify_v1_with_classification( + root_layer, + path_query, + &path_keys, + &classification, + grove_version, + ) + } +} + +/// Extract the V1 root layer from a `GroveDBProof` envelope, or refuse +/// the proof. ACOR (both leaf and carrier) requires V1 envelopes — the +/// V0 (`MerkOnlyLayerProof`) envelope predates ACOR and is only emitted +/// by grove versions older than the one used by Dash Platform v12, so +/// it cannot legitimately contain an ACOR proof. +fn require_v1_envelope<'a>( + proof: &'a GroveDBProof, + path_query: &PathQuery, +) -> Result<&'a LayerProof, Error> { + match proof { + GroveDBProof::V1(GroveDBProofV1 { root_layer }) => Ok(root_layer), + GroveDBProof::V0(_) => Err(Error::InvalidProof( + path_query.clone(), + "AggregateCountOnRange proofs require V1 proof envelopes; V0 envelopes predate \ + this feature and cannot legitimately carry an aggregate-count proof" + .to_string(), + )), } } @@ -258,54 +269,6 @@ fn decode_grovedb_proof(proof: &[u8]) -> Result { Ok(proof) } -// ── V0 leaf-only chain (legacy entry point, kept byte-identical) ─────────── - -fn verify_v0_leaf_chain( - layer: &MerkOnlyLayerProof, - path_query: &PathQuery, - path_keys: &[&[u8]], - depth: usize, - inner_range: &QueryItem, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, u64), Error> { - if depth == path_keys.len() { - return verify_count_leaf(&layer.merk_proof, inner_range, path_query); - } - - let next_key = path_keys[depth].to_vec(); - let (proven_value_bytes, parent_root_hash, parent_proof_hash) = - verify_single_key_layer_proof_v0(&layer.merk_proof, &next_key, path_query)?; - - let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "aggregate-count proof missing lower layer for path key {}", - hex::encode(&next_key) - ), - ) - })?; - let (lower_hash, count) = verify_v0_leaf_chain( - lower_layer, - path_query, - path_keys, - depth + 1, - inner_range, - grove_version, - )?; - - enforce_lower_chain( - path_query, - &next_key, - &proven_value_bytes, - &lower_hash, - &parent_proof_hash, - grove_version, - )?; - - Ok((parent_root_hash, count)) -} - fn verify_v1_leaf_chain( layer: &LayerProof, path_query: &PathQuery, @@ -354,43 +317,9 @@ fn verify_v1_leaf_chain( Ok((parent_root_hash, count)) } -// ── per-key entry-point traversal (leaf or carrier) ──────────────────────── - -/// V0 per-key dispatch: the V0 envelope (`MerkOnlyLayerProof`) is the -/// legacy proof format used only by older grove versions that pre-date -/// the carrier-ACOR feature. The prover for those versions never emits a -/// carrier-shaped proof, so V0 per-key is a strict alias for the -/// existing leaf-only chain — collapsed into a one-entry result vector. -/// -/// Carrier-shaped path queries paired with a V0 envelope are rejected -/// up front so a forged envelope can't sneak past the leaf chain and -/// have its multi-key merk proof reinterpreted as a single-key count -/// proof. -fn verify_v0_with_classification( - layer: &MerkOnlyLayerProof, - path_query: &PathQuery, - path_keys: &[&[u8]], - classification: &AcorClassification, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { - if classification.carrier_outer_items.is_some() { - return Err(Error::InvalidProof( - path_query.clone(), - "carrier AggregateCountOnRange queries are only supported on V1 proof envelopes; \ - upgrade the grove version producing the proof" - .to_string(), - )); - } - let (root_hash, count) = verify_v0_leaf_chain( - layer, - path_query, - path_keys, - 0, - &classification.leaf_inner_range, - grove_version, - )?; - Ok((root_hash, vec![(Vec::new(), count)])) -} +// ── per-key entry-point traversal (V1 only — V0 envelopes are +// rejected at the entry-point gate above, since they predate the +// ACOR feature and cannot legitimately carry an aggregate-count proof) fn verify_v1_with_classification( layer: &LayerProof, diff --git a/grovedb/src/operations/proof/generate.rs b/grovedb/src/operations/proof/generate.rs index 6f136e466..bcc5a6229 100644 --- a/grovedb/src/operations/proof/generate.rs +++ b/grovedb/src/operations/proof/generate.rs @@ -116,21 +116,36 @@ impl GroveDb { // the path doesn't exist. Without this gate, `prove_query` would // happily return a regular path/absence proof for an invalid // aggregate-count request. - if path_query + let is_acor_query = path_query .query .query - .has_aggregate_count_on_range_anywhere() - && let Err(e) = path_query.validate_aggregate_count_on_range() - { + .has_aggregate_count_on_range_anywhere(); + if is_acor_query && let Err(e) = path_query.validate_aggregate_count_on_range() { return Err(e).wrap_with_cost(OperationCost::default()); } - match grove_version + let prove_version = grove_version .grovedb_versions .operations .proof - .prove_query_non_serialized - { + .prove_query_non_serialized; + + // AggregateCountOnRange requires V1 proof envelopes. The legacy + // V0 (`MerkOnlyLayerProof`) envelope predates ACOR and is only + // produced by grove versions that pre-date Dash Platform v12; + // refusing the combination here keeps callers from accidentally + // emitting a V0 ACOR proof that the verifier would (correctly) + // reject. + if is_acor_query && prove_version == 0 { + return Err(Error::NotSupported( + "AggregateCountOnRange proofs require V1 proof envelopes; upgrade the grove \ + version producing the proof" + .to_string(), + )) + .wrap_with_cost(OperationCost::default()); + } + + match prove_version { 0 => self.prove_query_non_serialized_v0(path_query, prove_options, grove_version), 1 => self.prove_query_non_serialized_v1(path_query, prove_options, grove_version), version => Err(Error::VersionError( diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index 0aee0fa18..5c7e53588 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -836,26 +836,31 @@ mod tests { } #[test] - fn provable_count_tree_works_on_grove_v2_envelope() { - // GROVE_V2 dispatches to the V0 prove_query_non_serialized path, which - // produces a `MerkOnlyLayerProof` envelope rather than V1's - // `LayerProof`. Verify the same prove → verify cycle works through that - // envelope. + fn aggregate_count_rejects_grove_v2_envelope() { + // GROVE_V2 dispatches to the V0 prove_query_non_serialized path, + // which produces a `MerkOnlyLayerProof` envelope. ACOR was added + // after V0 envelopes were superseded by V1 (in the grove version + // used by Dash Platform v12+), so V0+ACOR is impossible in any + // deployed Platform release. The prover rejects the combination + // up front to keep callers from emitting a V0 ACOR proof that + // the verifier would (correctly) refuse. let v: &GroveVersion = &GROVE_V2; - let (db, root) = setup_15_key_provable_count_tree(v); + let (db, _root) = 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()), ); - let proof = db - .grove_db - .prove_query(&path_query, None, v) - .unwrap() - .expect("prove_query (v0 envelope) should succeed"); - let (got_root, got_count) = GroveDb::verify_aggregate_count_query(&proof, &path_query, v) - .expect("verify should succeed against v0 envelope"); - assert_eq!(got_root, root); - assert_eq!(got_count, 10); + let prove_result = db.grove_db.prove_query(&path_query, None, v).unwrap(); + match prove_result { + Err(crate::Error::NotSupported(msg)) => assert!( + msg.contains("V1 proof envelopes"), + "unexpected message: {msg}" + ), + other => panic!( + "expected NotSupported for V0+ACOR, got {:?}", + other.map(|b| b.len()) + ), + } } #[test] @@ -1935,43 +1940,24 @@ mod tests { #[test] fn acor_carrier_rejects_v0_envelope() { - // V0 proof envelopes are produced only by older grove versions - // (pre-carrier) and cannot carry the carrier shape. The per-key - // verifier must reject the combination up front so a forged V0 - // envelope can't be reinterpreted as a leaf proof. We construct - // a real carrier path query against a tree but force GROVE_V2 - // (V0 prover) to produce the envelope, then verify the - // verifier's rejection. + // V0 proof envelopes predate ACOR and cannot legitimately carry + // an aggregate-count proof — neither leaf nor carrier. The + // prover-side entry-point gate refuses to emit V0+ACOR. let v2 = &GROVE_V2; - // Set up the brand/color tree under V2 (V2's prover handles - // the regular subquery walks; the rejection happens at verifier - // entry). let (db, _root) = setup_brand_color_carrier_tree(v2, &[b"brand_000"], 100); let path_query = carrier_acor_path_query( &[b"brand_000"], QueryItem::RangeAfter(b"color_00049".to_vec()..), ); - // V2 prover refuses to emit a carrier ACOR proof; the - // existing classify-then-dispatch path means we get a clean - // validation error from the prover entry gate. Either the - // prover errors (preferred) or the verifier errors when given - // the V0 envelope — both close the surface. match db.grove_db.prove_query(&path_query, None, v2).unwrap() { - Ok(proof) => { - let err = GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v2) - .expect_err( - "V0 envelope with carrier path query must be rejected at verify time", - ); - match err { - crate::Error::InvalidProof(_, msg) => { - assert!(msg.contains("V1 proof envelope"), "unexpected error: {msg}") - } - other => panic!("expected InvalidProof, got {:?}", other), - } - } - Err(_) => { - // V2 prover refused to emit the proof — also acceptable. - } + Err(crate::Error::NotSupported(msg)) => assert!( + msg.contains("V1 proof envelopes"), + "unexpected message: {msg}" + ), + other => panic!( + "expected NotSupported for V0+carrier ACOR, got {:?}", + other.map(|b| b.len()) + ), } } From 4219045482889d27fb8478bbecf0b606b0d9031c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 05:17:35 +0700 Subject: [PATCH 05/15] fix(grovedb): support right-to-left direction in carrier ACOR verifier The carrier-layer verifier was hardcoding `left_to_right=true` when calling `merk::execute_proof`, even though the rest of the carrier classification (`AcorClassification::carrier_left_to_right`) and the prover (`generate_merk_proof(..., query.left_to_right, ...)`) both honor the query's direction. Consequence: a carrier query with `left_to_right=false` would produce a correct multi-key proof emitted in reverse order, but the verifier would feed it through `execute_proof` walking left-to-right and discover only the last matched key (the merk walker terminates as soon as the first "impossible direction" boundary is hit), returning a single result instead of all matches. The single-key descent (`verify_single_key_layer_proof_v0`) is unaffected because it's exactly-one-key, so order doesn't matter. The fix is a one-line change: pass `left_to_right` through to `execute_proof` in `execute_carrier_layer_proof`. Adds a new test `acor_subquery_right_to_left_returns_descending_order` that builds a 3-brand carrier with `left_to_right=false` and asserts results come back in descending lex order (brand_002, brand_001, brand_000), each with the correct per-brand count. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/operations/proof/aggregate_count.rs | 7 +++- .../src/tests/aggregate_count_query_tests.rs | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/grovedb/src/operations/proof/aggregate_count.rs b/grovedb/src/operations/proof/aggregate_count.rs index b741363f8..4c28a72c5 100644 --- a/grovedb/src/operations/proof/aggregate_count.rs +++ b/grovedb/src/operations/proof/aggregate_count.rs @@ -628,8 +628,13 @@ fn execute_carrier_layer_proof( ..Default::default() }; + // CRITICAL: the merk `execute_proof`'s third argument is the + // direction to walk the proof bytes — it must match the direction + // the prover used (which is `query.left_to_right`). Hardcoding + // `true` here would make `left_to_right=false` carrier proofs + // return only one matched key. let (root_hash, merk_result) = level_query - .execute_proof(merk_bytes, None, true, 0) + .execute_proof(merk_bytes, None, left_to_right, 0) .unwrap() .map_err(|e| { Error::InvalidProof( diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index 5c7e53588..2eb697080 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -1830,6 +1830,46 @@ mod tests { } } + #[test] + fn acor_subquery_right_to_left_returns_descending_order() { + // Flip the carrier's `left_to_right` flag — output must come + // back in descending lex order, mirroring the merk walker's + // reversed emission. + use grovedb_query::Query; + let v = GroveVersion::latest(); + let (db, expected_root) = + setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001", b"brand_002"], 100); + let mut carrier = Query::new_with_direction(false); + carrier.insert_key(b"brand_000".to_vec()); + carrier.insert_key(b"brand_001".to_vec()); + carrier.insert_key(b"brand_002".to_vec()); + carrier.set_subquery_path(vec![b"color".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_on_range(QueryItem::RangeAfter( + b"color_00049".to_vec().., + ))); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, None), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query (carrier ACOR, right-to-left) should succeed"); + let (got_root, results) = + GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect("verify carrier ACOR (right-to-left) should succeed"); + assert_eq!(got_root, expected_root); + assert_eq!(results.len(), 3, "expected 3 outer-key matches"); + // Descending lex: brand_002, brand_001, brand_000. + assert_eq!(results[0].0, b"brand_002".to_vec()); + assert_eq!(results[1].0, b"brand_001".to_vec()); + assert_eq!(results[2].0, b"brand_000".to_vec()); + for (_, count) in results { + assert_eq!(count, 50); + } + } + #[test] fn acor_per_key_rejects_non_acor_path_query() { // The per-key entry point rejects path queries that aren't ACOR From d8ddda502d1250c0c67d169498a6d4e125c1c720 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 05:22:21 +0700 Subject: [PATCH 06/15] docs(grovedb): tone down carrier ACOR direction comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip the "CRITICAL:" framing and the hypothetical "hardcoding true would..." — a short factual note about what would actually go wrong is enough. Co-Authored-By: Claude Opus 4.7 (1M context) --- grovedb/src/operations/proof/aggregate_count.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/grovedb/src/operations/proof/aggregate_count.rs b/grovedb/src/operations/proof/aggregate_count.rs index 4c28a72c5..cd44aa753 100644 --- a/grovedb/src/operations/proof/aggregate_count.rs +++ b/grovedb/src/operations/proof/aggregate_count.rs @@ -628,11 +628,9 @@ fn execute_carrier_layer_proof( ..Default::default() }; - // CRITICAL: the merk `execute_proof`'s third argument is the - // direction to walk the proof bytes — it must match the direction - // the prover used (which is `query.left_to_right`). Hardcoding - // `true` here would make `left_to_right=false` carrier proofs - // return only one matched key. + // Walk direction must match the prover's; otherwise the merk + // walker stops at the first out-of-order boundary and only the + // last key in the proof is returned. let (root_hash, merk_result) = level_query .execute_proof(merk_bytes, None, left_to_right, 0) .unwrap() From e1dddd5c949d594f2a80eb1074e6ecc93184f9ec Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 05:34:28 +0700 Subject: [PATCH 07/15] refactor(grovedb,query): drop "ACOR" abbreviation for forward-compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "ACOR" / "Acor" shorthand baked count into every identifier and prose mention — but the leaf/carrier shape generalizes to forthcoming aggregate variants (sum, average, …), and "ASOR" / "AAOR" don't make that lineage obvious. This commit drops the abbreviation so future aggregate modules can use parallel naming without inheriting a count-specific prefix. Renames: - `AcorClassification` → `AggregateCountClassification` - `classify_path_query` → `classify_aggregate_count_path_query` - `acor_subquery_*` test functions → `carrier_*` (the `aggregate_count_query_tests` module already scopes them) - `acor_carrier_*` → `carrier_*` - `acor_leaf_*` → `leaf_*` - `acor_per_key_*` → `per_key_*` - `carrier_acor_path_query` helper → `carrier_count_path_query` - `validate_acor_*` → `validate_aggregate_count_*` - `validate_carrier_acor_*` → `validate_carrier_aggregate_count_*` - `make_acor_query` → `make_aggregate_count_query` - `make_leaf_acor_subquery` → `make_leaf_aggregate_count_subquery` - `inner_acor*` → `inner_aggregate_count*` - `bad_inner_acor` → `bad_inner_aggregate_count` Docs/comments/error-message prose: "ACOR" → "aggregate-count" (or the full `AggregateCountOnRange` when referring to the QueryItem). Module docs note that future variants will live in sibling modules (`aggregate_sum`, `aggregate_average`, …) with parallel naming. No behavioral changes — pure rename. All 62 grovedb + 28 grovedb-query aggregate-count tests still pass; workspace tests + clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- grovedb-query/src/aggregate_count.rs | 239 +++++++++--------- .../src/operations/proof/aggregate_count.rs | 145 ++++++----- .../src/tests/aggregate_count_query_tests.rs | 122 ++++----- 3 files changed, 269 insertions(+), 237 deletions(-) diff --git a/grovedb-query/src/aggregate_count.rs b/grovedb-query/src/aggregate_count.rs index 69f29c9c5..b1d6317eb 100644 --- a/grovedb-query/src/aggregate_count.rs +++ b/grovedb-query/src/aggregate_count.rs @@ -1,18 +1,23 @@ -//! `AggregateCountOnRange` (ACOR) Query helpers and validation. +//! `AggregateCountOnRange` Query helpers and validation. //! -//! This module owns the Query-level construction, detection, and validation -//! of `AggregateCountOnRange` queries. ACOR comes in two shapes: +//! This module owns the Query-level construction, detection, and +//! validation of `AggregateCountOnRange` queries. They come in two +//! shapes: //! //! - **Leaf** — a query whose single item is `AggregateCountOnRange(_)`. //! Produces a single `u64` count over the inner range. //! -//! - **Carrier** — a query whose items are `Key(_)` / `Range*(_)` and whose -//! `default_subquery_branch.subquery` resolves (after walking the optional -//! `subquery_path`) to a valid leaf ACOR. Produces one `u64` per matched -//! outer key — the natural per-outer-key extension of the leaf shape. +//! - **Carrier** — a query whose items are `Key(_)` / `Range*(_)` and +//! whose `default_subquery_branch.subquery` resolves (after walking +//! the optional `subquery_path`) to a valid leaf +//! `AggregateCountOnRange`. Produces one `u64` per matched outer +//! key — the natural per-outer-key extension of the leaf shape. //! -//! All ACOR validation lives in this file so the much larger `Query` core -//! in `query.rs` stays focused on the general-purpose query plumbing. +//! All aggregate-count validation lives in this file so the much larger +//! `Query` core in `query.rs` stays focused on the general-purpose +//! query plumbing. Forthcoming aggregate variants (sum, average) will +//! live in sibling modules (`aggregate_sum`, `aggregate_average`, …) +//! with parallel naming. use crate::{error::Error, query::Query, query_item::QueryItem}; @@ -59,9 +64,9 @@ impl Query { /// `AggregateCountOnRange` is a *terminal* item: a well-formed query /// either contains exactly one `AggregateCountOnRange` at the top /// level and nothing else (leaf shape) or contains - /// `Key`/`Range*` items at the top level with an ACOR nested in the + /// `Key`/`Range*` items at the top level with an aggregate-count nested in the /// `default_subquery_branch.subquery` (carrier shape). This recursive - /// detector exists so the prover can validate up front: if any ACOR + /// detector exists so the prover can validate up front: if any aggregate-count /// is present anywhere, the query as a whole must satisfy /// [`Self::validate_aggregate_count_on_range`] — otherwise a malformed /// shape could slip past a top-level-only check and be silently routed @@ -109,10 +114,10 @@ impl Query { /// `PathQuery` / `SizedQuery` layer. pub fn validate_aggregate_count_on_range(&self) -> Result<&QueryItem, Error> { if self.aggregate_count_on_range().is_some() { - // Owns an ACOR at this level → leaf shape. + // Owns an aggregate-count at this level → leaf shape. self.validate_leaf_aggregate_count_on_range() } else if self.has_aggregate_count_on_range_anywhere() { - // Doesn't own an ACOR but a nested subquery does → carrier shape. + // Doesn't own an aggregate-count but a nested subquery does → carrier shape. self.validate_carrier_aggregate_count_on_range() } else { Err(Error::InvalidOperation( @@ -194,7 +199,7 @@ impl Query { /// Validates the carrier shape: an outer query whose items are /// `Key`/`Range`-like (NOT `AggregateCountOnRange`), and whose - /// `default_subquery_branch.subquery` resolves to a valid leaf ACOR + /// `default_subquery_branch.subquery` resolves to a valid leaf `AggregateCountOnRange` /// query (possibly after walking a `subquery_path`). /// /// Returns a reference to the leaf's inner range `QueryItem` — the @@ -205,10 +210,10 @@ impl Query { /// 1. Items must be non-empty. /// 2. Each item must be `Key(_)` or a `Range*(_)` variant — explicitly /// NOT `AggregateCountOnRange` (those route through the leaf - /// validator) and NOT `RangeFull` (use a leaf ACOR on the parent + /// validator) and NOT `RangeFull` (use a leaf `AggregateCountOnRange` on the parent /// instead). /// 3. `default_subquery_branch.subquery` must be `Some(_)`. Its target - /// query must itself validate as a leaf ACOR query. + /// query must itself validate as a leaf `AggregateCountOnRange` query. /// 4. `default_subquery_branch.subquery_path` may be `Some(_)` /// (typically names the path from each outer-key match to the leaf /// subtree). When set, every element must be a non-empty key. @@ -249,7 +254,7 @@ impl Query { None => { return Err(Error::InvalidOperation( "carrier AggregateCountOnRange query must set \ - default_subquery_branch.subquery to a leaf ACOR query", + default_subquery_branch.subquery to a leaf `AggregateCountOnRange` query", )); } }; @@ -268,7 +273,7 @@ impl Query { branches (out of scope for this feature)", )); } - // The subquery must validate as a leaf ACOR (which is what the + // The subquery must validate as a leaf `AggregateCountOnRange` (which is what the // proof descent will ultimately consume). subquery.validate_leaf_aggregate_count_on_range() } @@ -280,20 +285,20 @@ mod tests { use crate::{query_item::QueryItem, Query, SubqueryBranch}; - // ---------- Leaf-ACOR validation tests ---------- + // ---------- Leaf aggregate-count validation tests ---------- // // These hit each numbered rule in // `Query::validate_leaf_aggregate_count_on_range` independently. The // happy path is also covered to ensure the success arm returns the // inner range. - fn make_acor_query(inner: QueryItem) -> Query { + fn make_aggregate_count_query(inner: QueryItem) -> Query { Query::new_aggregate_count_on_range(inner) } #[test] - fn validate_acor_happy_path_returns_inner() { - let q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + fn validate_aggregate_count_happy_path_returns_inner() { + let q = make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); let inner = q .validate_aggregate_count_on_range() .expect("happy path should validate"); @@ -307,8 +312,8 @@ mod tests { } #[test] - fn validate_acor_rejects_extra_items() { - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + fn validate_aggregate_count_rejects_extra_items() { + let mut q = make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); q.items.push(QueryItem::Key(b"extra".to_vec())); let err = q .validate_aggregate_count_on_range() @@ -317,20 +322,20 @@ mod tests { } #[test] - fn validate_acor_rejects_non_acor_only_item() { + fn validate_aggregate_count_rejects_non_aggregate_count_only_item() { // A query with one item that isn't AggregateCountOnRange triggers the // "validate called on a query without an AggregateCountOnRange item" // branch. let q = Query::new_single_query_item(QueryItem::Key(b"k".to_vec())); let err = q .validate_aggregate_count_on_range() - .expect_err("non-ACOR-only item must fail"); + .expect_err("non-aggregate-count-only item must fail"); assert!(matches!(err, crate::error::Error::InvalidOperation(_))); } #[test] - fn validate_acor_rejects_inner_key() { - let q = make_acor_query(QueryItem::Key(b"k".to_vec())); + fn validate_aggregate_count_rejects_inner_key() { + let q = make_aggregate_count_query(QueryItem::Key(b"k".to_vec())); let err = q .validate_aggregate_count_on_range() .expect_err("inner Key must fail"); @@ -341,8 +346,8 @@ mod tests { } #[test] - fn validate_acor_rejects_inner_range_full() { - let q = make_acor_query(QueryItem::RangeFull(std::ops::RangeFull)); + fn validate_aggregate_count_rejects_inner_range_full() { + let q = make_aggregate_count_query(QueryItem::RangeFull(std::ops::RangeFull)); let err = q .validate_aggregate_count_on_range() .expect_err("inner RangeFull must fail"); @@ -353,15 +358,15 @@ mod tests { } #[test] - fn validate_acor_rejects_nested_acor() { + fn validate_aggregate_count_rejects_nested_aggregate_count() { // AggregateCountOnRange wrapping another AggregateCountOnRange. - let inner_acor = QueryItem::AggregateCountOnRange(Box::new(QueryItem::Range( + let inner_aggregate_count = QueryItem::AggregateCountOnRange(Box::new(QueryItem::Range( b"a".to_vec()..b"z".to_vec(), ))); - let q = make_acor_query(inner_acor); + let q = make_aggregate_count_query(inner_aggregate_count); let err = q .validate_aggregate_count_on_range() - .expect_err("nested ACOR must fail"); + .expect_err("nested `AggregateCountOnRange` must fail"); match err { crate::error::Error::InvalidOperation(msg) => { assert!(msg.contains("AggregateCountOnRange")) @@ -371,8 +376,8 @@ mod tests { } #[test] - fn validate_acor_rejects_default_subquery_branch() { - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + fn validate_aggregate_count_rejects_default_subquery_branch() { + let mut q = make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); q.default_subquery_branch = SubqueryBranch { subquery_path: None, subquery: Some(Box::new(Query::new())), @@ -387,8 +392,8 @@ mod tests { } #[test] - fn validate_acor_rejects_default_subquery_path() { - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + fn validate_aggregate_count_rejects_default_subquery_path() { + let mut q = make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); q.default_subquery_branch = SubqueryBranch { subquery_path: Some(vec![b"x".to_vec()]), subquery: None, @@ -403,8 +408,8 @@ mod tests { } #[test] - fn validate_acor_rejects_conditional_subquery_branches() { - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + fn validate_aggregate_count_rejects_conditional_subquery_branches() { + let mut q = make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); let mut branches = IndexMap::new(); branches.insert( QueryItem::Key(b"k".to_vec()), @@ -426,10 +431,10 @@ mod tests { } #[test] - fn validate_acor_accepts_empty_conditional_branches_map() { + fn validate_aggregate_count_accepts_empty_conditional_branches_map() { // An empty `Some(IndexMap::new())` is treated as "no branches" by the // validator (the rule enforces non-empty rejection only). - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + let mut q = make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); q.conditional_subquery_branches = Some(IndexMap::new()); let inner = q .validate_aggregate_count_on_range() @@ -438,12 +443,12 @@ mod tests { } #[test] - fn aggregate_count_on_range_helper_detects_acor_anywhere_in_items() { - // Well-formed shape — single ACOR item. - let q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + fn aggregate_count_on_range_helper_detects_aggregate_count_anywhere_in_items() { + // Well-formed shape — single aggregate-count item. + let q = make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); assert!(q.aggregate_count_on_range().is_some()); - // Two items including ACOR → still detected, so the routing layer + // Two items including aggregate-count → still detected, so the routing layer // can hand the malformed query to validate_aggregate_count_on_range // for a precise error rather than silently treating it as a regular // query. @@ -451,17 +456,17 @@ mod tests { q2.items.push(QueryItem::Key(b"x".to_vec())); assert!( q2.aggregate_count_on_range().is_some(), - "ACOR + extra item must still be detected as ACOR-bearing" + "aggregate-count + extra item must still be detected as aggregate-count-bearing" ); - // ACOR not at index 0 — also detected. + // aggregate-count not at index 0 — also detected. let mut q3 = Query::new_single_query_item(QueryItem::Key(b"x".to_vec())); q3.items.push(QueryItem::AggregateCountOnRange(Box::new( QueryItem::Range(b"a".to_vec()..b"z".to_vec()), ))); assert!(q3.aggregate_count_on_range().is_some()); - // No ACOR anywhere → None. + // No aggregate-count anywhere → None. let q4 = Query::new_single_query_item(QueryItem::Key(b"x".to_vec())); assert!(q4.aggregate_count_on_range().is_none()); @@ -472,64 +477,66 @@ mod tests { #[test] fn has_aggregate_count_on_range_anywhere_walks_subqueries() { - // No ACOR anywhere → false. + // No aggregate-count anywhere → false. let plain = Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); assert!(!plain.has_aggregate_count_on_range_anywhere()); - // Top-level ACOR → true (covered by `aggregate_count_on_range` too). - let top = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + // Top-level aggregate-count → true (covered by `aggregate_count_on_range` too). + let top = make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); assert!(top.has_aggregate_count_on_range_anywhere()); - // ACOR hidden inside `default_subquery_branch.subquery` — the + // aggregate-count hidden inside `default_subquery_branch.subquery` — the // top-level-only `aggregate_count_on_range` would miss it, but the // recursive helper finds it. This is the surface that the // prove_query entry-point gate uses to refuse to run any - // ACOR-bearing query that isn't a canonical leaf-or-carrier shape. - let inner_acor = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + // aggregate-count-bearing query that isn't a canonical leaf-or-carrier shape. + let inner_aggregate_count = + make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); let mut hidden = Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - hidden.set_subquery(inner_acor); + hidden.set_subquery(inner_aggregate_count); assert!(hidden.aggregate_count_on_range().is_none()); assert!( hidden.has_aggregate_count_on_range_anywhere(), - "ACOR hidden in default subquery branch must be detected" + "aggregate-count hidden in default subquery branch must be detected" ); - // ACOR hidden in a conditional subquery branch. - let inner_acor2 = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + // aggregate-count hidden in a conditional subquery branch. + let inner_aggregate_count2 = + make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); let mut conditional = Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); conditional.add_conditional_subquery( QueryItem::Key(b"k".to_vec()), None, - Some(inner_acor2), + Some(inner_aggregate_count2), ); assert!( conditional.has_aggregate_count_on_range_anywhere(), - "ACOR hidden in conditional subquery branch must be detected" + "aggregate-count hidden in conditional subquery branch must be detected" ); } - // ---------- Carrier ACOR validation tests ---------- + // ---------- Carrier aggregate-count validation tests ---------- // // The carrier shape is an outer query with `Key`/`Range*` items whose - // `default_subquery_branch.subquery` resolves to a leaf ACOR query. + // `default_subquery_branch.subquery` resolves to a leaf `AggregateCountOnRange` query. // It is the multi-outer-key extension of the leaf shape, returning one // count per outer key. These tests verify the // `validate_carrier_aggregate_count_on_range` rules and the dispatcher // behavior of the top-level `validate_aggregate_count_on_range`. - fn make_leaf_acor_subquery() -> Query { - make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())) + fn make_leaf_aggregate_count_subquery() -> Query { + make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())) } #[test] - fn validate_carrier_acor_happy_path_keys_outer_with_subquery_path() { + fn validate_carrier_aggregate_count_happy_path_keys_outer_with_subquery_path() { let mut carrier = Query::new(); carrier.items.push(QueryItem::Key(b"brand_000".to_vec())); carrier.items.push(QueryItem::Key(b"brand_001".to_vec())); carrier.set_subquery_path(vec![b"color".to_vec()]); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); // Top-level dispatcher accepts the carrier and returns the leaf's // inner range. let inner = carrier @@ -540,35 +547,35 @@ mod tests { carrier .validate_carrier_aggregate_count_on_range() .expect("carrier validator should accept"); - // Leaf validator must reject (carrier-level items aren't ACOR). + // Leaf validator must reject (carrier-level items aren't aggregate-count). assert!(carrier.validate_leaf_aggregate_count_on_range().is_err()); } #[test] - fn validate_carrier_acor_happy_path_no_subquery_path() { - // subquery_path is optional — the leaf ACOR may be directly under + fn validate_carrier_aggregate_count_happy_path_no_subquery_path() { + // subquery_path is optional — the leaf `AggregateCountOnRange` may be directly under // each outer match. let mut carrier = Query::new(); carrier.items.push(QueryItem::Key(b"a".to_vec())); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); carrier .validate_aggregate_count_on_range() .expect("carrier without subquery_path should validate"); } #[test] - fn validate_carrier_acor_rejects_acor_at_both_levels() { - // Carrier itself owns an ACOR AND its subquery is also an ACOR. + fn validate_carrier_aggregate_count_rejects_aggregate_count_at_both_levels() { + // Carrier itself owns an aggregate-count AND its subquery is also an aggregate-count. // The top-level dispatcher routes to the LEAF validator first // (because aggregate_count_on_range() returns Some at carrier // level), so the leaf's "single item" rule catches the - // ACOR-in-subquery shape via the items-len check or the + // aggregate-count-in-subquery shape via the items-len check or the // no-subquery rule. Either way the error fires. - let mut q = make_acor_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); - q.set_subquery(make_leaf_acor_subquery()); + let mut q = make_aggregate_count_query(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + q.set_subquery(make_leaf_aggregate_count_subquery()); let err = q .validate_aggregate_count_on_range() - .expect_err("ACOR at both levels must fail"); + .expect_err("aggregate-count at both levels must fail"); match err { crate::error::Error::InvalidOperation(msg) => { assert!( @@ -581,12 +588,12 @@ mod tests { } #[test] - fn validate_carrier_acor_rejects_range_full_outer() { + fn validate_carrier_aggregate_count_rejects_range_full_outer() { let mut carrier = Query::new(); carrier .items .push(QueryItem::RangeFull(std::ops::RangeFull)); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); let err = carrier .validate_aggregate_count_on_range() .expect_err("RangeFull outer must fail"); @@ -599,10 +606,10 @@ mod tests { } #[test] - fn validate_carrier_acor_rejects_acor_outer_item() { + fn validate_carrier_aggregate_count_rejects_aggregate_count_outer_item() { // Both a Key and an AggregateCountOnRange item at the carrier // level. The leaf validator's items-len check fires first (since - // there's an ACOR item in items, aggregate_count_on_range() + // there's an aggregate-count item in items, aggregate_count_on_range() // returns Some, and len != 1). let mut carrier = Query::new(); carrier.items.push(QueryItem::Key(b"k".to_vec())); @@ -611,18 +618,18 @@ mod tests { .push(QueryItem::AggregateCountOnRange(Box::new( QueryItem::Range(b"a".to_vec()..b"z".to_vec()), ))); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); let err = carrier .validate_aggregate_count_on_range() - .expect_err("ACOR + Key outer items must fail"); + .expect_err("aggregate-count + Key outer items must fail"); assert!(matches!(err, crate::error::Error::InvalidOperation(_))); } #[test] - fn validate_carrier_acor_rejects_carrier_with_missing_subquery() { + fn validate_carrier_aggregate_count_rejects_carrier_with_missing_subquery() { // Outer items present but no subquery → not a carrier (and not a // leaf), so the top-level dispatcher routes to the - // "not an ACOR query" error. + // "not an aggregate-count query" error. let mut carrier = Query::new(); carrier.items.push(QueryItem::Key(b"k".to_vec())); let err = carrier @@ -632,12 +639,12 @@ mod tests { } #[test] - fn validate_carrier_acor_rejects_non_acor_subquery() { - // Outer Keys + subquery that is NOT an ACOR (just a regular range - // query) → not a valid carrier ACOR. The top-level dispatcher + fn validate_carrier_aggregate_count_rejects_non_aggregate_count_subquery() { + // Outer Keys + subquery that is NOT an aggregate-count (just a regular range + // query) → not a valid carrier aggregate-count. The top-level dispatcher // sees `has_aggregate_count_on_range_anywhere() == false`, so it - // surfaces the "not an ACOR query" error rather than the carrier - // validator's "subquery must validate as leaf ACOR" error. + // surfaces the "not an aggregate-count query" error rather than the carrier + // validator's "subquery must validate as leaf `AggregateCountOnRange`" error. let mut carrier = Query::new(); carrier.items.push(QueryItem::Key(b"k".to_vec())); let regular_sub = @@ -645,19 +652,19 @@ mod tests { carrier.set_subquery(regular_sub); let err = carrier .validate_aggregate_count_on_range() - .expect_err("non-ACOR subquery must fail"); + .expect_err("non-aggregate-count subquery must fail"); assert!(matches!(err, crate::error::Error::InvalidOperation(_))); } #[test] - fn validate_carrier_acor_rejects_conditional_branches() { + fn validate_carrier_aggregate_count_rejects_conditional_branches() { let mut carrier = Query::new(); carrier.items.push(QueryItem::Key(b"k".to_vec())); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); carrier.add_conditional_subquery( QueryItem::Key(b"k".to_vec()), None, - Some(make_leaf_acor_subquery()), + Some(make_leaf_aggregate_count_subquery()), ); let err = carrier .validate_aggregate_count_on_range() @@ -671,11 +678,11 @@ mod tests { } #[test] - fn validate_carrier_acor_rejects_empty_outer_items() { - // Empty items + leaf ACOR subquery → not a valid carrier. + fn validate_carrier_aggregate_count_rejects_empty_outer_items() { + // Empty items + leaf `AggregateCountOnRange` subquery → not a valid carrier. // (Empty outer means no outer key to iterate; doesn't make sense.) let mut carrier = Query::new(); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); let err = carrier .validate_carrier_aggregate_count_on_range() .expect_err("empty outer items must fail"); @@ -683,18 +690,18 @@ mod tests { } #[test] - fn validate_carrier_acor_rejects_carrier_subquery_with_invalid_inner() { + fn validate_carrier_aggregate_count_rejects_carrier_subquery_with_invalid_inner() { // The carrier validator delegates to the leaf validator for the - // subquery, so a malformed leaf ACOR (e.g. wrapping `Key`) is + // subquery, so a malformed leaf `AggregateCountOnRange` (e.g. wrapping `Key`) is // surfaced via the carrier path. Pin the exact rejection message // so a refactor that re-routes the rejection through a different // arm doesn't silently accept the malformed shape. let mut carrier = Query::new(); carrier.items.push(QueryItem::Key(b"k".to_vec())); - carrier.set_subquery(make_acor_query(QueryItem::Key(b"k".to_vec()))); + carrier.set_subquery(make_aggregate_count_query(QueryItem::Key(b"k".to_vec()))); let err = carrier .validate_aggregate_count_on_range() - .expect_err("malformed inner Key in subquery ACOR must fail"); + .expect_err("malformed inner Key in subquery aggregate-count must fail"); match err { crate::error::Error::InvalidOperation(msg) => assert!( msg.contains("may not wrap Key"), @@ -705,14 +712,14 @@ mod tests { } #[test] - fn validate_carrier_acor_rejects_empty_subquery_path_element() { + fn validate_carrier_aggregate_count_rejects_empty_subquery_path_element() { // A carrier's subquery_path may not contain empty keys — those // would point at "no key" in the intermediate descent, which the // merk single-key prover can't satisfy. let mut carrier = Query::new(); carrier.items.push(QueryItem::Key(b"k".to_vec())); carrier.set_subquery_path(vec![b"".to_vec()]); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); let err = carrier .validate_aggregate_count_on_range() .expect_err("empty subquery_path key must fail"); @@ -725,7 +732,7 @@ mod tests { } #[test] - fn validate_carrier_acor_accepts_range_outer_items() { + fn validate_carrier_aggregate_count_accepts_range_outer_items() { // A carrier may use Range outer items (the spec leaves room for // this). Verify the validator agrees for every Range* variant // the rule whitelists. @@ -741,7 +748,7 @@ mod tests { ] { let mut carrier = Query::new(); carrier.items.push(outer); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); carrier .validate_aggregate_count_on_range() .expect("carrier with Range* outer should validate"); @@ -749,13 +756,13 @@ mod tests { } #[test] - fn validate_acor_dispatcher_rejects_non_acor_query() { - // The top-level dispatcher returns the "not an ACOR" error when + fn validate_aggregate_count_dispatcher_rejects_non_aggregate_count_query() { + // The top-level dispatcher returns the "not an aggregate-count" error when // neither shape matches. let q = Query::new_single_query_item(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); let err = q .validate_aggregate_count_on_range() - .expect_err("non-ACOR query must fail"); + .expect_err("non-aggregate-count query must fail"); match err { crate::error::Error::InvalidOperation(msg) => assert!(msg.contains( "validate_aggregate_count_on_range called on a query \ @@ -770,12 +777,12 @@ mod tests { // The top-level dispatcher classifies first and routes to either // `validate_leaf_aggregate_count_on_range` or // `validate_carrier_aggregate_count_on_range`. Some carrier rules - // (no subquery, ACOR outer item) get masked by classification — + // (no subquery, aggregate-count outer item) get masked by classification — // calling the carrier validator directly is the only way to exercise // them. These tests pin those branches. #[test] - fn validate_carrier_acor_direct_rejects_missing_subquery() { + fn validate_carrier_aggregate_count_direct_rejects_missing_subquery() { // Carrier-shaped items but no subquery — the carrier // validator's "subquery must be Some" branch fires. let mut carrier = Query::new(); @@ -792,8 +799,8 @@ mod tests { } #[test] - fn validate_carrier_acor_direct_rejects_acor_outer_item() { - // ACOR appears in outer items + a leaf subquery is set. The + fn validate_carrier_aggregate_count_direct_rejects_aggregate_count_outer_item() { + // aggregate-count appears in outer items + a leaf subquery is set. The // top-level dispatcher routes to the leaf validator (because // `aggregate_count_on_range()` returns Some at the carrier // level); calling the carrier validator directly is the only @@ -804,10 +811,10 @@ mod tests { .push(QueryItem::AggregateCountOnRange(Box::new( QueryItem::Range(b"a".to_vec()..b"z".to_vec()), ))); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); let err = carrier .validate_carrier_aggregate_count_on_range() - .expect_err("ACOR outer item via direct carrier validator must fail"); + .expect_err("aggregate-count outer item via direct carrier validator must fail"); match err { crate::error::Error::InvalidOperation(msg) => assert!( msg.contains("may not own an") || msg.contains("AggregateCountOnRange"), @@ -818,14 +825,14 @@ mod tests { } #[test] - fn validate_carrier_acor_direct_rejects_range_full_outer() { + fn validate_carrier_aggregate_count_direct_rejects_range_full_outer() { // RangeFull outer + leaf subquery — exercise the carrier // validator's `RangeFull` arm directly. let mut carrier = Query::new(); carrier .items .push(QueryItem::RangeFull(std::ops::RangeFull)); - carrier.set_subquery(make_leaf_acor_subquery()); + carrier.set_subquery(make_leaf_aggregate_count_subquery()); let err = carrier .validate_carrier_aggregate_count_on_range() .expect_err("RangeFull outer via direct carrier validator must fail"); diff --git a/grovedb/src/operations/proof/aggregate_count.rs b/grovedb/src/operations/proof/aggregate_count.rs index cd44aa753..a376adee5 100644 --- a/grovedb/src/operations/proof/aggregate_count.rs +++ b/grovedb/src/operations/proof/aggregate_count.rs @@ -23,13 +23,18 @@ //! //! - **Carrier** — an outer query whose items are `Key(_)` / `Range*(_)` //! (one IN-style fan-out dimension) and whose -//! `default_subquery_branch.subquery` resolves to a leaf ACOR query. -//! The proof descends `path_query.path` via single-key checks, then at -//! the carrier merk it produces a multi-key proof over the outer items; -//! each matched outer key recurses through the `subquery_path` (if any) -//! to a leaf merk that produces its own count. The verifier returns one -//! `(outer_key, count)` pair per matched outer key. Surfaced through +//! `default_subquery_branch.subquery` resolves to a leaf +//! `AggregateCountOnRange`. The proof descends `path_query.path` via +//! single-key checks, then at the carrier merk it produces a multi-key +//! proof over the outer items; each matched outer key recurses through +//! the `subquery_path` (if any) to a leaf merk that produces its own +//! count. The verifier returns one `(outer_key, count)` pair per +//! matched outer key. Surfaced through //! [`GroveDb::verify_aggregate_count_query_per_key`]. +//! +//! The same leaf/carrier shape will apply to forthcoming aggregate +//! variants (sum, average) — each will get its own sibling module under +//! `grovedb/src/operations/proof/` with parallel naming. use grovedb_merk::{ proofs::{ @@ -56,15 +61,17 @@ impl GroveDb { /// [`PathQuery::validate_aggregate_count_on_range`] and additionally must /// be the **leaf** shape — a single `AggregateCountOnRange(_)` item, no /// subqueries, no pagination, and an inner range that isn't `Key`, - /// `RangeFull`, or another `AggregateCountOnRange`. Carrier-shape ACOR - /// queries (outer `Keys` + ACOR subquery) must use + /// `RangeFull`, or another `AggregateCountOnRange`. Carrier-shape + /// aggregate-count queries (outer `Keys` + `AggregateCountOnRange` + /// subquery) must use /// [`GroveDb::verify_aggregate_count_query_per_key`] instead. /// /// `AggregateCountOnRange` requires **V1 proof envelopes** /// (`GroveDBProofV1`). V0 (`GroveDBProofV0` / `MerkOnlyLayerProof`) - /// envelopes predate the ACOR feature and are only produced by grove - /// versions older than the one used by Dash Platform v12; this entry - /// point rejects them with `Error::InvalidProof`. + /// envelopes predate the aggregate-count feature and are only + /// produced by grove versions older than the one used by Dash + /// Platform v12; this entry point rejects them with + /// `Error::InvalidProof`. /// /// Returns: /// - `root_hash` — the reconstructed GroveDB root hash. The caller is @@ -97,8 +104,8 @@ impl GroveDb { ); // Validate at the PathQuery level so SizedQuery::limit / offset - // (which ACOR explicitly forbids) are enforced alongside the - // inner-Query shape rules. + // (which aggregate-count explicitly forbids) are enforced + // alongside the inner-Query shape rules. let inner_range = path_query.validate_leaf_aggregate_count_on_range()?.clone(); let grovedb_proof = decode_grovedb_proof(proof)?; @@ -116,31 +123,36 @@ impl GroveDb { Ok((root_hash, count)) } - /// Verify a serialized `prove_query` proof against an ACOR `PathQuery` - /// in either the leaf or carrier shape, returning one - /// `(outer_key, count)` pair per matched outer key. + /// Verify a serialized `prove_query` proof against an + /// `AggregateCountOnRange` `PathQuery` in either the leaf or carrier + /// shape, returning one `(outer_key, count)` pair per matched outer + /// key. /// - /// For a **leaf** ACOR query the returned vector contains exactly one - /// entry whose key is an empty byte string and whose count is the same - /// `u64` [`GroveDb::verify_aggregate_count_query`] would have returned. - /// This makes carrier and leaf consumers symmetric: callers that always - /// process a `Vec<(Vec, u64)>` don't need to branch on the shape. + /// For a **leaf** aggregate-count query the returned vector contains + /// exactly one entry whose key is an empty byte string and whose + /// count is the same `u64` + /// [`GroveDb::verify_aggregate_count_query`] would have returned. + /// This makes carrier and leaf consumers symmetric: callers that + /// always process a `Vec<(Vec, u64)>` don't need to branch on + /// the shape. /// - /// For a **carrier** ACOR query the outer items must be `Key(_)` / - /// `Range*(_)`, the `default_subquery_branch.subquery` must validate as a - /// leaf ACOR, and the optional `subquery_path` is followed exactly - /// (single-key descent per element) before the count proof. The returned - /// vector has one entry per matched outer key in **query-direction - /// order**: when the carrier's `left_to_right` is `true` (the default, - /// matching the merk prover's natural walk) entries come back in - /// ascending lexicographic key order; when `left_to_right` is `false` - /// they come back in descending order, mirroring the merk proof's own + /// For a **carrier** aggregate-count query the outer items must be + /// `Key(_)` / `Range*(_)`, the `default_subquery_branch.subquery` + /// must validate as a leaf `AggregateCountOnRange`, and the optional + /// `subquery_path` is followed exactly (single-key descent per + /// element) before the count proof. The returned vector has one + /// entry per matched outer key in **query-direction order**: when + /// the carrier's `left_to_right` is `true` (the default, matching + /// the merk prover's natural walk) entries come back in ascending + /// lexicographic key order; when `left_to_right` is `false` they + /// come back in descending order, mirroring the merk proof's own /// emission order. Outer-key candidates that the prover proved as /// absent contribute no entry. /// /// Like [`GroveDb::verify_aggregate_count_query`], this entry point - /// requires **V1 proof envelopes**. V0 envelopes predate ACOR and are - /// rejected with `Error::InvalidProof`. + /// requires **V1 proof envelopes**. V0 envelopes predate the + /// aggregate-count feature and are rejected with + /// `Error::InvalidProof`. /// /// Cryptographic guarantees: /// - Every layer is committed via the same `combine_hash(H(value), @@ -167,7 +179,7 @@ impl GroveDb { // Classify the query and extract the leaf inner range plus the // optional carrier subquery_path. For leaf queries the carrier // descent below is skipped (carrier_outer_items is None). - let classification = classify_path_query(path_query)?; + let classification = classify_aggregate_count_path_query(path_query)?; let grovedb_proof = decode_grovedb_proof(proof)?; let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); @@ -184,10 +196,11 @@ impl GroveDb { } /// Extract the V1 root layer from a `GroveDBProof` envelope, or refuse -/// the proof. ACOR (both leaf and carrier) requires V1 envelopes — the -/// V0 (`MerkOnlyLayerProof`) envelope predates ACOR and is only emitted -/// by grove versions older than the one used by Dash Platform v12, so -/// it cannot legitimately contain an ACOR proof. +/// the proof. `AggregateCountOnRange` (both leaf and carrier) requires +/// V1 envelopes — the V0 (`MerkOnlyLayerProof`) envelope predates the +/// aggregate-count feature and is only emitted by grove versions older +/// than the one used by Dash Platform v12, so it cannot legitimately +/// contain an aggregate-count proof. fn require_v1_envelope<'a>( proof: &'a GroveDBProof, path_query: &PathQuery, @@ -203,11 +216,17 @@ fn require_v1_envelope<'a>( } } -/// Classification of an ACOR `PathQuery`. Encodes either the leaf-only -/// inner range (no carrier descent) or the carrier outer items + leaf -/// inner range + optional subquery_path that the verifier must follow -/// per outer key. -struct AcorClassification { +/// Classification of an `AggregateCountOnRange` `PathQuery`. Encodes +/// either the leaf-only inner range (no carrier descent) or the +/// carrier outer items + leaf inner range + optional `subquery_path` +/// that the verifier must follow per outer key. +/// +/// Forthcoming aggregate variants (sum, average) will define their own +/// parallel classification types (`AggregateSumClassification`, +/// `AggregateAverageClassification`, …) — the leaf-vs-carrier shape is +/// a property of any aggregate-on-range query, but each variant carries +/// its own kind of inner descriptor. +struct AggregateCountClassification { /// The inner range that the leaf merk count proof must satisfy. leaf_inner_range: QueryItem, /// Carrier outer items. `None` for leaf-only queries. @@ -222,19 +241,22 @@ struct AcorClassification { carrier_left_to_right: bool, } -fn classify_path_query(path_query: &PathQuery) -> Result { +fn classify_aggregate_count_path_query( + path_query: &PathQuery, +) -> Result { // Validate at the PathQuery level so SizedQuery::limit / offset - // (which ACOR explicitly forbids) are enforced alongside the - // inner-Query shape rules — for both the leaf and the carrier branch - // below. + // (which aggregate-count explicitly forbids) are enforced alongside + // the inner-Query shape rules — for both the leaf and the carrier + // branch below. let leaf_inner = path_query.validate_aggregate_count_on_range()?.clone(); let q = &path_query.query.query; if q.aggregate_count_on_range().is_some() { - // Leaf shape: top-level ACOR item. The top-level - // `validate_aggregate_count_on_range` dispatcher above routed - // through the leaf validator, so we already know `leaf_inner` is - // the inner range of the top-level ACOR item. - return Ok(AcorClassification { + // Leaf shape: top-level `AggregateCountOnRange` item. The + // top-level `validate_aggregate_count_on_range` dispatcher above + // routed through the leaf validator, so we already know + // `leaf_inner` is the inner range of the top-level + // `AggregateCountOnRange` item. + return Ok(AggregateCountClassification { leaf_inner_range: leaf_inner, carrier_outer_items: None, carrier_subquery_path: None, @@ -250,7 +272,7 @@ fn classify_path_query(path_query: &PathQuery) -> Result Result<(CryptoHash, Vec<(Vec, u64)>), Error> { verify_v1_per_key( @@ -343,7 +365,7 @@ fn verify_v1_per_key( path_query: &PathQuery, path_keys: &[&[u8]], depth: usize, - classification: &AcorClassification, + classification: &AggregateCountClassification, grove_version: &GroveVersion, ) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; @@ -402,7 +424,7 @@ fn verify_v1_carrier_layer( merk_bytes: &[u8], path_query: &PathQuery, outer_items: &[QueryItem], - classification: &AcorClassification, + classification: &AggregateCountClassification, grove_version: &GroveVersion, ) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { let (carrier_root, matched) = execute_carrier_layer_proof( @@ -428,7 +450,7 @@ fn verify_v1_carrier_layer( Error::InvalidProof( path_query.clone(), format!( - "carrier ACOR proof missing lower layer for outer key {}", + "carrier aggregate-count proof missing lower layer for outer key {}", hex::encode(&outer_key) ), ) @@ -476,7 +498,7 @@ fn verify_v1_subquery_path( Error::InvalidProof( path_query.clone(), format!( - "carrier ACOR proof missing subquery_path layer for key {}", + "carrier aggregate-count proof missing subquery_path layer for key {}", hex::encode(&next_key) ), ) @@ -637,7 +659,10 @@ fn execute_carrier_layer_proof( .map_err(|e| { Error::InvalidProof( path_query.clone(), - format!("carrier ACOR multi-key proof failed to verify: {}", e), + format!( + "carrier aggregate-count multi-key proof failed to verify: {}", + e + ), ) })?; @@ -647,7 +672,7 @@ fn execute_carrier_layer_proof( Error::InvalidProof( path_query.clone(), format!( - "carrier ACOR proof returned a result row without value bytes for key {}", + "carrier aggregate-count proof returned a result row without value bytes for key {}", hex::encode(&proved.key) ), ) diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index 2eb697080..822b73b1b 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -735,16 +735,16 @@ mod tests { #[test] fn aggregate_count_with_missing_path_and_invalid_inner_is_rejected_at_entry() { // Codex finding: validation only fires inside `prove_subqueries` when - // the recursion reaches the ACOR-bearing leaf level. If the path + // the recursion reaches the aggregate-count-bearing leaf level. If the path // doesn't exist (e.g. "missing" key under TEST_LEAF), the recursive - // prover never sees the ACOR item and the malformed query is allowed + // prover never sees the aggregate-count item and the malformed query is allowed // to return a regular path/absence proof. Fix: validate at the // `prove_query` entry point, before any recursive dispatch. 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"missing".to_vec()], - // QueryItem::Key as the inner range is invalid for ACOR. + // QueryItem::Key as the inner range is invalid for aggregate-count. QueryItem::Key(b"k".to_vec()), ); let prove_result = db.grove_db.prove_query(&path_query, None, v).unwrap(); @@ -752,11 +752,11 @@ mod tests { Err(crate::Error::InvalidQuery(msg)) => { assert!( msg.contains("AggregateCountOnRange may not wrap Key"), - "expected ACOR-Key rejection, got: {msg}" + "expected `AggregateCountOnRange`-Key rejection, got: {msg}" ); } other => panic!( - "malformed ACOR with non-existent path must be rejected at entry, got {:?}", + "malformed aggregate-count with non-existent path must be rejected at entry, got {:?}", other.map(|b| b.len()) ), } @@ -764,23 +764,23 @@ mod tests { #[test] fn aggregate_count_hidden_in_subquery_branch_with_invalid_inner_is_rejected_at_entry() { - // After the carrier-ACOR feature landed, an `AggregateCountOnRange` + // After the carrier aggregate-count feature landed, an `AggregateCountOnRange` // smuggled inside a `default_subquery_branch.subquery` is **valid** // when the surrounding query satisfies the carrier rules — that is // the whole point of the carrier shape. // // What this test still guards is the *other* malformed case: a - // carrier whose subquery is itself a malformed leaf ACOR (here, an - // ACOR wrapping `Key` — leaf rule 3). The carrier validator + // carrier whose subquery is itself a malformed leaf `AggregateCountOnRange` (here, an + // aggregate-count wrapping `Key` — leaf rule 3). The carrier validator // delegates to `validate_leaf_aggregate_count_on_range`, which // surfaces the malformed-inner error, and the prove-entry gate // refuses to run the query. let v = GroveVersion::latest(); let db = make_test_grovedb(v); - let bad_inner_acor = + let bad_inner_aggregate_count = QueryItem::AggregateCountOnRange(Box::new(QueryItem::Key(b"k".to_vec()))); let mut sub_query = grovedb_merk::proofs::Query::new(); - sub_query.insert_item(bad_inner_acor); + sub_query.insert_item(bad_inner_aggregate_count); let mut top_query = grovedb_merk::proofs::Query::new(); top_query.insert_range_inclusive(b"a".to_vec()..=b"z".to_vec()); top_query.set_subquery(sub_query); @@ -800,7 +800,7 @@ mod tests { "expected malformed-inner-Key rejection, got: {msg}" ), other => panic!( - "carrier ACOR with malformed leaf-inner Key must be rejected at entry, got {:?}", + "carrier aggregate-count with malformed leaf-inner Key must be rejected at entry, got {:?}", other.map(|b| b.len()) ), } @@ -838,11 +838,11 @@ mod tests { #[test] fn aggregate_count_rejects_grove_v2_envelope() { // GROVE_V2 dispatches to the V0 prove_query_non_serialized path, - // which produces a `MerkOnlyLayerProof` envelope. ACOR was added + // which produces a `MerkOnlyLayerProof` envelope. aggregate-count was added // after V0 envelopes were superseded by V1 (in the grove version - // used by Dash Platform v12+), so V0+ACOR is impossible in any + // used by Dash Platform v12+), so V0+aggregate-count is impossible in any // deployed Platform release. The prover rejects the combination - // up front to keep callers from emitting a V0 ACOR proof that + // up front to keep callers from emitting a V0 aggregate-count proof that // the verifier would (correctly) refuse. let v: &GroveVersion = &GROVE_V2; let (db, _root) = setup_15_key_provable_count_tree(v); @@ -857,7 +857,7 @@ mod tests { "unexpected message: {msg}" ), other => panic!( - "expected NotSupported for V0+ACOR, got {:?}", + "expected NotSupported for V0+aggregate-count, got {:?}", other.map(|b| b.len()) ), } @@ -1563,12 +1563,12 @@ mod tests { assert_eq!(count, 0, "empty tree must return 0"); } - // ---------- Carrier ACOR end-to-end tests ---------- + // ---------- Carrier aggregate-count end-to-end tests ---------- // - // A "carrier" ACOR query is an outer fan-out — the outer query items + // A "carrier" aggregate-count query is an outer fan-out — the outer query items // are `Key`/`Range*` and the `default_subquery_branch.subquery` // resolves (after walking the optional `subquery_path`) to a leaf - // ACOR. The verifier returns one `(outer_key, u64)` pair per matched + // aggregate-count. The verifier returns one `(outer_key, u64)` pair per matched // outer key. These tests exercise the full prove → encode → decode → // verify pipeline. @@ -1638,11 +1638,11 @@ mod tests { (db, root) } - /// Build a carrier ACOR `PathQuery` rooted at + /// Build a carrier aggregate-count `PathQuery` rooted at /// `[TEST_LEAF, "byBrand"]`, fanning out across `outer_keys` and /// counting elements in each brand's `color` subtree matching the /// inner range. - fn carrier_acor_path_query(outer_keys: &[&[u8]], inner_range: QueryItem) -> PathQuery { + fn carrier_count_path_query(outer_keys: &[&[u8]], inner_range: QueryItem) -> PathQuery { use grovedb_query::Query; let mut carrier = Query::new(); @@ -1662,7 +1662,7 @@ mod tests { } #[test] - fn acor_subquery_two_outer_keys_succeeds() { + fn carrier_two_outer_keys_succeeds() { // Carrier with two outer brand keys, range on the color subtree. // Expected: two (key, count) pairs in query-direction order with // the correct per-brand aggregate. The carrier defaults to @@ -1672,7 +1672,7 @@ mod tests { setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 1_000); // Pick a range that drops the lower 500 elements (`color_00000` // through `color_00499`). - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000", b"brand_001"], QueryItem::RangeAfter(b"color_00499".to_vec()..), ); @@ -1680,10 +1680,10 @@ mod tests { .grove_db .prove_query(&path_query, None, v) .unwrap() - .expect("prove_query (carrier ACOR) should succeed"); + .expect("prove_query (carrier aggregate-count) should succeed"); let (got_root, results) = GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) - .expect("verify carrier ACOR should succeed"); + .expect("verify carrier aggregate-count should succeed"); assert_eq!(got_root, expected_root, "root must match GroveDB root"); assert_eq!(results.len(), 2, "expected one result per outer key"); assert_eq!(results[0].0, b"brand_000".to_vec()); @@ -1695,7 +1695,7 @@ mod tests { } #[test] - fn acor_subquery_with_unknown_outer_key_returns_present_keys_only() { + fn carrier_with_unknown_outer_key_returns_present_keys_only() { // Spec acceptance criterion 2: an outer-key match that doesn't // exist contributes no entry to the result vector (it's an // absence, not an error). The prover doesn't emit a lower layer @@ -1704,7 +1704,7 @@ mod tests { let v = GroveVersion::latest(); let (db, expected_root) = setup_brand_color_carrier_tree(v, &[b"brand_000"], 1_000); // Ask for two brands — one present, one absent. - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000", b"brand_999_missing"], QueryItem::RangeAfter(b"color_00499".to_vec()..), ); @@ -1728,9 +1728,9 @@ mod tests { } #[test] - fn acor_subquery_rejects_acor_at_both_levels() { - // Try to build a query where the carrier ITSELF has an ACOR item - // AND its subquery is also an ACOR. The validator must reject up + fn rejects_aggregate_count_at_both_levels() { + // Try to build a query where the carrier ITSELF has an aggregate-count item + // AND its subquery is also an aggregate-count. The validator must reject up // front at prove time. use grovedb_query::Query; @@ -1744,7 +1744,7 @@ mod tests { // Validation catches it. assert!( pq.validate_aggregate_count_on_range().is_err(), - "ACOR + subquery ACOR must fail validation" + "aggregate-count + subquery aggregate-count must fail validation" ); // The prove_query entry-point gate must also reject it. let prove_result = make_test_grovedb(v).grove_db.prove_query(&pq, None, v); @@ -1755,8 +1755,8 @@ mod tests { } #[test] - fn acor_leaf_unchanged_under_per_key_verifier() { - // The leaf shape — a single-ACOR query — produces exactly the + fn leaf_unchanged_under_per_key_verifier() { + // The leaf shape — a single-`AggregateCountOnRange` query — produces exactly the // same proof bytes it did before this feature. Verifying it via // the new per-key entry point returns a one-entry Vec with an // empty key and the same count `verify_aggregate_count_query` @@ -1789,7 +1789,7 @@ mod tests { } #[test] - fn acor_subquery_carrier_with_range_outer_succeeds() { + fn carrier_with_range_outer_succeeds() { // The carrier supports a Range outer item (the per-spec // "decide-or-defer" case). With an outer `RangeAfter`, the // matched outer keys come back in lex-asc order and each @@ -1831,7 +1831,7 @@ mod tests { } #[test] - fn acor_subquery_right_to_left_returns_descending_order() { + fn carrier_right_to_left_returns_descending_order() { // Flip the carrier's `left_to_right` flag — output must come // back in descending lex order, mirroring the merk walker's // reversed emission. @@ -1855,10 +1855,10 @@ mod tests { .grove_db .prove_query(&path_query, None, v) .unwrap() - .expect("prove_query (carrier ACOR, right-to-left) should succeed"); + .expect("prove_query (carrier aggregate-count, right-to-left) should succeed"); let (got_root, results) = GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) - .expect("verify carrier ACOR (right-to-left) should succeed"); + .expect("verify carrier aggregate-count (right-to-left) should succeed"); assert_eq!(got_root, expected_root); assert_eq!(results.len(), 3, "expected 3 outer-key matches"); // Descending lex: brand_002, brand_001, brand_000. @@ -1871,8 +1871,8 @@ mod tests { } #[test] - fn acor_per_key_rejects_non_acor_path_query() { - // The per-key entry point rejects path queries that aren't ACOR + fn per_key_rejects_non_aggregate_count_path_query() { + // The per-key entry point rejects path queries that aren't aggregate-count // queries at all — neither leaf nor carrier — before decoding // proof bytes. let v = GroveVersion::latest(); @@ -1882,7 +1882,7 @@ mod tests { ); let dummy_proof = vec![0u8; 16]; let err = GroveDb::verify_aggregate_count_query_per_key(&dummy_proof, &bad_query, v) - .expect_err("non-ACOR path_query must be rejected up front"); + .expect_err("non-aggregate-count path_query must be rejected up front"); match err { crate::Error::InvalidQuery(_) => {} other => panic!("expected InvalidQuery, got {:?}", other), @@ -1890,7 +1890,7 @@ mod tests { } #[test] - fn acor_subquery_count_forgery_is_caught() { + fn carrier_count_forgery_is_caught() { // Same spirit as `count_forgery_is_caught_at_grovedb_level` but // against a carrier proof: pick the first leaf merk // `HashWithCount` op in any of the per-outer-key sub-proofs and @@ -1905,7 +1905,7 @@ mod tests { let v = GroveVersion::latest(); let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 100); - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000", b"brand_001"], QueryItem::RangeAfter(b"color_00049".to_vec()..), ); @@ -1979,13 +1979,13 @@ mod tests { } #[test] - fn acor_carrier_rejects_v0_envelope() { - // V0 proof envelopes predate ACOR and cannot legitimately carry + fn carrier_rejects_v0_envelope() { + // V0 proof envelopes predate aggregate-count and cannot legitimately carry // an aggregate-count proof — neither leaf nor carrier. The - // prover-side entry-point gate refuses to emit V0+ACOR. + // prover-side entry-point gate refuses to emit V0+aggregate-count. let v2 = &GROVE_V2; let (db, _root) = setup_brand_color_carrier_tree(v2, &[b"brand_000"], 100); - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000"], QueryItem::RangeAfter(b"color_00049".to_vec()..), ); @@ -1995,14 +1995,14 @@ mod tests { "unexpected message: {msg}" ), other => panic!( - "expected NotSupported for V0+carrier ACOR, got {:?}", + "expected NotSupported for V0+carrier aggregate-count, got {:?}", other.map(|b| b.len()) ), } } #[test] - fn acor_carrier_with_long_subquery_path_succeeds() { + fn carrier_with_long_subquery_path_succeeds() { // Exercises a non-trivial `subquery_path` (length > 1) in the // carrier shape: TEST_LEAF / "outer" / / "level1" / // "level2" / . The verifier must walk both @@ -2098,14 +2098,14 @@ mod tests { } #[test] - fn acor_carrier_corrupted_outer_layer_byte_is_rejected() { + fn carrier_corrupted_outer_layer_byte_is_rejected() { // Flip a byte deep inside the carrier-layer merk proof bytes // (which encode the outer-Keys multi-key proof). Either the // merk-level execute_proof rejects the bytes, or the chain // check downstream rejects the resulting hash mismatch. let v = GroveVersion::latest(); let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 100); - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000", b"brand_001"], QueryItem::RangeAfter(b"color_00049".to_vec()..), ); @@ -2127,11 +2127,11 @@ mod tests { } #[test] - fn acor_carrier_undecodable_proof_is_rejected() { + fn carrier_undecodable_proof_is_rejected() { // Send garbage bytes — the bincode decoder rejects the // envelope up front with `Error::CorruptedData`. let v = GroveVersion::latest(); - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000"], QueryItem::RangeAfter(b"color_00049".to_vec()..), ); @@ -2145,14 +2145,14 @@ mod tests { } #[test] - fn acor_carrier_legacy_verifier_rejects_carrier_query() { + fn carrier_legacy_verifier_rejects_carrier_query() { // The legacy single-`u64` `verify_aggregate_count_query` strictly // validates the leaf shape and rejects carrier queries — even // though the proof bytes themselves are well-formed. Callers // must use `verify_aggregate_count_query_per_key` for carriers. let v = GroveVersion::latest(); let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000"], 50); - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000"], QueryItem::Range(b"color_00010".to_vec()..b"color_00020".to_vec()), ); @@ -2170,7 +2170,7 @@ mod tests { } #[test] - fn acor_carrier_missing_outer_lower_layer_is_rejected() { + fn carrier_missing_outer_lower_layer_is_rejected() { // Decode the carrier proof envelope, drop one of the // `lower_layers[outer_key]` entries, re-encode, and verify the // verifier rejects with "missing lower layer for outer key". @@ -2182,7 +2182,7 @@ mod tests { let v = GroveVersion::latest(); let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 100); - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000", b"brand_001"], QueryItem::RangeAfter(b"color_00049".to_vec()..), ); @@ -2233,7 +2233,7 @@ mod tests { } #[test] - fn acor_carrier_missing_subquery_path_layer_is_rejected() { + fn carrier_missing_subquery_path_layer_is_rejected() { // Same idea as the previous test but one level deeper: drop the // `subquery_path` layer ("color") that sits between the outer // brand match and the leaf merk. Exercises the @@ -2244,7 +2244,7 @@ mod tests { let v = GroveVersion::latest(); let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000"], 100); - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000"], QueryItem::RangeAfter(b"color_00049".to_vec()..), ); @@ -2297,7 +2297,7 @@ mod tests { } #[test] - fn acor_carrier_non_merk_proof_bytes_is_rejected() { + fn carrier_non_merk_proof_bytes_is_rejected() { // Replace the subquery_path layer's `ProofBytes::Merk(...)` with // a `ProofBytes::MMR(...)` variant. The verifier rejects the // mismatched proof-bytes flavor through `expect_merk_bytes`. @@ -2307,7 +2307,7 @@ mod tests { let v = GroveVersion::latest(); let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000"], 100); - let path_query = carrier_acor_path_query( + let path_query = carrier_count_path_query( &[b"brand_000"], QueryItem::RangeAfter(b"color_00049".to_vec()..), ); @@ -2359,7 +2359,7 @@ mod tests { } #[test] - fn acor_carrier_pagination_is_rejected_at_entry() { + fn carrier_pagination_is_rejected_at_entry() { // Carriers (like leaves) forbid SizedQuery::limit and offset. // The PathQuery-level validator surfaces this before any proof // bytes are decoded. @@ -2377,7 +2377,7 @@ mod tests { ); let dummy_proof = vec![0u8; 8]; let err = GroveDb::verify_aggregate_count_query_per_key(&dummy_proof, &path_query, v) - .expect_err("carrier ACOR with limit must be rejected at entry"); + .expect_err("carrier aggregate-count with limit must be rejected at entry"); match err { crate::Error::InvalidQuery(msg) => { assert!(msg.contains("limit"), "unexpected message: {msg}") From 066c7743a85af33ad9464a1402c354434c3a0b2b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 05:40:21 +0700 Subject: [PATCH 08/15] refactor(grovedb): split aggregate_count proof module into submodules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single 750-line `aggregate_count.rs` had grown to cover four distinct concerns: public API, classification, the leaf-only chain walker, and the per-key carrier walker (plus a pile of shared helpers). Split into a directory module with one file per concern. ``` grovedb/src/operations/proof/aggregate_count/ ├── mod.rs 227 lines — public API (impl GroveDb, │ verify_aggregate_count_query + │ verify_aggregate_count_query_per_key), │ require_v1_envelope, submodule wiring ├── classification.rs 77 lines — AggregateCountClassification struct + │ classify_aggregate_count_path_query ├── leaf_chain.rs 77 lines — verify_v1_leaf_chain (legacy │ single-u64 walker) ├── per_key.rs 221 lines — per-key carrier walker │ (with_classification, _per_key, │ _carrier_layer, _subquery_path) └── helpers.rs 270 lines — shared utilities (decode envelope, verify_count_leaf, expect_merk_bytes, verify_single_key_layer_proof_v0, OuterMatch + execute_carrier_layer_proof, enforce_lower_chain) ``` Visibility is `pub(super)` everywhere — the submodules are crate implementation detail, only `verify_aggregate_count_query` and `verify_aggregate_count_query_per_key` are public. Pure code reorganization — no behavior change. All 62 grovedb + 28 grovedb-query aggregate-count tests still pass; workspace + clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/operations/proof/aggregate_count.rs | 750 ------------------ .../proof/aggregate_count/classification.rs | 77 ++ .../proof/aggregate_count/helpers.rs | 270 +++++++ .../proof/aggregate_count/leaf_chain.rs | 77 ++ .../operations/proof/aggregate_count/mod.rs | 227 ++++++ .../proof/aggregate_count/per_key.rs | 221 ++++++ 6 files changed, 872 insertions(+), 750 deletions(-) delete mode 100644 grovedb/src/operations/proof/aggregate_count.rs create mode 100644 grovedb/src/operations/proof/aggregate_count/classification.rs create mode 100644 grovedb/src/operations/proof/aggregate_count/helpers.rs create mode 100644 grovedb/src/operations/proof/aggregate_count/leaf_chain.rs create mode 100644 grovedb/src/operations/proof/aggregate_count/mod.rs create mode 100644 grovedb/src/operations/proof/aggregate_count/per_key.rs diff --git a/grovedb/src/operations/proof/aggregate_count.rs b/grovedb/src/operations/proof/aggregate_count.rs deleted file mode 100644 index a376adee5..000000000 --- a/grovedb/src/operations/proof/aggregate_count.rs +++ /dev/null @@ -1,750 +0,0 @@ -//! GroveDB-side prove/verify glue for `AggregateCountOnRange` queries. -//! -//! The merk-level pieces live in `grovedb_merk::proofs::query::aggregate_count` -//! (proof generation in `Merk::prove_aggregate_count_on_range`, proof -//! verification in `verify_aggregate_count_on_range_proof`). This module -//! adds the GroveDB-level *envelope* handling: a verifier that walks the -//! multi-layer `GroveDBProof` chain (parent merk → ... → leaf merk), -//! verifies the path-element existence proofs at each non-leaf layer, and -//! delegates to the merk-level count verifier at the leaf. -//! -//! The proof generator side is wired directly into -//! [`GroveDb::prove_subqueries`] / [`GroveDb::prove_subqueries_v1`] — see -//! the "Aggregate-count short-circuit" branches there. -//! -//! ## Two shapes -//! -//! `AggregateCountOnRange` queries come in two flavors: -//! -//! - **Leaf** — a single `AggregateCountOnRange(_)` item at the top level -//! of the inner `Query`. The proof descends `path_query.path` via -//! single-key existence checks and produces a single `u64` at the leaf -//! merk. Surfaced through [`GroveDb::verify_aggregate_count_query`]. -//! -//! - **Carrier** — an outer query whose items are `Key(_)` / `Range*(_)` -//! (one IN-style fan-out dimension) and whose -//! `default_subquery_branch.subquery` resolves to a leaf -//! `AggregateCountOnRange`. The proof descends `path_query.path` via -//! single-key checks, then at the carrier merk it produces a multi-key -//! proof over the outer items; each matched outer key recurses through -//! the `subquery_path` (if any) to a leaf merk that produces its own -//! count. The verifier returns one `(outer_key, count)` pair per -//! matched outer key. Surfaced through -//! [`GroveDb::verify_aggregate_count_query_per_key`]. -//! -//! The same leaf/carrier shape will apply to forthcoming aggregate -//! variants (sum, average) — each will get its own sibling module under -//! `grovedb/src/operations/proof/` with parallel naming. - -use grovedb_merk::{ - proofs::{ - query::{aggregate_count::verify_aggregate_count_on_range_proof, QueryProofVerify}, - Query as MerkQuery, - }, - tree::{combine_hash, value_hash}, - CryptoHash, -}; -use grovedb_query::QueryItem; -use grovedb_version::{check_grovedb_v0, version::GroveVersion}; - -use crate::{ - operations::proof::{GroveDBProof, GroveDBProofV1, LayerProof, ProofBytes}, - Element, Error, GroveDb, PathQuery, -}; - -impl GroveDb { - /// Verify a serialized `prove_query` proof against a leaf - /// `AggregateCountOnRange` `PathQuery`, returning the GroveDB root hash - /// and the verified count. - /// - /// `path_query` must satisfy - /// [`PathQuery::validate_aggregate_count_on_range`] and additionally must - /// be the **leaf** shape — a single `AggregateCountOnRange(_)` item, no - /// subqueries, no pagination, and an inner range that isn't `Key`, - /// `RangeFull`, or another `AggregateCountOnRange`. Carrier-shape - /// aggregate-count queries (outer `Keys` + `AggregateCountOnRange` - /// subquery) must use - /// [`GroveDb::verify_aggregate_count_query_per_key`] instead. - /// - /// `AggregateCountOnRange` requires **V1 proof envelopes** - /// (`GroveDBProofV1`). V0 (`GroveDBProofV0` / `MerkOnlyLayerProof`) - /// envelopes predate the aggregate-count feature and are only - /// produced by grove versions older than the one used by Dash - /// Platform v12; this entry point rejects them with - /// `Error::InvalidProof`. - /// - /// Returns: - /// - `root_hash` — the reconstructed GroveDB root hash. The caller is - /// responsible for comparing this against their trusted root hash. - /// - `count` — the number of keys in the inner range that were committed - /// by the proof. - /// - /// Cryptographic guarantees: - /// - At each non-leaf layer, a regular single-key merk proof - /// demonstrates that the next path element exists with the recorded - /// value bytes; the verifier checks the chain - /// `combine_hash(H(value), lower_hash) == parent_proof_hash` so a - /// forged path is impossible without a root-hash mismatch. - /// - At the leaf layer, the count is committed by `HashWithCount`'s - /// `node_hash_with_count(kv_hash, left, right, count)` recomputation — - /// tampering with the count produces a different reconstructed merk - /// root, and the chain check above then fails. - pub fn verify_aggregate_count_query( - proof: &[u8], - path_query: &PathQuery, - grove_version: &GroveVersion, - ) -> Result<(CryptoHash, u64), Error> { - check_grovedb_v0!( - "verify_aggregate_count_query", - grove_version - .grovedb_versions - .operations - .proof - .verify_query_with_options - ); - - // Validate at the PathQuery level so SizedQuery::limit / offset - // (which aggregate-count explicitly forbids) are enforced - // alongside the inner-Query shape rules. - let inner_range = path_query.validate_leaf_aggregate_count_on_range()?.clone(); - - let grovedb_proof = decode_grovedb_proof(proof)?; - let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); - - let root_layer = require_v1_envelope(&grovedb_proof, path_query)?; - let (root_hash, count) = verify_v1_leaf_chain( - root_layer, - path_query, - &path_keys, - 0, - &inner_range, - grove_version, - )?; - Ok((root_hash, count)) - } - - /// Verify a serialized `prove_query` proof against an - /// `AggregateCountOnRange` `PathQuery` in either the leaf or carrier - /// shape, returning one `(outer_key, count)` pair per matched outer - /// key. - /// - /// For a **leaf** aggregate-count query the returned vector contains - /// exactly one entry whose key is an empty byte string and whose - /// count is the same `u64` - /// [`GroveDb::verify_aggregate_count_query`] would have returned. - /// This makes carrier and leaf consumers symmetric: callers that - /// always process a `Vec<(Vec, u64)>` don't need to branch on - /// the shape. - /// - /// For a **carrier** aggregate-count query the outer items must be - /// `Key(_)` / `Range*(_)`, the `default_subquery_branch.subquery` - /// must validate as a leaf `AggregateCountOnRange`, and the optional - /// `subquery_path` is followed exactly (single-key descent per - /// element) before the count proof. The returned vector has one - /// entry per matched outer key in **query-direction order**: when - /// the carrier's `left_to_right` is `true` (the default, matching - /// the merk prover's natural walk) entries come back in ascending - /// lexicographic key order; when `left_to_right` is `false` they - /// come back in descending order, mirroring the merk proof's own - /// emission order. Outer-key candidates that the prover proved as - /// absent contribute no entry. - /// - /// Like [`GroveDb::verify_aggregate_count_query`], this entry point - /// requires **V1 proof envelopes**. V0 envelopes predate the - /// aggregate-count feature and are rejected with - /// `Error::InvalidProof`. - /// - /// Cryptographic guarantees: - /// - Every layer is committed via the same `combine_hash(H(value), - /// lower_hash) == parent_proof_hash` chain check used by the leaf - /// verifier, so a forged path through the carrier or - /// `subquery_path` produces a root-hash mismatch. - /// - Each per-outer-key count is committed by the leaf - /// `HashWithCount` / `KVDigestCount` recomputation; - /// counts can't be tampered with independently. - pub fn verify_aggregate_count_query_per_key( - proof: &[u8], - path_query: &PathQuery, - grove_version: &GroveVersion, - ) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { - check_grovedb_v0!( - "verify_aggregate_count_query_per_key", - grove_version - .grovedb_versions - .operations - .proof - .verify_query_with_options - ); - - // Classify the query and extract the leaf inner range plus the - // optional carrier subquery_path. For leaf queries the carrier - // descent below is skipped (carrier_outer_items is None). - let classification = classify_aggregate_count_path_query(path_query)?; - - let grovedb_proof = decode_grovedb_proof(proof)?; - let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); - - let root_layer = require_v1_envelope(&grovedb_proof, path_query)?; - verify_v1_with_classification( - root_layer, - path_query, - &path_keys, - &classification, - grove_version, - ) - } -} - -/// Extract the V1 root layer from a `GroveDBProof` envelope, or refuse -/// the proof. `AggregateCountOnRange` (both leaf and carrier) requires -/// V1 envelopes — the V0 (`MerkOnlyLayerProof`) envelope predates the -/// aggregate-count feature and is only emitted by grove versions older -/// than the one used by Dash Platform v12, so it cannot legitimately -/// contain an aggregate-count proof. -fn require_v1_envelope<'a>( - proof: &'a GroveDBProof, - path_query: &PathQuery, -) -> Result<&'a LayerProof, Error> { - match proof { - GroveDBProof::V1(GroveDBProofV1 { root_layer }) => Ok(root_layer), - GroveDBProof::V0(_) => Err(Error::InvalidProof( - path_query.clone(), - "AggregateCountOnRange proofs require V1 proof envelopes; V0 envelopes predate \ - this feature and cannot legitimately carry an aggregate-count proof" - .to_string(), - )), - } -} - -/// Classification of an `AggregateCountOnRange` `PathQuery`. Encodes -/// either the leaf-only inner range (no carrier descent) or the -/// carrier outer items + leaf inner range + optional `subquery_path` -/// that the verifier must follow per outer key. -/// -/// Forthcoming aggregate variants (sum, average) will define their own -/// parallel classification types (`AggregateSumClassification`, -/// `AggregateAverageClassification`, …) — the leaf-vs-carrier shape is -/// a property of any aggregate-on-range query, but each variant carries -/// its own kind of inner descriptor. -struct AggregateCountClassification { - /// The inner range that the leaf merk count proof must satisfy. - leaf_inner_range: QueryItem, - /// Carrier outer items. `None` for leaf-only queries. - carrier_outer_items: Option>, - /// Carrier subquery_path (the keys between each outer match and the - /// leaf merk). Empty `Vec` if no subquery_path was set. `None` for - /// leaf-only queries. - carrier_subquery_path: Option>>, - /// Whether the outer query is left-to-right. Affects which results the - /// merk_proof returns when the outer items are ranges. Always `true` - /// for leaf-only. - carrier_left_to_right: bool, -} - -fn classify_aggregate_count_path_query( - path_query: &PathQuery, -) -> Result { - // Validate at the PathQuery level so SizedQuery::limit / offset - // (which aggregate-count explicitly forbids) are enforced alongside - // the inner-Query shape rules — for both the leaf and the carrier - // branch below. - let leaf_inner = path_query.validate_aggregate_count_on_range()?.clone(); - let q = &path_query.query.query; - if q.aggregate_count_on_range().is_some() { - // Leaf shape: top-level `AggregateCountOnRange` item. The - // top-level `validate_aggregate_count_on_range` dispatcher above - // routed through the leaf validator, so we already know - // `leaf_inner` is the inner range of the top-level - // `AggregateCountOnRange` item. - return Ok(AggregateCountClassification { - leaf_inner_range: leaf_inner, - carrier_outer_items: None, - carrier_subquery_path: None, - carrier_left_to_right: true, - }); - } - // Carrier shape: validation above routed through the carrier - // validator, so `leaf_inner` is the *subquery's* inner range. We just - // need to extract the outer items and the optional subquery_path. - let outer_items = q.items.clone(); - let subquery_path = q - .default_subquery_branch - .subquery_path - .clone() - .unwrap_or_default(); - Ok(AggregateCountClassification { - leaf_inner_range: leaf_inner, - carrier_outer_items: Some(outer_items), - carrier_subquery_path: Some(subquery_path), - carrier_left_to_right: q.left_to_right, - }) -} - -fn decode_grovedb_proof(proof: &[u8]) -> Result { - // Decode the GroveDBProof envelope using the same config the prover - // uses on the way out (matches `prove_query`). - let config = bincode::config::standard() - .with_big_endian() - .with_limit::<{ 256 * 1024 * 1024 }>(); - let (proof, _) = bincode::decode_from_slice(proof, config) - .map_err(|e| Error::CorruptedData(format!("unable to decode proof: {}", e)))?; - Ok(proof) -} - -fn verify_v1_leaf_chain( - layer: &LayerProof, - path_query: &PathQuery, - path_keys: &[&[u8]], - depth: usize, - inner_range: &QueryItem, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, u64), Error> { - let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; - - if depth == path_keys.len() { - return verify_count_leaf(merk_bytes, inner_range, path_query); - } - - let next_key = path_keys[depth].to_vec(); - let (proven_value_bytes, parent_root_hash, parent_proof_hash) = - verify_single_key_layer_proof_v0(merk_bytes, &next_key, path_query)?; - - let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "aggregate-count proof missing lower layer for path key {}", - hex::encode(&next_key) - ), - ) - })?; - let (lower_hash, count) = verify_v1_leaf_chain( - lower_layer, - path_query, - path_keys, - depth + 1, - inner_range, - grove_version, - )?; - - enforce_lower_chain( - path_query, - &next_key, - &proven_value_bytes, - &lower_hash, - &parent_proof_hash, - grove_version, - )?; - - Ok((parent_root_hash, count)) -} - -// ── per-key entry-point traversal (V1 only — V0 envelopes are -// rejected at the entry-point gate above, since they predate the -// aggregate-count feature and cannot legitimately carry one) - -fn verify_v1_with_classification( - layer: &LayerProof, - path_query: &PathQuery, - path_keys: &[&[u8]], - classification: &AggregateCountClassification, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { - verify_v1_per_key( - layer, - path_query, - path_keys, - 0, - classification, - grove_version, - ) -} - -fn verify_v1_per_key( - layer: &LayerProof, - path_query: &PathQuery, - path_keys: &[&[u8]], - depth: usize, - classification: &AggregateCountClassification, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { - let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; - - if depth < path_keys.len() { - let next_key = path_keys[depth].to_vec(); - let (proven_value_bytes, parent_root_hash, parent_proof_hash) = - verify_single_key_layer_proof_v0(merk_bytes, &next_key, path_query)?; - let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "aggregate-count proof missing lower layer for path key {}", - hex::encode(&next_key) - ), - ) - })?; - let (lower_hash, results) = verify_v1_per_key( - lower_layer, - path_query, - path_keys, - depth + 1, - classification, - grove_version, - )?; - enforce_lower_chain( - path_query, - &next_key, - &proven_value_bytes, - &lower_hash, - &parent_proof_hash, - grove_version, - )?; - return Ok((parent_root_hash, results)); - } - - match &classification.carrier_outer_items { - None => { - let (root, count) = - verify_count_leaf(merk_bytes, &classification.leaf_inner_range, path_query)?; - Ok((root, vec![(Vec::new(), count)])) - } - Some(outer_items) => verify_v1_carrier_layer( - layer, - merk_bytes, - path_query, - outer_items, - classification, - grove_version, - ), - } -} - -fn verify_v1_carrier_layer( - layer: &LayerProof, - merk_bytes: &[u8], - path_query: &PathQuery, - outer_items: &[QueryItem], - classification: &AggregateCountClassification, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { - let (carrier_root, matched) = execute_carrier_layer_proof( - merk_bytes, - outer_items, - classification.carrier_left_to_right, - path_query, - )?; - - let subquery_path = classification - .carrier_subquery_path - .as_ref() - .expect("carrier subquery_path is set when carrier_outer_items is Some"); - - let mut results = Vec::with_capacity(matched.len()); - for OuterMatch { - outer_key, - value_bytes, - commitment_hash, - } in matched - { - let lower_layer = layer.lower_layers.get(&outer_key).ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "carrier aggregate-count proof missing lower layer for outer key {}", - hex::encode(&outer_key) - ), - ) - })?; - - let (lower_root, count) = verify_v1_subquery_path( - lower_layer, - path_query, - subquery_path, - 0, - &classification.leaf_inner_range, - grove_version, - )?; - - enforce_lower_chain( - path_query, - &outer_key, - &value_bytes, - &lower_root, - &commitment_hash, - grove_version, - )?; - results.push((outer_key, count)); - } - - Ok((carrier_root, results)) -} - -fn verify_v1_subquery_path( - layer: &LayerProof, - path_query: &PathQuery, - subquery_path: &[Vec], - depth: usize, - inner_range: &QueryItem, - grove_version: &GroveVersion, -) -> Result<(CryptoHash, u64), Error> { - let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; - if depth == subquery_path.len() { - return verify_count_leaf(merk_bytes, inner_range, path_query); - } - let next_key = subquery_path[depth].clone(); - let (proven_value_bytes, parent_root_hash, parent_proof_hash) = - verify_single_key_layer_proof_v0(merk_bytes, &next_key, path_query)?; - let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "carrier aggregate-count proof missing subquery_path layer for key {}", - hex::encode(&next_key) - ), - ) - })?; - let (lower_hash, count) = verify_v1_subquery_path( - lower_layer, - path_query, - subquery_path, - depth + 1, - inner_range, - grove_version, - )?; - enforce_lower_chain( - path_query, - &next_key, - &proven_value_bytes, - &lower_hash, - &parent_proof_hash, - grove_version, - )?; - Ok((parent_root_hash, count)) -} - -// ── shared helpers ───────────────────────────────────────────────────────── - -/// Verify the leaf layer: bytes are the encoded count-proof Op stream; -/// the inner range is the same one the prover counted over. -fn verify_count_leaf( - leaf_bytes: &[u8], - inner_range: &QueryItem, - path_query: &PathQuery, -) -> Result<(CryptoHash, u64), Error> { - let (root_hash, count) = verify_aggregate_count_on_range_proof(leaf_bytes, inner_range) - .unwrap() - .map_err(|e| { - Error::InvalidProof( - path_query.clone(), - format!("aggregate-count leaf proof failed to verify: {}", e), - ) - })?; - Ok((root_hash, count)) -} - -fn expect_merk_bytes<'a>( - proof_bytes: &'a ProofBytes, - path_query: &PathQuery, -) -> Result<&'a [u8], Error> { - match proof_bytes { - ProofBytes::Merk(b) => Ok(b.as_slice()), - other => Err(Error::InvalidProof( - path_query.clone(), - format!( - "aggregate-count proof has unexpected non-merk layer bytes: {:?}", - std::mem::discriminant(other) - ), - )), - } -} - -/// Verify a non-leaf layer that should contain a single-key proof for -/// `target_key`. Returns `(proven_value_bytes, this_layer_root_hash, -/// proof_hash_recorded_for_target)`. -/// -/// The "proof_hash" is the value_hash committed by the merk proof for the -/// target key — this is the hash the verifier will compare against -/// `combine_hash(H(child_tree_value), lower_layer_root_hash)` to enforce -/// the chain. -fn verify_single_key_layer_proof_v0( - merk_bytes: &[u8], - target_key: &[u8], - path_query: &PathQuery, -) -> Result<(Vec, CryptoHash, CryptoHash), Error> { - let level_query = MerkQuery { - items: vec![grovedb_merk::proofs::query::QueryItem::Key( - target_key.to_vec(), - )], - left_to_right: true, - ..Default::default() - }; - - let (root_hash, merk_result) = level_query - .execute_proof(merk_bytes, None, true, 0) - .unwrap() - .map_err(|e| { - Error::InvalidProof( - path_query.clone(), - format!( - "non-leaf single-key proof for {} failed to verify: {}", - hex::encode(target_key), - e - ), - ) - })?; - - let proved = merk_result - .result_set - .iter() - .find(|p| p.key == target_key) - .ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "non-leaf proof did not contain the expected key {}", - hex::encode(target_key) - ), - ) - })?; - - let value_bytes = proved.value.clone().ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "non-leaf proof for key {} returned no value bytes", - hex::encode(target_key) - ), - ) - })?; - - Ok((value_bytes, root_hash, proved.proof)) -} - -/// One matched outer key in the carrier layer's multi-key merk proof. -struct OuterMatch { - /// The matched outer key bytes. - outer_key: Vec, - /// The serialized tree element bytes for the matched outer key (a - /// non-empty tree element of some flavor). - value_bytes: Vec, - /// The value_hash the parent merk committed for this outer key — the - /// hash that must equal `combine_hash(H(value), lower_layer_root)`. - commitment_hash: CryptoHash, -} - -/// Execute the carrier-layer multi-key merk proof for `outer_items`, -/// returning `(carrier_merk_root_hash, matched_outer_keys)`. Each -/// `OuterMatch` carries the value bytes and the parent-recorded value_hash -/// that the chain check will validate. -fn execute_carrier_layer_proof( - merk_bytes: &[u8], - outer_items: &[QueryItem], - left_to_right: bool, - path_query: &PathQuery, -) -> Result<(CryptoHash, Vec), Error> { - // The grovedb_query::QueryItem and grovedb_merk::proofs::query::QueryItem - // types are identical (the merk crate re-exports the grovedb-query one). - let level_query = MerkQuery { - items: outer_items.to_vec(), - left_to_right, - ..Default::default() - }; - - // Walk direction must match the prover's; otherwise the merk - // walker stops at the first out-of-order boundary and only the - // last key in the proof is returned. - let (root_hash, merk_result) = level_query - .execute_proof(merk_bytes, None, left_to_right, 0) - .unwrap() - .map_err(|e| { - Error::InvalidProof( - path_query.clone(), - format!( - "carrier aggregate-count multi-key proof failed to verify: {}", - e - ), - ) - })?; - - let mut matched = Vec::with_capacity(merk_result.result_set.len()); - for proved in &merk_result.result_set { - let value = proved.value.clone().ok_or_else(|| { - Error::InvalidProof( - path_query.clone(), - format!( - "carrier aggregate-count proof returned a result row without value bytes for key {}", - hex::encode(&proved.key) - ), - ) - })?; - matched.push(OuterMatch { - outer_key: proved.key.clone(), - value_bytes: value, - commitment_hash: proved.proof, - }); - } - - Ok((root_hash, matched)) -} - -/// Enforce the layer-chain hash equality: the parent merk's recorded -/// value_hash for the tree element must equal `combine_hash(H(value), -/// lower_layer_root_hash)`. This is what makes the count cryptographically -/// bound to the GroveDB root hash — the leaf count proof's reconstructed -/// `lower_hash` must agree with the parent's commitment, transitively up to -/// the root. -/// -/// Intermediate path elements may be any tree type — the GroveDB grove can -/// route through Normal/Sum/Count/etc. trees on the way down to the -/// provable-count leaf. The leaf-level tree-type check is enforced by the -/// merk prover (`Merk::prove_aggregate_count_on_range`); here we only -/// require that each non-leaf element on the path *is* some non-empty tree, -/// since only trees have a lower layer to chain into. -fn enforce_lower_chain( - path_query: &PathQuery, - target_key: &[u8], - proven_value_bytes: &[u8], - lower_hash: &CryptoHash, - parent_proof_hash: &CryptoHash, - grove_version: &GroveVersion, -) -> Result<(), Error> { - let element = Element::deserialize(proven_value_bytes, grove_version) - .map_err(|e| { - Error::InvalidProof( - path_query.clone(), - format!( - "non-leaf proof's element at key {} failed to deserialize: {}", - hex::encode(target_key), - e - ), - ) - })? - .into_underlying(); - if !element.is_any_tree() { - return Err(Error::InvalidProof( - path_query.clone(), - format!( - "aggregate-count proof's path element at key {} is not a tree element \ - (got {:?}); count queries can only descend through tree elements", - hex::encode(target_key), - std::mem::discriminant(&element) - ), - )); - } - - let value_h = value_hash(proven_value_bytes).value().to_owned(); - let combined = combine_hash(&value_h, lower_hash).value().to_owned(); - if combined != *parent_proof_hash { - return Err(Error::InvalidProof( - path_query.clone(), - format!( - "aggregate-count proof chain mismatch at key {}: parent recorded value_hash \ - {} but combine_hash(H(value), lower_root) is {}", - hex::encode(target_key), - hex::encode(parent_proof_hash), - hex::encode(combined) - ), - )); - } - Ok(()) -} diff --git a/grovedb/src/operations/proof/aggregate_count/classification.rs b/grovedb/src/operations/proof/aggregate_count/classification.rs new file mode 100644 index 000000000..40c314beb --- /dev/null +++ b/grovedb/src/operations/proof/aggregate_count/classification.rs @@ -0,0 +1,77 @@ +//! Classification of an `AggregateCountOnRange` `PathQuery` into either +//! the **leaf** shape (single `AggregateCountOnRange(_)` item) or the +//! **carrier** shape (outer `Key`/`Range*` items routing to a leaf +//! aggregate-count subquery). +//! +//! The classification is consumed by the per-key traversal in +//! [`super::per_key`] to decide whether to terminate the path walk at a +//! single count proof or to fan out across the carrier's matched outer +//! keys. +//! +//! Forthcoming aggregate variants (sum, average) will define their own +//! parallel classification types (`AggregateSumClassification`, +//! `AggregateAverageClassification`, …) in sibling modules — the +//! leaf-vs-carrier shape is a property of any aggregate-on-range query, +//! but each variant carries its own kind of inner descriptor. + +use grovedb_query::QueryItem; + +use crate::{Error, PathQuery}; + +/// Classification of an `AggregateCountOnRange` `PathQuery`. Encodes +/// either the leaf-only inner range (no carrier descent) or the +/// carrier outer items + leaf inner range + optional `subquery_path` +/// that the verifier must follow per outer key. +pub(super) struct AggregateCountClassification { + /// The inner range that the leaf merk count proof must satisfy. + pub(super) leaf_inner_range: QueryItem, + /// Carrier outer items. `None` for leaf-only queries. + pub(super) carrier_outer_items: Option>, + /// Carrier subquery_path (the keys between each outer match and the + /// leaf merk). Empty `Vec` if no subquery_path was set. `None` for + /// leaf-only queries. + pub(super) carrier_subquery_path: Option>>, + /// Whether the outer query is left-to-right. Affects which results + /// the merk_proof returns when the outer items are ranges. Always + /// `true` for leaf-only. + pub(super) carrier_left_to_right: bool, +} + +/// Classify an `AggregateCountOnRange` path query and validate it at +/// the PathQuery level — `SizedQuery::limit` / `offset` (which +/// aggregate-count explicitly forbids) are enforced for both shapes. +pub(super) fn classify_aggregate_count_path_query( + path_query: &PathQuery, +) -> Result { + let leaf_inner = path_query.validate_aggregate_count_on_range()?.clone(); + let q = &path_query.query.query; + if q.aggregate_count_on_range().is_some() { + // Leaf shape: top-level `AggregateCountOnRange` item. The + // top-level `validate_aggregate_count_on_range` dispatcher above + // routed through the leaf validator, so we already know + // `leaf_inner` is the inner range of the top-level + // `AggregateCountOnRange` item. + return Ok(AggregateCountClassification { + leaf_inner_range: leaf_inner, + carrier_outer_items: None, + carrier_subquery_path: None, + carrier_left_to_right: true, + }); + } + // Carrier shape: validation above routed through the carrier + // validator, so `leaf_inner` is the *subquery's* inner range. We + // just need to extract the outer items and the optional + // subquery_path. + let outer_items = q.items.clone(); + let subquery_path = q + .default_subquery_branch + .subquery_path + .clone() + .unwrap_or_default(); + Ok(AggregateCountClassification { + leaf_inner_range: leaf_inner, + carrier_outer_items: Some(outer_items), + carrier_subquery_path: Some(subquery_path), + carrier_left_to_right: q.left_to_right, + }) +} diff --git a/grovedb/src/operations/proof/aggregate_count/helpers.rs b/grovedb/src/operations/proof/aggregate_count/helpers.rs new file mode 100644 index 000000000..0fe12a938 --- /dev/null +++ b/grovedb/src/operations/proof/aggregate_count/helpers.rs @@ -0,0 +1,270 @@ +//! Shared helpers used by both the leaf-chain walker and the per-key +//! carrier walker. +//! +//! - [`decode_grovedb_proof`] — parse the bincode envelope. +//! - [`verify_count_leaf`] — delegate to the merk-level count verifier. +//! - [`expect_merk_bytes`] — unwrap a `ProofBytes::Merk(_)` or reject. +//! - [`verify_single_key_layer_proof_v0`] — verify a non-leaf merk +//! proof for one expected key and recover its value bytes + chain +//! commitment hash. +//! - [`OuterMatch`] + [`execute_carrier_layer_proof`] — verify the +//! carrier's multi-key merk proof, collect one `OuterMatch` per +//! matched outer key. +//! - [`enforce_lower_chain`] — `combine_hash(H(value), lower_root) == +//! parent_value_hash`, the binding that ties each layer's count to +//! the GroveDB root hash. + +use grovedb_merk::{ + proofs::{ + query::{aggregate_count::verify_aggregate_count_on_range_proof, QueryProofVerify}, + Query as MerkQuery, + }, + tree::{combine_hash, value_hash}, + CryptoHash, +}; +use grovedb_query::QueryItem; +use grovedb_version::version::GroveVersion; + +use crate::{ + operations::proof::{GroveDBProof, ProofBytes}, + Element, Error, PathQuery, +}; + +/// Decode a serialized `GroveDBProof` envelope using the same bincode +/// configuration the prover writes out. +pub(super) fn decode_grovedb_proof(proof: &[u8]) -> Result { + let config = bincode::config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (proof, _) = bincode::decode_from_slice(proof, config) + .map_err(|e| Error::CorruptedData(format!("unable to decode proof: {}", e)))?; + Ok(proof) +} + +/// Verify the leaf layer: bytes are the encoded count-proof Op stream; +/// the inner range is the same one the prover counted over. +pub(super) fn verify_count_leaf( + leaf_bytes: &[u8], + inner_range: &QueryItem, + path_query: &PathQuery, +) -> Result<(CryptoHash, u64), Error> { + let (root_hash, count) = verify_aggregate_count_on_range_proof(leaf_bytes, inner_range) + .unwrap() + .map_err(|e| { + Error::InvalidProof( + path_query.clone(), + format!("aggregate-count leaf proof failed to verify: {}", e), + ) + })?; + Ok((root_hash, count)) +} + +/// Unwrap a `ProofBytes::Merk(_)` or reject the proof — aggregate-count +/// envelopes are always merk-flavored at every layer. +pub(super) fn expect_merk_bytes<'a>( + proof_bytes: &'a ProofBytes, + path_query: &PathQuery, +) -> Result<&'a [u8], Error> { + match proof_bytes { + ProofBytes::Merk(b) => Ok(b.as_slice()), + other => Err(Error::InvalidProof( + path_query.clone(), + format!( + "aggregate-count proof has unexpected non-merk layer bytes: {:?}", + std::mem::discriminant(other) + ), + )), + } +} + +/// Verify a non-leaf layer that should contain a single-key proof for +/// `target_key`. Returns `(proven_value_bytes, this_layer_root_hash, +/// proof_hash_recorded_for_target)`. +/// +/// The "proof_hash" is the value_hash committed by the merk proof for the +/// target key — this is the hash the verifier will compare against +/// `combine_hash(H(child_tree_value), lower_layer_root_hash)` to enforce +/// the chain. +pub(super) fn verify_single_key_layer_proof_v0( + merk_bytes: &[u8], + target_key: &[u8], + path_query: &PathQuery, +) -> Result<(Vec, CryptoHash, CryptoHash), Error> { + let level_query = MerkQuery { + items: vec![grovedb_merk::proofs::query::QueryItem::Key( + target_key.to_vec(), + )], + left_to_right: true, + ..Default::default() + }; + + let (root_hash, merk_result) = level_query + .execute_proof(merk_bytes, None, true, 0) + .unwrap() + .map_err(|e| { + Error::InvalidProof( + path_query.clone(), + format!( + "non-leaf single-key proof for {} failed to verify: {}", + hex::encode(target_key), + e + ), + ) + })?; + + let proved = merk_result + .result_set + .iter() + .find(|p| p.key == target_key) + .ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "non-leaf proof did not contain the expected key {}", + hex::encode(target_key) + ), + ) + })?; + + let value_bytes = proved.value.clone().ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "non-leaf proof for key {} returned no value bytes", + hex::encode(target_key) + ), + ) + })?; + + Ok((value_bytes, root_hash, proved.proof)) +} + +/// One matched outer key in the carrier layer's multi-key merk proof. +pub(super) struct OuterMatch { + /// The matched outer key bytes. + pub(super) outer_key: Vec, + /// The serialized tree element bytes for the matched outer key (a + /// non-empty tree element of some flavor). + pub(super) value_bytes: Vec, + /// The value_hash the parent merk committed for this outer key — the + /// hash that must equal `combine_hash(H(value), lower_layer_root)`. + pub(super) commitment_hash: CryptoHash, +} + +/// Execute the carrier-layer multi-key merk proof for `outer_items`, +/// returning `(carrier_merk_root_hash, matched_outer_keys)`. Each +/// `OuterMatch` carries the value bytes and the parent-recorded value_hash +/// that the chain check will validate. +pub(super) fn execute_carrier_layer_proof( + merk_bytes: &[u8], + outer_items: &[QueryItem], + left_to_right: bool, + path_query: &PathQuery, +) -> Result<(CryptoHash, Vec), Error> { + // The grovedb_query::QueryItem and grovedb_merk::proofs::query::QueryItem + // types are identical (the merk crate re-exports the grovedb-query one). + let level_query = MerkQuery { + items: outer_items.to_vec(), + left_to_right, + ..Default::default() + }; + + // Walk direction must match the prover's; otherwise the merk + // walker stops at the first out-of-order boundary and only the + // last key in the proof is returned. + let (root_hash, merk_result) = level_query + .execute_proof(merk_bytes, None, left_to_right, 0) + .unwrap() + .map_err(|e| { + Error::InvalidProof( + path_query.clone(), + format!( + "carrier aggregate-count multi-key proof failed to verify: {}", + e + ), + ) + })?; + + let mut matched = Vec::with_capacity(merk_result.result_set.len()); + for proved in &merk_result.result_set { + let value = proved.value.clone().ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "carrier aggregate-count proof returned a result row without value bytes \ + for key {}", + hex::encode(&proved.key) + ), + ) + })?; + matched.push(OuterMatch { + outer_key: proved.key.clone(), + value_bytes: value, + commitment_hash: proved.proof, + }); + } + + Ok((root_hash, matched)) +} + +/// Enforce the layer-chain hash equality: the parent merk's recorded +/// value_hash for the tree element must equal `combine_hash(H(value), +/// lower_layer_root_hash)`. This is what makes the count cryptographically +/// bound to the GroveDB root hash — the leaf count proof's reconstructed +/// `lower_hash` must agree with the parent's commitment, transitively up to +/// the root. +/// +/// Intermediate path elements may be any tree type — the GroveDB grove can +/// route through Normal/Sum/Count/etc. trees on the way down to the +/// provable-count leaf. The leaf-level tree-type check is enforced by the +/// merk prover (`Merk::prove_aggregate_count_on_range`); here we only +/// require that each non-leaf element on the path *is* some non-empty tree, +/// since only trees have a lower layer to chain into. +pub(super) fn enforce_lower_chain( + path_query: &PathQuery, + target_key: &[u8], + proven_value_bytes: &[u8], + lower_hash: &CryptoHash, + parent_proof_hash: &CryptoHash, + grove_version: &GroveVersion, +) -> Result<(), Error> { + let element = Element::deserialize(proven_value_bytes, grove_version) + .map_err(|e| { + Error::InvalidProof( + path_query.clone(), + format!( + "non-leaf proof's element at key {} failed to deserialize: {}", + hex::encode(target_key), + e + ), + ) + })? + .into_underlying(); + if !element.is_any_tree() { + return Err(Error::InvalidProof( + path_query.clone(), + format!( + "aggregate-count proof's path element at key {} is not a tree element \ + (got {:?}); count queries can only descend through tree elements", + hex::encode(target_key), + std::mem::discriminant(&element) + ), + )); + } + + let value_h = value_hash(proven_value_bytes).value().to_owned(); + let combined = combine_hash(&value_h, lower_hash).value().to_owned(); + if combined != *parent_proof_hash { + return Err(Error::InvalidProof( + path_query.clone(), + format!( + "aggregate-count proof chain mismatch at key {}: parent recorded value_hash \ + {} but combine_hash(H(value), lower_root) is {}", + hex::encode(target_key), + hex::encode(parent_proof_hash), + hex::encode(combined) + ), + )); + } + Ok(()) +} diff --git a/grovedb/src/operations/proof/aggregate_count/leaf_chain.rs b/grovedb/src/operations/proof/aggregate_count/leaf_chain.rs new file mode 100644 index 000000000..7057142ca --- /dev/null +++ b/grovedb/src/operations/proof/aggregate_count/leaf_chain.rs @@ -0,0 +1,77 @@ +//! Leaf-chain walker: descends `path_query.path` via single-key existence +//! proofs and delegates to the merk-level count verifier at the leaf +//! merk. Used by the legacy single-`u64` entry point +//! [`crate::GroveDb::verify_aggregate_count_query`]. +//! +//! The carrier-shape per-key walker in [`super::per_key`] reuses the +//! same single-key descent helpers (`verify_single_key_layer_proof_v0`, +//! `enforce_lower_chain`) for path-prefix layers and is structurally +//! parallel, but emits a `Vec<(outer_key, count)>` instead of one `u64`. + +use grovedb_merk::CryptoHash; +use grovedb_query::QueryItem; +use grovedb_version::version::GroveVersion; + +use crate::{ + operations::proof::{ + aggregate_count::helpers::{ + enforce_lower_chain, expect_merk_bytes, verify_count_leaf, + verify_single_key_layer_proof_v0, + }, + LayerProof, + }, + Error, PathQuery, +}; + +/// Walk `path_query.path` layer by layer through `layer.lower_layers`, +/// verifying a single-key existence proof at each non-leaf depth and +/// delegating to [`verify_count_leaf`] at the leaf. At each non-leaf +/// step, the chain check `combine_hash(H(value), lower_root) == +/// parent_value_hash` ties the layer's count to the GroveDB root hash. +pub(super) fn verify_v1_leaf_chain( + layer: &LayerProof, + path_query: &PathQuery, + path_keys: &[&[u8]], + depth: usize, + inner_range: &QueryItem, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, u64), Error> { + let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; + + if depth == path_keys.len() { + return verify_count_leaf(merk_bytes, inner_range, path_query); + } + + let next_key = path_keys[depth].to_vec(); + let (proven_value_bytes, parent_root_hash, parent_proof_hash) = + verify_single_key_layer_proof_v0(merk_bytes, &next_key, path_query)?; + + let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "aggregate-count proof missing lower layer for path key {}", + hex::encode(&next_key) + ), + ) + })?; + let (lower_hash, count) = verify_v1_leaf_chain( + lower_layer, + path_query, + path_keys, + depth + 1, + inner_range, + grove_version, + )?; + + enforce_lower_chain( + path_query, + &next_key, + &proven_value_bytes, + &lower_hash, + &parent_proof_hash, + grove_version, + )?; + + Ok((parent_root_hash, count)) +} diff --git a/grovedb/src/operations/proof/aggregate_count/mod.rs b/grovedb/src/operations/proof/aggregate_count/mod.rs new file mode 100644 index 000000000..c163e4e7b --- /dev/null +++ b/grovedb/src/operations/proof/aggregate_count/mod.rs @@ -0,0 +1,227 @@ +//! GroveDB-side prove/verify glue for `AggregateCountOnRange` queries. +//! +//! The merk-level pieces live in `grovedb_merk::proofs::query::aggregate_count` +//! (proof generation in `Merk::prove_aggregate_count_on_range`, proof +//! verification in `verify_aggregate_count_on_range_proof`). This module +//! adds the GroveDB-level *envelope* handling: a verifier that walks the +//! multi-layer `GroveDBProof` chain (parent merk → ... → leaf merk), +//! verifies the path-element existence proofs at each non-leaf layer, and +//! delegates to the merk-level count verifier at the leaf. +//! +//! The proof generator side is wired directly into +//! [`GroveDb::prove_subqueries`] / [`GroveDb::prove_subqueries_v1`] — see +//! the "Aggregate-count short-circuit" branches there. +//! +//! ## Two shapes +//! +//! `AggregateCountOnRange` queries come in two flavors: +//! +//! - **Leaf** — a single `AggregateCountOnRange(_)` item at the top level +//! of the inner `Query`. The proof descends `path_query.path` via +//! single-key existence checks and produces a single `u64` at the leaf +//! merk. Surfaced through [`GroveDb::verify_aggregate_count_query`]. +//! +//! - **Carrier** — an outer query whose items are `Key(_)` / `Range*(_)` +//! (one IN-style fan-out dimension) and whose +//! `default_subquery_branch.subquery` resolves to a leaf +//! `AggregateCountOnRange`. The proof descends `path_query.path` via +//! single-key checks, then at the carrier merk it produces a multi-key +//! proof over the outer items; each matched outer key recurses through +//! the `subquery_path` (if any) to a leaf merk that produces its own +//! count. The verifier returns one `(outer_key, count)` pair per +//! matched outer key. Surfaced through +//! [`GroveDb::verify_aggregate_count_query_per_key`]. +//! +//! The same leaf/carrier shape will apply to forthcoming aggregate +//! variants (sum, average) — each will get its own sibling module under +//! `grovedb/src/operations/proof/` with parallel naming. +//! +//! ## Module layout +//! +//! - [`classification`] — `AggregateCountClassification` struct and the +//! `classify_aggregate_count_path_query` function that distinguishes +//! leaf vs. carrier shape. +//! - [`leaf_chain`] — the recursive walker used by the legacy +//! single-`u64` entry point. +//! - [`per_key`] — the carrier-shape walker that drives both shapes +//! through the new `(outer_key, count)` entry point. +//! - [`helpers`] — shared utilities (envelope decode, single-key +//! layer verification, chain enforcement, multi-key outer proof +//! execution). + +mod classification; +mod helpers; +mod leaf_chain; +mod per_key; + +use grovedb_merk::CryptoHash; +use grovedb_version::{check_grovedb_v0, version::GroveVersion}; + +use crate::{ + operations::proof::{GroveDBProof, GroveDBProofV1, LayerProof}, + Error, GroveDb, PathQuery, +}; + +impl GroveDb { + /// Verify a serialized `prove_query` proof against a leaf + /// `AggregateCountOnRange` `PathQuery`, returning the GroveDB root hash + /// and the verified count. + /// + /// `path_query` must satisfy + /// [`PathQuery::validate_aggregate_count_on_range`] and additionally must + /// be the **leaf** shape — a single `AggregateCountOnRange(_)` item, no + /// subqueries, no pagination, and an inner range that isn't `Key`, + /// `RangeFull`, or another `AggregateCountOnRange`. Carrier-shape + /// aggregate-count queries (outer `Keys` + `AggregateCountOnRange` + /// subquery) must use + /// [`GroveDb::verify_aggregate_count_query_per_key`] instead. + /// + /// `AggregateCountOnRange` requires **V1 proof envelopes** + /// (`GroveDBProofV1`). V0 (`GroveDBProofV0` / `MerkOnlyLayerProof`) + /// envelopes predate the aggregate-count feature and are only + /// produced by grove versions older than the one used by Dash + /// Platform v12; this entry point rejects them with + /// `Error::InvalidProof`. + /// + /// Returns: + /// - `root_hash` — the reconstructed GroveDB root hash. The caller is + /// responsible for comparing this against their trusted root hash. + /// - `count` — the number of keys in the inner range that were committed + /// by the proof. + /// + /// Cryptographic guarantees: + /// - At each non-leaf layer, a regular single-key merk proof + /// demonstrates that the next path element exists with the recorded + /// value bytes; the verifier checks the chain + /// `combine_hash(H(value), lower_hash) == parent_proof_hash` so a + /// forged path is impossible without a root-hash mismatch. + /// - At the leaf layer, the count is committed by `HashWithCount`'s + /// `node_hash_with_count(kv_hash, left, right, count)` recomputation — + /// tampering with the count produces a different reconstructed merk + /// root, and the chain check above then fails. + pub fn verify_aggregate_count_query( + proof: &[u8], + path_query: &PathQuery, + grove_version: &GroveVersion, + ) -> Result<(CryptoHash, u64), Error> { + check_grovedb_v0!( + "verify_aggregate_count_query", + grove_version + .grovedb_versions + .operations + .proof + .verify_query_with_options + ); + + // Validate at the PathQuery level so SizedQuery::limit / offset + // (which aggregate-count explicitly forbids) are enforced + // alongside the inner-Query shape rules. + let inner_range = path_query.validate_leaf_aggregate_count_on_range()?.clone(); + + let grovedb_proof = helpers::decode_grovedb_proof(proof)?; + let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); + + let root_layer = require_v1_envelope(&grovedb_proof, path_query)?; + let (root_hash, count) = leaf_chain::verify_v1_leaf_chain( + root_layer, + path_query, + &path_keys, + 0, + &inner_range, + grove_version, + )?; + Ok((root_hash, count)) + } + + /// Verify a serialized `prove_query` proof against an + /// `AggregateCountOnRange` `PathQuery` in either the leaf or carrier + /// shape, returning one `(outer_key, count)` pair per matched outer + /// key. + /// + /// For a **leaf** aggregate-count query the returned vector contains + /// exactly one entry whose key is an empty byte string and whose + /// count is the same `u64` + /// [`GroveDb::verify_aggregate_count_query`] would have returned. + /// This makes carrier and leaf consumers symmetric: callers that + /// always process a `Vec<(Vec, u64)>` don't need to branch on + /// the shape. + /// + /// For a **carrier** aggregate-count query the outer items must be + /// `Key(_)` / `Range*(_)`, the `default_subquery_branch.subquery` + /// must validate as a leaf `AggregateCountOnRange`, and the optional + /// `subquery_path` is followed exactly (single-key descent per + /// element) before the count proof. The returned vector has one + /// entry per matched outer key in **query-direction order**: when + /// the carrier's `left_to_right` is `true` (the default, matching + /// the merk prover's natural walk) entries come back in ascending + /// lexicographic key order; when `left_to_right` is `false` they + /// come back in descending order, mirroring the merk proof's own + /// emission order. Outer-key candidates that the prover proved as + /// absent contribute no entry. + /// + /// Like [`GroveDb::verify_aggregate_count_query`], this entry point + /// requires **V1 proof envelopes**. V0 envelopes predate the + /// aggregate-count feature and are rejected with + /// `Error::InvalidProof`. + /// + /// Cryptographic guarantees: + /// - Every layer is committed via the same `combine_hash(H(value), + /// lower_hash) == parent_proof_hash` chain check used by the leaf + /// verifier, so a forged path through the carrier or + /// `subquery_path` produces a root-hash mismatch. + /// - Each per-outer-key count is committed by the leaf + /// `HashWithCount` / `KVDigestCount` recomputation; + /// counts can't be tampered with independently. + pub fn verify_aggregate_count_query_per_key( + proof: &[u8], + path_query: &PathQuery, + grove_version: &GroveVersion, + ) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + check_grovedb_v0!( + "verify_aggregate_count_query_per_key", + grove_version + .grovedb_versions + .operations + .proof + .verify_query_with_options + ); + + // Classify the query and extract the leaf inner range plus the + // optional carrier subquery_path. For leaf queries the carrier + // descent below is skipped (carrier_outer_items is None). + let classification = classification::classify_aggregate_count_path_query(path_query)?; + + let grovedb_proof = helpers::decode_grovedb_proof(proof)?; + let path_keys: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); + + let root_layer = require_v1_envelope(&grovedb_proof, path_query)?; + per_key::verify_v1_with_classification( + root_layer, + path_query, + &path_keys, + &classification, + grove_version, + ) + } +} + +/// Extract the V1 root layer from a `GroveDBProof` envelope, or refuse +/// the proof. `AggregateCountOnRange` (both leaf and carrier) requires +/// V1 envelopes — the V0 (`MerkOnlyLayerProof`) envelope predates the +/// aggregate-count feature and is only emitted by grove versions older +/// than the one used by Dash Platform v12, so it cannot legitimately +/// contain an aggregate-count proof. +fn require_v1_envelope<'a>( + proof: &'a GroveDBProof, + path_query: &PathQuery, +) -> Result<&'a LayerProof, Error> { + match proof { + GroveDBProof::V1(GroveDBProofV1 { root_layer }) => Ok(root_layer), + GroveDBProof::V0(_) => Err(Error::InvalidProof( + path_query.clone(), + "AggregateCountOnRange proofs require V1 proof envelopes; V0 envelopes predate \ + this feature and cannot legitimately carry an aggregate-count proof" + .to_string(), + )), + } +} diff --git a/grovedb/src/operations/proof/aggregate_count/per_key.rs b/grovedb/src/operations/proof/aggregate_count/per_key.rs new file mode 100644 index 000000000..57b76a7c0 --- /dev/null +++ b/grovedb/src/operations/proof/aggregate_count/per_key.rs @@ -0,0 +1,221 @@ +//! Per-key carrier walker: dispatches leaf vs. carrier shape based on +//! the [`AggregateCountClassification`], walks the path-prefix layers +//! with single-key descents, then either emits a single +//! `(empty_key, u64)` entry (leaf shape) or executes the carrier's +//! multi-key merk proof and recurses through `subquery_path` per +//! matched outer key (carrier shape). +//! +//! V0 (`MerkOnlyLayerProof`) envelopes are rejected at the entry-point +//! gate in [`super::mod`] before they reach this walker — V0 predates +//! the aggregate-count feature and cannot legitimately carry one. + +use grovedb_merk::CryptoHash; +use grovedb_query::QueryItem; +use grovedb_version::version::GroveVersion; + +use crate::{ + operations::proof::{ + aggregate_count::{ + classification::AggregateCountClassification, + helpers::{ + enforce_lower_chain, execute_carrier_layer_proof, expect_merk_bytes, + verify_count_leaf, verify_single_key_layer_proof_v0, OuterMatch, + }, + }, + LayerProof, + }, + Error, PathQuery, +}; + +/// Entry point for the per-key carrier walker. Wraps the recursive +/// [`verify_v1_per_key`] with `depth = 0`. +pub(super) fn verify_v1_with_classification( + layer: &LayerProof, + path_query: &PathQuery, + path_keys: &[&[u8]], + classification: &AggregateCountClassification, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + verify_v1_per_key( + layer, + path_query, + path_keys, + 0, + classification, + grove_version, + ) +} + +/// Recursive worker for the per-key walk. While `depth < path_keys.len()` +/// it performs a single-key descent (same as the leaf chain walker); +/// once it reaches the carrier merk it dispatches on the classification +/// shape: leaf collapses to a one-entry result vector, carrier executes +/// the multi-key proof and fans out via [`verify_v1_carrier_layer`]. +fn verify_v1_per_key( + layer: &LayerProof, + path_query: &PathQuery, + path_keys: &[&[u8]], + depth: usize, + classification: &AggregateCountClassification, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; + + if depth < path_keys.len() { + let next_key = path_keys[depth].to_vec(); + let (proven_value_bytes, parent_root_hash, parent_proof_hash) = + verify_single_key_layer_proof_v0(merk_bytes, &next_key, path_query)?; + let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "aggregate-count proof missing lower layer for path key {}", + hex::encode(&next_key) + ), + ) + })?; + let (lower_hash, results) = verify_v1_per_key( + lower_layer, + path_query, + path_keys, + depth + 1, + classification, + grove_version, + )?; + enforce_lower_chain( + path_query, + &next_key, + &proven_value_bytes, + &lower_hash, + &parent_proof_hash, + grove_version, + )?; + return Ok((parent_root_hash, results)); + } + + match &classification.carrier_outer_items { + None => { + let (root, count) = + verify_count_leaf(merk_bytes, &classification.leaf_inner_range, path_query)?; + Ok((root, vec![(Vec::new(), count)])) + } + Some(outer_items) => verify_v1_carrier_layer( + layer, + merk_bytes, + path_query, + outer_items, + classification, + grove_version, + ), + } +} + +/// Execute the carrier's multi-key outer merk proof, then for each +/// matched outer key descend the `subquery_path` (if any) and the +/// leaf count proof, enforcing the chain at each step. Returns one +/// `(outer_key, count)` entry per match in query-direction order. +fn verify_v1_carrier_layer( + layer: &LayerProof, + merk_bytes: &[u8], + path_query: &PathQuery, + outer_items: &[QueryItem], + classification: &AggregateCountClassification, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, Vec<(Vec, u64)>), Error> { + let (carrier_root, matched) = execute_carrier_layer_proof( + merk_bytes, + outer_items, + classification.carrier_left_to_right, + path_query, + )?; + + let subquery_path = classification + .carrier_subquery_path + .as_ref() + .expect("carrier subquery_path is set when carrier_outer_items is Some"); + + let mut results = Vec::with_capacity(matched.len()); + for OuterMatch { + outer_key, + value_bytes, + commitment_hash, + } in matched + { + let lower_layer = layer.lower_layers.get(&outer_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "carrier aggregate-count proof missing lower layer for outer key {}", + hex::encode(&outer_key) + ), + ) + })?; + + let (lower_root, count) = verify_v1_subquery_path( + lower_layer, + path_query, + subquery_path, + 0, + &classification.leaf_inner_range, + grove_version, + )?; + + enforce_lower_chain( + path_query, + &outer_key, + &value_bytes, + &lower_root, + &commitment_hash, + grove_version, + )?; + results.push((outer_key, count)); + } + + Ok((carrier_root, results)) +} + +/// Walk the carrier's `subquery_path` (zero or more intermediate +/// single-key layers between an outer match and the leaf merk), +/// terminating in the merk-level count verifier. +fn verify_v1_subquery_path( + layer: &LayerProof, + path_query: &PathQuery, + subquery_path: &[Vec], + depth: usize, + inner_range: &QueryItem, + grove_version: &GroveVersion, +) -> Result<(CryptoHash, u64), Error> { + let merk_bytes = expect_merk_bytes(&layer.merk_proof, path_query)?; + if depth == subquery_path.len() { + return verify_count_leaf(merk_bytes, inner_range, path_query); + } + let next_key = subquery_path[depth].clone(); + let (proven_value_bytes, parent_root_hash, parent_proof_hash) = + verify_single_key_layer_proof_v0(merk_bytes, &next_key, path_query)?; + let lower_layer = layer.lower_layers.get(&next_key).ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + format!( + "carrier aggregate-count proof missing subquery_path layer for key {}", + hex::encode(&next_key) + ), + ) + })?; + let (lower_hash, count) = verify_v1_subquery_path( + lower_layer, + path_query, + subquery_path, + depth + 1, + inner_range, + grove_version, + )?; + enforce_lower_chain( + path_query, + &next_key, + &proven_value_bytes, + &lower_hash, + &parent_proof_hash, + grove_version, + )?; + Ok((parent_root_hash, count)) +} From 19739e5ed2de2d6af85f0fd38a7d412584d80330 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 05:43:14 +0700 Subject: [PATCH 09/15] fix(grovedb): canonical decoding for aggregate-count proofs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bincode::decode_from_slice` returns the number of bytes consumed but we were discarding it, so a valid proof with arbitrary trailing bytes appended would still decode to the same envelope and verify successfully. The chain-bound cryptographic check still gives the same `(RootHash, count)`, so this isn't a correctness/security problem — but it does mean a single logical proof has infinitely many byte encodings, which breaks any equality-by-bytes assumption (caching, deduplication, hashing the proof itself). `decode_grovedb_proof` now compares `consumed` to `proof.len()` and rejects with `Error::CorruptedData("trailing bytes after the encoded envelope")` when they differ. Test `aggregate_count_proof_with_trailing_bytes_is_rejected` pins the rejection at both entry points (`verify_aggregate_count_query` and `verify_aggregate_count_query_per_key`) with a real proof + one appended zero byte. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../proof/aggregate_count/helpers.rs | 18 +++++++- .../src/tests/aggregate_count_query_tests.rs | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/grovedb/src/operations/proof/aggregate_count/helpers.rs b/grovedb/src/operations/proof/aggregate_count/helpers.rs index 0fe12a938..d22ab6e45 100644 --- a/grovedb/src/operations/proof/aggregate_count/helpers.rs +++ b/grovedb/src/operations/proof/aggregate_count/helpers.rs @@ -32,13 +32,27 @@ use crate::{ /// Decode a serialized `GroveDBProof` envelope using the same bincode /// configuration the prover writes out. +/// +/// Decoding is canonical: trailing bytes beyond the encoded envelope +/// are rejected. Without this check the same `(RootHash, count)` could +/// be reconstructed from many different proof byte-strings (a proof and +/// the same proof with arbitrary suffix bytes), which is harmless for +/// the chain-bound correctness guarantee but breaks any +/// equality-by-bytes assumption a caller might rely on (caching, +/// deduplication, hashing the proof itself). pub(super) fn decode_grovedb_proof(proof: &[u8]) -> Result { let config = bincode::config::standard() .with_big_endian() .with_limit::<{ 256 * 1024 * 1024 }>(); - let (proof, _) = bincode::decode_from_slice(proof, config) + let (decoded, consumed) = bincode::decode_from_slice(proof, config) .map_err(|e| Error::CorruptedData(format!("unable to decode proof: {}", e)))?; - Ok(proof) + if consumed != proof.len() { + return Err(Error::CorruptedData(format!( + "aggregate-count proof has {} trailing bytes after the encoded envelope", + proof.len() - consumed + ))); + } + Ok(decoded) } /// Verify the leaf layer: bytes are the encoded count-proof Op stream; diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index 822b73b1b..2e69872eb 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -2144,6 +2144,49 @@ mod tests { } } + #[test] + fn aggregate_count_proof_with_trailing_bytes_is_rejected() { + // Decoding is canonical — a valid proof with any trailing + // bytes appended must be rejected, even though the + // cryptographic chain check would still bind the same + // `(RootHash, count)` result. Otherwise the same logical + // proof would have many distinct byte encodings, which breaks + // proof-equality / caching assumptions. + let v = GroveVersion::latest(); + let (db, _root) = 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()), + ); + let mut proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + // Sanity: the untouched proof verifies. + GroveDb::verify_aggregate_count_query(&proof, &path_query, v) + .expect("clean proof should verify"); + // Now append a single trailing byte and expect rejection from + // both entry points. + proof.push(0u8); + let leaf_err = GroveDb::verify_aggregate_count_query(&proof, &path_query, v) + .expect_err("leaf entry: trailing-byte proof must be rejected"); + match leaf_err { + crate::Error::CorruptedData(msg) => { + assert!(msg.contains("trailing bytes"), "unexpected message: {msg}") + } + other => panic!("expected CorruptedData, got {:?}", other), + } + let per_key_err = GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect_err("per-key entry: trailing-byte proof must be rejected"); + match per_key_err { + crate::Error::CorruptedData(msg) => { + assert!(msg.contains("trailing bytes"), "unexpected message: {msg}") + } + other => panic!("expected CorruptedData, got {:?}", other), + } + } + #[test] fn carrier_legacy_verifier_rejects_carrier_query() { // The legacy single-`u64` `verify_aggregate_count_query` strictly From f1c365a1b92f3ffe4a48b1b77ec514cd1d73d97f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 05:57:23 +0700 Subject: [PATCH 10/15] test(grovedb,query): pin nested-carrier rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original spec explicitly defers the "Range × Range × ACOR" / "IN × IN on prefix" composition: a carrier whose subquery is itself another carrier (rather than a leaf `AggregateCountOnRange`). The carrier validator already rejects this — it calls `validate_leaf_aggregate_count_on_range` on the subquery, which catches the inner carrier's outer items — but we didn't have a test documenting that contract. Two new tests, one per layer: - `grovedb-query::validate_carrier_aggregate_count_rejects_nested_carrier` drives the static validator with `outer.set_subquery(inner_carrier)` and asserts `InvalidOperation`. - `grovedb::rejects_nested_carrier_range_range_aggregate_count` builds a full `PathQuery` with the same shape and asserts both the static `PathQuery::validate_aggregate_count_on_range` and the prover's entry-point gate (`prove_query` → `InvalidQuery`) refuse. If we ever decide to support the nested shape, these tests are the contract that has to change first. Co-Authored-By: Claude Opus 4.7 (1M context) --- grovedb-query/src/aggregate_count.rs | 31 ++++++++++++ .../src/tests/aggregate_count_query_tests.rs | 50 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/grovedb-query/src/aggregate_count.rs b/grovedb-query/src/aggregate_count.rs index b1d6317eb..986978a70 100644 --- a/grovedb-query/src/aggregate_count.rs +++ b/grovedb-query/src/aggregate_count.rs @@ -689,6 +689,37 @@ mod tests { assert!(matches!(err, crate::error::Error::InvalidOperation(_))); } + #[test] + fn validate_carrier_aggregate_count_rejects_nested_carrier() { + // Out of scope: a "Range × Range × AggregateCountOnRange" + // shape — i.e. an outer carrier whose subquery is itself + // another carrier (with its own Range outer items + leaf + // AggregateCountOnRange subquery). This is the + // `IN × IN`-on-prefix case the spec explicitly defers. + // + // The carrier validator delegates to the *leaf* validator for + // the subquery, so a carrier-of-carrier subquery fails the + // leaf rules ("must contain exactly one item" / + // "validate called on a query without an AggregateCountOnRange + // item") — exactly what we want. + let mut inner_carrier = Query::new(); + inner_carrier + .items + .push(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + inner_carrier.set_subquery(make_leaf_aggregate_count_subquery()); + + let mut outer_carrier = Query::new(); + outer_carrier + .items + .push(QueryItem::Range(b"A".to_vec()..b"Z".to_vec())); + outer_carrier.set_subquery(inner_carrier); + + let err = outer_carrier + .validate_aggregate_count_on_range() + .expect_err("nested carrier (Range x Range x ACOR) must be rejected"); + assert!(matches!(err, crate::error::Error::InvalidOperation(_))); + } + #[test] fn validate_carrier_aggregate_count_rejects_carrier_subquery_with_invalid_inner() { // The carrier validator delegates to the leaf validator for the diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index 2e69872eb..fece5c6df 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -1727,6 +1727,56 @@ mod tests { assert_eq!(results[0].1, 500); } + #[test] + fn rejects_nested_carrier_range_range_aggregate_count() { + // Out of scope: a "Range × Range × AggregateCountOnRange" + // shape — an outer carrier whose subquery is *itself* another + // carrier. This is the `IN × IN`-on-prefix case the spec + // explicitly defers. The carrier validator delegates to the + // leaf validator for the subquery, which rejects because the + // inner carrier has its own outer items (not a single + // `AggregateCountOnRange`). Both the static validator and the + // prover's entry-point gate must refuse. + use grovedb_query::Query; + + // inner_carrier: Range outer + leaf aggregate-count subquery. + let mut inner_carrier = Query::new(); + inner_carrier + .items + .push(QueryItem::Range(b"a".to_vec()..b"z".to_vec())); + inner_carrier.set_subquery_path(vec![b"leaf".to_vec()]); + inner_carrier.set_subquery(Query::new_aggregate_count_on_range(QueryItem::Range( + b"a".to_vec()..b"z".to_vec(), + ))); + + // outer_carrier: Range outer + inner_carrier as subquery. + let mut outer_carrier = Query::new(); + outer_carrier + .items + .push(QueryItem::Range(b"A".to_vec()..b"Z".to_vec())); + outer_carrier.set_subquery_path(vec![b"middle".to_vec()]); + outer_carrier.set_subquery(inner_carrier); + + let pq = PathQuery::new( + vec![TEST_LEAF.to_vec()], + SizedQuery::new(outer_carrier, None, None), + ); + let v = GroveVersion::latest(); + + // Static validator rejects. + assert!( + pq.validate_aggregate_count_on_range().is_err(), + "nested carrier (Range x Range x ACOR) must fail validation" + ); + + // Prover entry-point gate also rejects. + let prove_result = make_test_grovedb(v).grove_db.prove_query(&pq, None, v); + match prove_result.value() { + Err(crate::Error::InvalidQuery(_)) => {} + other => panic!("expected InvalidQuery, got {:?}", other), + } + } + #[test] fn rejects_aggregate_count_at_both_levels() { // Try to build a query where the carrier ITSELF has an aggregate-count item From 7b083f571bc74c3a7a8eb9fd5145facb3f719e80 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 06:08:06 +0700 Subject: [PATCH 11/15] test(grovedb): pin SQL-style A=1, B>4, COUNT(C>4) carrier query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit end-to-end test that mirrors the canonical SQL aggregate-count query SELECT COUNT(*) FROM t WHERE a = 1 AND b > 4 AND c > 4 against an `(a, b, c)`-indexed grove laid out as TEST_LEAF / byA / / byB / / byC / The mapping to the carrier ACOR shape is: - `A = 1` is a fixed prefix → `path_query.path` - `B > 4` is the variable outer dimension → carrier's `RangeAfter` - per matched `B`, walk `byC` → carrier's `subquery_path` - `COUNT(C > 4)` is the leaf aggregate-count subquery The tree contains both `A = 1` and `A = 2` so we also confirm the fixed prefix actually scopes the result (only `A = 1` rows count). Expected: two `(b_val, 5)` entries — `b_5` and `b_7` each have 5 `C > c_4` matches. This shape was already supported via the existing carrier machinery — the test just makes the SQL → grove mapping obvious for callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/tests/aggregate_count_query_tests.rs | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index fece5c6df..ee84d5e6c 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -2051,6 +2051,141 @@ mod tests { } } + #[test] + fn carrier_sql_style_fixed_prefix_range_then_count_succeeds() { + // Demonstrates the SQL-style 3-column aggregate query + // + // SELECT COUNT(*) FROM t WHERE a = 1 AND b > 4 AND c > 4 + // + // against an `(a, b, c)`-indexed grove laid out as + // + // TEST_LEAF / byA / / byB / / byC / + // + // + // The mapping is: + // - `A = 1` is a fixed prefix → lives in `path_query.path` + // (the verifier walks it via single-key descents). + // - `B > 4` is the variable outer dimension → carrier's + // `RangeAfter("b_4")` item. + // - per matched `B`, walk `byC` → carrier's `subquery_path`. + // - `COUNT(C > 4)` is the leaf aggregate-count subquery. + // + // Expected: one `(b_val, count)` entry per matched `b > 4`, + // each carrying the count of `c > 4` under that `(a=1, b)` cell. + use grovedb_query::Query; + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + + // Build the tree: TEST_LEAF/byA/1/byB//byC/. + db.insert( + [TEST_LEAF].as_ref(), + b"byA", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert byA"); + // Insert two `A` values so we can confirm the path prefix + // actually scopes the count (queries with `A = 1` must not + // see anything under `A = 2`). + for a_val in [b"1".as_ref(), b"2".as_ref()] { + db.insert( + [TEST_LEAF, b"byA"].as_ref(), + a_val, + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert a_val"); + db.insert( + [TEST_LEAF, b"byA", a_val].as_ref(), + b"byB", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert byB"); + for b_val in [b"b_3".as_ref(), b"b_5".as_ref(), b"b_7".as_ref()] { + db.insert( + [TEST_LEAF, b"byA", a_val, b"byB"].as_ref(), + b_val, + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert b_val"); + db.insert( + [TEST_LEAF, b"byA", a_val, b"byB", b_val].as_ref(), + b"byC", + Element::empty_provable_count_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert byC"); + for i in 0..10u8 { + let c_key = format!("c_{i}"); + db.insert( + [TEST_LEAF, b"byA", a_val, b"byB", b_val, b"byC"].as_ref(), + c_key.as_bytes(), + Element::new_item(c_key.as_bytes().to_vec()), + None, + None, + v, + ) + .unwrap() + .expect("insert c"); + } + } + } + let expected_root = db.grove_db.root_hash(None, v).unwrap().expect("root_hash"); + + // Carrier: `B > "b_4"` outer, walk `byC`, count `C > "c_4"`. + let mut carrier = Query::new(); + carrier.items.push(QueryItem::RangeAfter(b"b_4".to_vec()..)); + carrier.set_subquery_path(vec![b"byC".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_on_range(QueryItem::RangeAfter( + b"c_4".to_vec().., + ))); + + // PathQuery: fix `A = 1` via the path prefix. + let path_query = PathQuery::new( + vec![ + TEST_LEAF.to_vec(), + b"byA".to_vec(), + b"1".to_vec(), + b"byB".to_vec(), + ], + SizedQuery::new(carrier, None, None), + ); + + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove (A=1, B>4, COUNT C>4) should succeed"); + let (got_root, results) = + GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(got_root, expected_root); + // B > "b_4" matches `b_5` and `b_7` (not `b_3`). For each + // matched B, count C > "c_4" → c_5..=c_9 → 5 elements. + assert_eq!(results.len(), 2, "expected b_5 and b_7"); + assert_eq!(results[0].0, b"b_5".to_vec()); + assert_eq!(results[1].0, b"b_7".to_vec()); + assert_eq!(results[0].1, 5); + assert_eq!(results[1].1, 5); + } + #[test] fn carrier_with_long_subquery_path_succeeds() { // Exercises a non-trivial `subquery_path` (length > 1) in the From 0dbeff4bd419c1328c5f1d52d985162f4b578b9e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 06:15:42 +0700 Subject: [PATCH 12/15] fix(grovedb): emit lower-layer for empty count tree under carrier ACOR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a carrier `AggregateCountOnRange` query's descent reached an empty `Element::ProvableCountTree(None, ..)` or `ProvableCountSumTree(None, ..)`, the V1 prover treated it as a terminal "tree with no children" result and inserted no `lower_layer` for that step. The carrier verifier then rejected the proof at the chain walk with `"carrier aggregate-count proof missing subquery_path layer for key …"`. The correct behavior is to return `count = 0` for that outer key — an existing empty leaf count tree is still a valid match. The fix is a new, narrow arm in `prove_subqueries_v1` that recurses into empty `Provable{Count,CountSum}Tree(None, ..)` elements when both: 1. The surrounding `PathQuery` is an aggregate-count query (`has_aggregate_count_on_range_anywhere() == true`), and 2. The current level's query has a subquery / matching subquery_path step on this key. The recursion opens the empty merk, hits the aggregate-count short-circuit, runs `prove_aggregate_count_on_range` on the empty subtree (which returns an empty op stream), and emits a `LayerProof { merk_proof: ProofBytes::Merk(empty), .. }`. The verifier's `verify_count_leaf` reads empty bytes as `(NULL_HASH, 0)`, and the chain check `combine_hash(H(empty_tree_value), NULL_HASH) == parent_value_hash` holds because the parent committed the tree element with the same `NULL_HASH` child root. Non-aggregate-count queries keep their existing "empty tree = terminal result" semantics. The narrow arm fires *before* the general empty-tree arm and only matches on the two provable-count flavors, so behavior is unchanged for every other tree type. Adds `carrier_returns_zero_count_for_empty_leaf_subtree` — inserts `byBrand/brand_000/color` as an empty `ProvableCountTree`, runs a carrier query, and asserts the returned vector is `[(b"brand_000", 0)]` with matching root hash. Co-Authored-By: Claude Opus 4.7 (1M context) --- grovedb/src/operations/proof/generate.rs | 44 +++++++++++ .../src/tests/aggregate_count_query_tests.rs | 74 +++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/grovedb/src/operations/proof/generate.rs b/grovedb/src/operations/proof/generate.rs index bcc5a6229..935af6772 100644 --- a/grovedb/src/operations/proof/generate.rs +++ b/grovedb/src/operations/proof/generate.rs @@ -1105,6 +1105,17 @@ impl GroveDb { .wrap_with_cost(cost); } + // Whether the surrounding query is an aggregate-count carrier: + // empty trees that match a `subquery_path` step still need a + // lower-layer descent so the aggregate-count short-circuit can + // emit an empty count proof (verifier reads it as count = 0). + // For non-aggregate-count queries, empty trees keep their + // existing "terminal result" semantics. + let is_aggregate_count_query = path_query + .query + .query + .has_aggregate_count_on_range_anywhere(); + let mut merk_proof = cost_return_on_error!( &mut cost, self.generate_merk_proof( @@ -1422,6 +1433,39 @@ impl GroveDb { } has_a_result_at_level |= true; } + // Empty count trees under an aggregate-count + // carrier still need a lower-layer descent — + // the recursion hits the ACOR short-circuit on + // the empty merk and emits an empty count proof + // (verifier reads it as count = 0). + Ok(Element::ProvableCountTree(None, ..)) + | Ok(Element::ProvableCountSumTree(None, ..)) + if !done_with_results + && is_aggregate_count_query + && query.has_subquery_or_matching_in_path_on_key(key) => + { + let mut lower_path = path.clone(); + lower_path.push(key.as_slice()); + + let previous_limit = *overall_limit; + + let layer_proof = cost_return_on_error!( + &mut cost, + self.prove_subqueries_v1( + lower_path, + path_query, + overall_limit, + prove_options, + current_depth + 1, + grove_version, + ) + ); + + if previous_limit != *overall_limit { + has_a_result_at_level |= true; + } + lower_layers.insert(key.clone(), layer_proof); + } // Empty trees and CommitmentTree without subquery Ok(Element::Tree(None, _)) | Ok(Element::SumTree(None, ..)) diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index ee84d5e6c..c34fe1c63 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -2186,6 +2186,80 @@ mod tests { assert_eq!(results[1].1, 5); } + #[test] + fn carrier_returns_zero_count_for_empty_leaf_subtree() { + // An outer-key match exists, the subquery_path resolves + // cleanly, but the **leaf** `ProvableCountTree` is empty + // (root_key = None). The verifier must still get a clean + // `(brand, 0)` entry — the leaf count proof for an empty + // merk is the empty op stream, which the merk-level verifier + // reads as `(NULL_HASH, 0)`, and the chain check + // `combine_hash(H(empty_tree_value), NULL_HASH) == + // parent_value_hash` holds because the parent committed the + // tree element with the same NULL_HASH child root. + use grovedb_query::Query; + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"byBrand", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert byBrand"); + db.insert( + [TEST_LEAF, b"byBrand"].as_ref(), + b"brand_000", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert brand"); + // Insert the leaf `color` count tree but leave it empty. + db.insert( + [TEST_LEAF, b"byBrand", b"brand_000"].as_ref(), + b"color", + Element::empty_provable_count_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert empty color count tree"); + let expected_root = db.grove_db.root_hash(None, v).unwrap().expect("root_hash"); + + let mut carrier = Query::new(); + carrier.insert_key(b"brand_000".to_vec()); + carrier.set_subquery_path(vec![b"color".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_on_range(QueryItem::Range( + b"a".to_vec()..b"z".to_vec(), + ))); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, None), + ); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed even when leaf count tree is empty"); + let (got_root, results) = + GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect("verify should succeed with (brand_000, 0)"); + assert_eq!(got_root, expected_root); + assert_eq!(results.len(), 1, "expected one entry for brand_000"); + assert_eq!(results[0].0, b"brand_000".to_vec()); + assert_eq!( + results[0].1, 0, + "empty leaf count tree must yield count = 0" + ); + } + #[test] fn carrier_with_long_subquery_path_succeeds() { // Exercises a non-trivial `subquery_path` (length > 1) in the From 2bbf7d176f486b0e9b2ef0ed15ef3dd4c73e1eb1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 06:25:59 +0700 Subject: [PATCH 13/15] fix(grovedb): tighten query_aggregate_count to leaf-only validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `query_aggregate_count` is the no-proof counterpart of `verify_aggregate_count_query`: it returns a single `u64` and has no way to surface per-outer-key carrier counts. But the validator it called — `path_query.validate_aggregate_count_on_range` — is the top-level dispatcher that accepts BOTH leaf and carrier shapes. A carrier-shape path query would slip past validation here, then the subsequent `count_aggregate_on_range` call would either error (wrong merk tree type at `path_query.path`) or silently miscompute because the function only ever counts the inner range against the single subtree at `path_query.path`. Same fix we applied to `verify_aggregate_count_query` earlier: switch to `validate_leaf_aggregate_count_on_range`, which rejects the carrier shape up front. The docstring is updated to point carrier-shape callers at `verify_aggregate_count_query_per_key` (the proof-based per-key entry). New test `no_proof_rejects_carrier_shape` constructs a valid carrier path query, sanity-checks that the dispatcher-level validator still accepts it (so the rejection below is specifically from the leaf-only tightening), and asserts the no-proof entry returns `InvalidQuery` with no storage reads. Co-Authored-By: Claude Opus 4.7 (1M context) --- grovedb/src/operations/get/query.rs | 24 +++++++---- .../src/tests/aggregate_count_query_tests.rs | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/grovedb/src/operations/get/query.rs b/grovedb/src/operations/get/query.rs index bbbe7d371..d2edea332 100644 --- a/grovedb/src/operations/get/query.rs +++ b/grovedb/src/operations/get/query.rs @@ -668,10 +668,16 @@ where { /// 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 + /// [`PathQuery::validate_leaf_aggregate_count_on_range`] — strictly the + /// **leaf** shape: a single `AggregateCountOnRange(_)` item, no + /// subqueries, no pagination, and an inner range that isn't `Key`, + /// `RangeFull`, or another `AggregateCountOnRange`. Carrier-shape + /// queries (outer `Keys` + `AggregateCountOnRange` subquery) are + /// rejected here because this entry point returns one `u64` and has + /// no way to surface per-outer-key counts; use + /// [`Self::prove_query`] + + /// [`Self::verify_aggregate_count_query_per_key`](GroveDb::verify_aggregate_count_query_per_key) + /// for those. 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 @@ -701,12 +707,14 @@ where { 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. + // Up-front shape validation. Strictly the leaf shape — this + // entry point returns a single `u64` and has no way to surface + // per-outer-key carrier results. Catches malformed leaf + // aggregate-count queries (illegal inner range, pagination, + // etc.) AND carrier-shape queries before any storage reads. let inner_range = cost_return_on_error_no_add!( cost, - path_query.validate_aggregate_count_on_range().cloned() + path_query.validate_leaf_aggregate_count_on_range().cloned() ); let tx = TxRef::new(&self.db, transaction); diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index c34fe1c63..b13e0f44d 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -1399,6 +1399,46 @@ mod tests { ); } + #[test] + fn no_proof_rejects_carrier_shape() { + // `query_aggregate_count` returns a single `u64` and has no way + // to surface per-outer-key carrier counts. Calling it with a + // carrier-shape path query must be rejected up front by the + // leaf-only validator, BEFORE any storage reads happen — even + // though the dispatcher-level `validate_aggregate_count_on_range` + // would have accepted the same query. + use grovedb_query::Query; + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_provable_count_tree(v); + + let mut carrier = Query::new(); + carrier.insert_key(b"brand_000".to_vec()); + carrier.set_subquery_path(vec![b"color".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_on_range(QueryItem::Range( + b"a".to_vec()..b"z".to_vec(), + ))); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + SizedQuery::new(carrier, None, None), + ); + + // Sanity: the dispatcher-level validator accepts this as a + // valid carrier, so the rejection below is specifically + // because `query_aggregate_count` tightens to leaf-only. + assert!(path_query.validate_aggregate_count_on_range().is_ok()); + + let err = db + .grove_db + .query_aggregate_count(&path_query, None, v) + .unwrap() + .expect_err("carrier shape must be rejected at the no-proof entry"); + assert!( + matches!(err, crate::Error::InvalidQuery(_)), + "expected InvalidQuery, got {:?}", + err + ); + } + #[test] fn no_proof_rejects_invalid_inner_range() { // Same shape check the prover/verifier use: Key inner is invalid for From fc4e09e68149f42a792cf75fbe8af86e1c71238a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 06:31:47 +0700 Subject: [PATCH 14/15] fix(grovedb): surface InvalidProof instead of panicking on classification drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify_v1_carrier_layer` was `.expect()`-ing that `classification.carrier_subquery_path` is `Some` whenever `carrier_outer_items` is `Some`. That invariant is held today by `classify_aggregate_count_path_query`, but a panic is the wrong failure mode if the invariant ever drifts — a verifier should surface verification failures, not abort. Replace with `ok_or_else` returning `Error::InvalidProof` with a contextual message. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/operations/proof/aggregate_count/per_key.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/grovedb/src/operations/proof/aggregate_count/per_key.rs b/grovedb/src/operations/proof/aggregate_count/per_key.rs index 57b76a7c0..4594c54e7 100644 --- a/grovedb/src/operations/proof/aggregate_count/per_key.rs +++ b/grovedb/src/operations/proof/aggregate_count/per_key.rs @@ -129,10 +129,19 @@ fn verify_v1_carrier_layer( path_query, )?; + // Invariant from `classify_aggregate_count_path_query`: whenever + // `carrier_outer_items` is `Some`, `carrier_subquery_path` is also + // `Some` (possibly empty). Surface a verification failure rather + // than aborting if the invariant ever drifts. let subquery_path = classification .carrier_subquery_path .as_ref() - .expect("carrier subquery_path is set when carrier_outer_items is Some"); + .ok_or_else(|| { + Error::InvalidProof( + path_query.clone(), + "carrier aggregate-count classification missing subquery_path".to_string(), + ) + })?; let mut results = Vec::with_capacity(matched.len()); for OuterMatch { From d1cd151edeb993068ddd422437a334668cd72353 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 15 May 2026 06:42:26 +0700 Subject: [PATCH 15/15] feat(grovedb): add no-proof query_aggregate_count_per_key entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the leaf/carrier asymmetry on the no-proof side. Before this commit: proof path: verify_aggregate_count_query (leaf only, -> u64) verify_aggregate_count_query_per_key (leaf or carrier, -> Vec<(key, u64)>) no-proof: query_aggregate_count (leaf only, -> u64) A carrier-shape caller that didn't need a proof had to go through `prove_query` + `verify_aggregate_count_query_per_key` anyway — paying proof generation, serialization, and chain verification just to throw the proof bytes away. `query_aggregate_count_per_key` mirrors the proof-path surface (returns `Vec<(Vec, u64)>`) and accepts the same shapes: - Leaf path query: delegates to `query_aggregate_count` and wraps as a one-entry vec with an empty key — matches the proof-path leaf-symmetry contract. - Carrier path query: opens the carrier subtree via `query_raw` with a "shallow" path query (carrier's outer items, no subquery) to enumerate matched outer keys. For each match, walks `subquery_path` manually and calls `count_aggregate_on_range` on the leaf merk. Absent outer keys contribute no entry; outer keys whose leaf is an empty `ProvableCountTree` contribute `(key, 0)`. Validation is the dispatcher-level `validate_aggregate_count_on_range` (accepts both shapes); non-ACOR queries get `InvalidQuery` before any storage reads. Non-tree matches on the carrier level surface `InvalidQuery` rather than the merk-level `count_aggregate_on_range` error. Six tests pin the contract: - `no_proof_per_key_leaf_matches_single_count` — leaf shape returns the same `u64` as `query_aggregate_count`, wrapped. - `no_proof_per_key_carrier_returns_per_outer_count` — two outer brands, 500 colors each -> two entries. - `no_proof_per_key_skips_absent_outer_keys` — absent outer key contributes no entry. - `no_proof_per_key_empty_leaf_returns_zero` — outer key exists, leaf is empty `ProvableCountTree` -> `(key, 0)`. - `no_proof_per_key_matches_proof_path_per_key` — element-for-element equality with `verify_aggregate_count_query_per_key` on a non-trivial 3-brand carrier query. - `no_proof_per_key_rejects_non_aggregate_count_query` — non-ACOR path queries rejected with `InvalidQuery`. Reuses the existing `query_aggregate_count_on_range` version gate since this is another entry point for the same feature, not a distinct protocol change. Co-Authored-By: Claude Opus 4.7 (1M context) --- grovedb/src/operations/get/query.rs | 153 +++++++++++++++ .../src/tests/aggregate_count_query_tests.rs | 183 ++++++++++++++++++ 2 files changed, 336 insertions(+) diff --git a/grovedb/src/operations/get/query.rs b/grovedb/src/operations/get/query.rs index d2edea332..b5d87d043 100644 --- a/grovedb/src/operations/get/query.rs +++ b/grovedb/src/operations/get/query.rs @@ -743,6 +743,159 @@ where { Ok(count).wrap_with_cost(cost) } + /// Executes an `AggregateCountOnRange` query in either the **leaf** or + /// **carrier** shape without generating a proof, returning one + /// `(outer_key, count)` pair per matched outer key. + /// + /// This is the no-proof counterpart of + /// [`GroveDb::verify_aggregate_count_query_per_key`]: it performs the + /// same merk-level boundary walks the per-key verifier reconstructs + /// from a proof but skips proof generation, encoding, decoding, and + /// chain verification entirely. + /// + /// For a **leaf** query the returned vector contains exactly one + /// entry whose key is an empty byte string and whose count is the + /// same `u64` [`Self::query_aggregate_count`] would have returned. + /// This matches the per-key verifier's leaf behavior, so callers + /// that always handle `Vec<(Vec, u64)>` don't need to branch on + /// the shape. + /// + /// For a **carrier** query the outer items must be `Key(_)` / + /// `Range*(_)` and the `default_subquery_branch.subquery` must + /// validate as a leaf `AggregateCountOnRange`. The optional + /// `subquery_path` is followed exactly (single-key step per element) + /// before the count walk. The returned vector has one entry per + /// matched outer key in query-direction order (ascending lex when + /// `left_to_right = true`, descending otherwise). Outer-key + /// candidates that don't exist contribute no entry; outer-key + /// candidates whose leaf subtree is empty contribute `(key, 0)`. + /// + /// `path_query` must satisfy + /// [`PathQuery::validate_aggregate_count_on_range`] in either + /// shape. Pagination is rejected. Each leaf subtree the walk + /// terminates in must be a `ProvableCountTree` or + /// `ProvableCountSumTree` — the merk-level walk rejects any other + /// tree type. + /// + /// The returned counts are **not** independently verifiable — + /// callers are trusting their own merk read path. For verifiable + /// counts, use [`Self::prove_query`] + + /// [`GroveDb::verify_aggregate_count_query_per_key`]. + pub fn query_aggregate_count_per_key( + &self, + path_query: &PathQuery, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult, u64)>, Error> { + check_grovedb_v0_with_cost!( + "query_aggregate_count_per_key", + grove_version + .grovedb_versions + .operations + .query + .query_aggregate_count_on_range + ); + + let mut cost = OperationCost::default(); + + // Up-front shape validation: accept both leaf and carrier shapes. + // We classify by what the top-level query owns: a direct + // `AggregateCountOnRange` item means leaf; otherwise the + // dispatcher already confirmed a valid carrier subquery exists. + let inner_range = cost_return_on_error_no_add!( + cost, + path_query.validate_aggregate_count_on_range().cloned() + ); + + if path_query.query.query.aggregate_count_on_range().is_some() { + // Leaf shape: delegate to the existing single-`u64` entry + // point and wrap as a one-entry vector with an empty key. + let count = cost_return_on_error!( + &mut cost, + self.query_aggregate_count(path_query, transaction, grove_version) + ); + return Ok(vec![(Vec::new(), count)]).wrap_with_cost(cost); + } + + // Carrier shape: enumerate matched outer keys at the carrier + // subtree, then per match navigate `subquery_path` and run the + // merk-level count walk on the leaf. + let q = &path_query.query.query; + let outer_items = q.items.clone(); + let subquery_path = q + .default_subquery_branch + .subquery_path + .clone() + .unwrap_or_default(); + let left_to_right = q.left_to_right; + + // Build a "shallow" path query that enumerates the carrier's + // outer items at `path_query.path` without descending into the + // subquery — we want just the matched outer keys, not the + // (unproven) results of the leaf aggregate-count. + let mut shallow_query = grovedb_query::Query::new_with_direction(left_to_right); + shallow_query.items = outer_items; + let shallow_pq = PathQuery::new_unsized(path_query.path.clone(), shallow_query); + + let (matched, _skipped) = cost_return_on_error!( + &mut cost, + self.query_raw( + &shallow_pq, + true, // allow_cache + false, // decrease_limit_on_range_with_no_sub_elements + true, // error_if_intermediate_path_tree_not_present + QueryResultType::QueryKeyElementPairResultType, + transaction, + grove_version, + ) + ); + + let key_elements = matched.to_key_elements(); + let mut results: Vec<(Vec, u64)> = Vec::with_capacity(key_elements.len()); + let tx = TxRef::new(&self.db, transaction); + + for (key, element) in key_elements { + // Refuse non-tree matches: aggregate-count requires + // descending into the matched element to find the leaf + // count subtree. + if !element.is_any_tree() { + return Err(Error::InvalidQuery( + "carrier aggregate-count matched a non-tree element; outer items must \ + resolve to tree elements", + )) + .wrap_with_cost(cost); + } + + // Build the path to the leaf count subtree: + // `path_query.path / outer_key / subquery_path...`. + let mut leaf_path_owned: Vec> = path_query.path.clone(); + leaf_path_owned.push(key.clone()); + leaf_path_owned.extend(subquery_path.iter().cloned()); + let leaf_path: Vec<&[u8]> = leaf_path_owned.iter().map(|p| p.as_slice()).collect(); + + let leaf_subtree = cost_return_on_error!( + &mut cost, + self.open_transactional_merk_at_path( + SubtreePath::from(leaf_path.as_slice()), + tx.as_ref(), + None, + grove_version, + ) + ); + + let count = cost_return_on_error!( + &mut cost, + leaf_subtree + .count_aggregate_on_range(&inner_range, grove_version) + .map_err(Error::MerkError) + ); + + results.push((key, count)); + } + + Ok(results).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 b13e0f44d..e4568ebc7 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -1603,6 +1603,189 @@ mod tests { assert_eq!(count, 0, "empty tree must return 0"); } + // ---------- No-proof per-key entry point ---------- + // + // `query_aggregate_count_per_key` is the no-proof counterpart of + // `verify_aggregate_count_query_per_key`: same surface shape + // (`Vec<(Vec, u64)>`), accepts both leaf and carrier path + // queries, but skips proof generation and verification entirely. + + #[test] + fn no_proof_per_key_leaf_matches_single_count() { + // Leaf-shape path query → returns a one-entry vec with an + // empty key and the same count `query_aggregate_count` + // returns (the per-key entry's leaf-symmetry contract). + 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()), + ); + + let single = db + .grove_db + .query_aggregate_count(&path_query, None, v) + .unwrap() + .expect("legacy single-u64 entry should succeed"); + let per_key = db + .grove_db + .query_aggregate_count_per_key(&path_query, None, v) + .unwrap() + .expect("per-key entry should succeed"); + + assert_eq!(single, 10); + assert_eq!(per_key.len(), 1); + assert_eq!(per_key[0].0, Vec::::new()); + assert_eq!(per_key[0].1, single); + } + + #[test] + fn no_proof_per_key_carrier_returns_per_outer_count() { + // Carrier shape → one (brand, count) entry per matched outer + // key, mirroring `verify_aggregate_count_query_per_key`'s + // contract. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001"], 1_000); + let path_query = carrier_count_path_query( + &[b"brand_000", b"brand_001"], + QueryItem::RangeAfter(b"color_00499".to_vec()..), + ); + let results = db + .grove_db + .query_aggregate_count_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof carrier query should succeed"); + assert_eq!(results.len(), 2); + assert_eq!(results[0].0, b"brand_000".to_vec()); + assert_eq!(results[1].0, b"brand_001".to_vec()); + assert_eq!(results[0].1, 500); + assert_eq!(results[1].1, 500); + } + + #[test] + fn no_proof_per_key_skips_absent_outer_keys() { + // Absent outer keys contribute no entry — same as the proof + // path's behavior. + let v = GroveVersion::latest(); + let (db, _root) = setup_brand_color_carrier_tree(v, &[b"brand_000"], 100); + let path_query = carrier_count_path_query( + &[b"brand_000", b"brand_missing"], + QueryItem::RangeAfter(b"color_00049".to_vec()..), + ); + let results = db + .grove_db + .query_aggregate_count_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof carrier query should succeed"); + assert_eq!(results.len(), 1, "absent key contributes no entry"); + assert_eq!(results[0].0, b"brand_000".to_vec()); + assert_eq!(results[0].1, 50); + } + + #[test] + fn no_proof_per_key_empty_leaf_returns_zero() { + // Outer key exists, subquery_path resolves cleanly, but the + // leaf count tree is empty. Match the proof path: emit + // `(key, 0)` rather than skipping or erroring. + use grovedb_query::Query; + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"byBrand", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert byBrand"); + db.insert( + [TEST_LEAF, b"byBrand"].as_ref(), + b"brand_000", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert brand"); + db.insert( + [TEST_LEAF, b"byBrand", b"brand_000"].as_ref(), + b"color", + Element::empty_provable_count_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert empty color"); + + let mut carrier = Query::new(); + carrier.insert_key(b"brand_000".to_vec()); + carrier.set_subquery_path(vec![b"color".to_vec()]); + carrier.set_subquery(Query::new_aggregate_count_on_range(QueryItem::Range( + b"a".to_vec()..b"z".to_vec(), + ))); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec(), b"byBrand".to_vec()], + SizedQuery::new(carrier, None, None), + ); + let results = db + .grove_db + .query_aggregate_count_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof carrier with empty leaf should succeed"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, b"brand_000".to_vec()); + assert_eq!(results[0].1, 0); + } + + #[test] + fn no_proof_per_key_matches_proof_path_per_key() { + // Cross-check: for a non-trivial carrier query, the no-proof + // result must agree element-for-element with the proof-based + // `verify_aggregate_count_query_per_key`. + let v = GroveVersion::latest(); + let (db, _root) = + setup_brand_color_carrier_tree(v, &[b"brand_000", b"brand_001", b"brand_002"], 100); + let path_query = carrier_count_path_query( + &[b"brand_000", b"brand_001", b"brand_002"], + QueryItem::RangeAfter(b"color_00049".to_vec()..), + ); + let no_proof = db + .grove_db + .query_aggregate_count_per_key(&path_query, None, v) + .unwrap() + .expect("no-proof should succeed"); + let proof = db + .grove_db + .prove_query(&path_query, None, v) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved) = GroveDb::verify_aggregate_count_query_per_key(&proof, &path_query, v) + .expect("verify should succeed"); + assert_eq!(no_proof, proved); + } + + #[test] + fn no_proof_per_key_rejects_non_aggregate_count_query() { + // Same validation gate as the proof per-key entry: non-ACOR + // path queries are rejected up front with `InvalidQuery`. + let v = GroveVersion::latest(); + let path_query = PathQuery::new_single_query_item( + vec![TEST_LEAF.to_vec()], + QueryItem::Key(b"k".to_vec()), + ); + let db = make_test_grovedb(v); + let err = db + .grove_db + .query_aggregate_count_per_key(&path_query, None, v) + .unwrap() + .expect_err("non-aggregate-count path query must be rejected"); + assert!(matches!(err, crate::Error::InvalidQuery(_))); + } + // ---------- Carrier aggregate-count end-to-end tests ---------- // // A "carrier" aggregate-count query is an outer fan-out — the outer query items