From 2559561c2fad887f23dce51988d7b09e3cf0047e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 14 Aug 2026 05:31:51 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20provable=20sum-budget=20reads=20?= =?UTF-8?q?=E2=80=94=20SumBudgetWindow=20in=20the=20GroveDBProof=20V1=20en?= =?UTF-8?q?velope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sum-budget path queries (ReadMode::SumBudget: walk items in key order, stop when the running net sum reaches the budget) gain a proof form: a new appended ProofBytes::SumBudgetWindow variant whose payload carries an ordinary Merk proof over exactly the window of elements the budget walk scanned, plus the window size and whether the walk exhausted the ranges. The verifier executes the window proof with the query's OWN items and direction (limited to the claimed window on a stop, unlimited on claimed exhaustion so the proof itself attests the range end), then REPLAYS the engine's fold arithmetic element by element — saturating net-budget subtraction, per-match limit, the grove-version global scan cap with its counted-but-unprocessed tripping element, skip semantics — and rejects a window that continues past a fired stop, stops short of one, or misstates exhaustion. The parent binding falls out of the ordinary combine_hash(H(value), child_root) tree-descent check, and a plain Merk descent at a sum-budget position is rejected so the shape can never be silently served as key selection. Provable fold semantics are pinned to SKIP non-sum elements and SKIP references — the two behaviors a single-subtree window proof can replay deterministically (reference targets live outside the window). The unified trusted read (run_path_query) switches to the same options so read and verified results agree over any state; the legacy AggregateSumPathQuery surface keeps its configurable options untouched. Corrections made while pinning the semantics against the engine: SumBudgetRead's cap field is renamed max_items_checked -> match_limit (the engine decrements it per MATCHED result, not per scanned element — the old name and doc were wrong; wire layout unchanged, stack unmerged), sum_limit > i64::MAX is now rejected at validation (the engine's budget arithmetic is signed), and AggregateSumQueryResult gains an elements_scanned field (the window size the prover needs, and a useful read-API datum on its own). Gated on the new proof.sum_budget_in_v1_envelope slot (0 in V1..V3, 1 in V4) on both sides, with the V0-envelope refusal following the ACOR template. verify_path_query returns the new VerifiedPathQuery::SumBudget { matches, total, stop } with the replay-attested SumBudgetStop reason. Tests: round trips for all four stop conditions (budget — including negative values giving budget back — match limit, exhaustion, plus skip-semantics windows with foreign elements); read/verified agreement across budget configs; forgeries (understated window, both directions of exhaustion lying, plain-descent substitution); V3-refuses/V4-serves gates on both sides. Full suites, clippy, verify-only build green. Co-Authored-By: Claude Fable 5 --- grovedb-query/src/read_mode.rs | 48 ++- grovedb-query/tests/query_encoding_golden.rs | 2 +- .../src/version/grovedb_versions.rs | 15 + grovedb-version/src/version/v1.rs | 1 + grovedb-version/src/version/v2.rs | 1 + grovedb-version/src/version/v3.rs | 1 + grovedb-version/src/version/v4.rs | 8 + .../src/element/aggregate_sum_query/mod.rs | 6 + grovedb/src/operations/get/run_path_query.rs | 17 +- grovedb/src/operations/proof/generate.rs | 251 ++++++++++-- grovedb/src/operations/proof/mod.rs | 78 ++++ grovedb/src/operations/proof/verify.rs | 245 +++++++++++- .../src/operations/proof/verify_path_query.rs | 85 ++++- grovedb/src/query/mod.rs | 37 +- grovedb/src/query/shape.rs | 6 +- grovedb/src/tests/mod.rs | 1 + grovedb/src/tests/read_mode_gate_tests.rs | 16 +- grovedb/src/tests/sum_budget_proof_tests.rs | 356 ++++++++++++++++++ 18 files changed, 1098 insertions(+), 76 deletions(-) create mode 100644 grovedb/src/tests/sum_budget_proof_tests.rs diff --git a/grovedb-query/src/read_mode.rs b/grovedb-query/src/read_mode.rs index 9cf36031f..b91035925 100644 --- a/grovedb-query/src/read_mode.rs +++ b/grovedb-query/src/read_mode.rs @@ -38,13 +38,17 @@ use crate::{axis_query::AxisQuery, error::Error, query::Query}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct SumBudgetRead { - /// Stop once the running sum of matched sum-item values reaches - /// this. Distinct from a result-count limit: how many entries that - /// takes depends on the data. + /// Stop once the running **net** sum of matched sum-item values + /// reaches this. Distinct from a result-count limit: how many + /// entries that takes depends on the data (and negative values give + /// budget back). Must fit in `i64` — the budget arithmetic is the + /// engine's signed saturating subtraction. pub sum_limit: u64, - /// Cap on elements scanned (matched or skipped), on top of the - /// grove-version global scan cap. `None` = only the global cap. - pub max_items_checked: Option, + /// Stop after this many **matched** sum items, regardless of + /// budget. `None` = no match cap. (Elements scanned but skipped — + /// non-sum elements, references — do not count; the grove-version + /// global scan cap bounds those separately.) + pub match_limit: Option, } impl SumBudgetRead { @@ -56,10 +60,16 @@ impl SumBudgetRead { selecting anything", )); } - if self.max_items_checked == Some(0) { + if self.sum_limit > i64::MAX as u64 { return Err(Error::InvalidOperation( - "sum-budget read: `max_items_checked` must be at least 1 when set; a zero \ - scan cap selects nothing", + "sum-budget read: `sum_limit` must fit in i64 — the budget arithmetic is \ + signed", + )); + } + if self.match_limit == Some(0) { + return Err(Error::InvalidOperation( + "sum-budget read: `match_limit` must be at least 1 when set; a zero match cap \ + selects nothing", )); } Ok(()) @@ -69,7 +79,7 @@ impl SumBudgetRead { impl Encode for SumBudgetRead { fn encode(&self, encoder: &mut E) -> Result<(), EncodeError> { self.sum_limit.encode(encoder)?; - self.max_items_checked.encode(encoder) + self.match_limit.encode(encoder) } } @@ -77,7 +87,7 @@ impl Decode for SumBudgetRead { fn decode>(decoder: &mut D) -> Result { Ok(Self { sum_limit: u64::decode(decoder)?, - max_items_checked: Option::::decode(decoder)?, + match_limit: Option::::decode(decoder)?, }) } } @@ -94,8 +104,8 @@ impl fmt::Display for SumBudgetRead { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "SumBudget {{ sum_limit: {}, max_items_checked: {:?} }}", - self.sum_limit, self.max_items_checked + "SumBudget {{ sum_limit: {}, match_limit: {:?} }}", + self.sum_limit, self.match_limit ) } } @@ -209,11 +219,11 @@ mod tests { ReadMode::Axis(AxisQuery::top_k(IndexAxis::Sum, 10, 20, true)), ReadMode::SumBudget(SumBudgetRead { sum_limit: 1000, - max_items_checked: Some(50), + match_limit: Some(50), }), ReadMode::SumBudget(SumBudgetRead { sum_limit: 1, - max_items_checked: None, + match_limit: None, }), ]; for mode in modes { @@ -234,7 +244,7 @@ mod tests { ); let budget = ReadMode::SumBudget(SumBudgetRead { sum_limit: 1, - max_items_checked: None, + match_limit: None, }); assert_eq!( bincode::encode_to_vec(&budget, config::standard()).unwrap()[0], @@ -250,19 +260,19 @@ mod tests { fn sum_budget_validation() { assert!(SumBudgetRead { sum_limit: 0, - max_items_checked: None + match_limit: None } .validate() .is_err()); assert!(SumBudgetRead { sum_limit: 1, - max_items_checked: Some(0) + match_limit: Some(0) } .validate() .is_err()); assert!(SumBudgetRead { sum_limit: 1, - max_items_checked: Some(1) + match_limit: Some(1) } .validate() .is_ok()); diff --git a/grovedb-query/tests/query_encoding_golden.rs b/grovedb-query/tests/query_encoding_golden.rs index 72c2854e4..96eb9dc08 100644 --- a/grovedb-query/tests/query_encoding_golden.rs +++ b/grovedb-query/tests/query_encoding_golden.rs @@ -93,7 +93,7 @@ fn read_mode_queries_use_version_2_and_round_trip() { let mut budget_query = Query::new_single_query_item(QueryItem::RangeFull(..)); budget_query.read_mode = Some(ReadMode::SumBudget(SumBudgetRead { sum_limit: 500, - max_items_checked: Some(100), + match_limit: Some(100), })); let bytes = encode(&budget_query); assert_eq!(bytes[0], 2); diff --git a/grovedb-version/src/version/grovedb_versions.rs b/grovedb-version/src/version/grovedb_versions.rs index 88d6ae356..ec276d02f 100644 --- a/grovedb-version/src/version/grovedb_versions.rs +++ b/grovedb-version/src/version/grovedb_versions.rs @@ -215,6 +215,21 @@ pub struct GroveDBOperationsProofVersions { /// exists. Indexed trees themselves cannot exist in pre-V4 /// production data, so `0` never rejects anything real. pub axis_descent_in_v1_envelope: FeatureVersion, + /// Whether the V1 proof envelope carries **sum-budget windows** + /// (`ProofBytes::SumBudgetWindow`), serving `PathQuery`s whose root + /// query node holds `ReadMode::SumBudget` — an ordinary Merk proof + /// over exactly the window the budget walk scanned, replayed by the + /// verifier with the engine's own fold arithmetic. + /// + /// - `0` (V1..V3): the prover refuses sum-budget queries and the + /// verifier rejects any proof/query pair involving one. + /// - `1` (V4+): served, with the fold replay attesting the stop + /// condition (budget reached / match limit / hard scan cap / + /// range exhausted). + /// + /// Gated because it adds an acceptance rule to the live V1 + /// envelope, same as `axis_descent_in_v1_envelope`. + pub sum_budget_in_v1_envelope: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/grovedb-version/src/version/v1.rs b/grovedb-version/src/version/v1.rs index 0577ee368..01cb8f295 100644 --- a/grovedb-version/src/version/v1.rs +++ b/grovedb-version/src/version/v1.rs @@ -169,6 +169,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { verify_query_get_parent_tree_info_with_options: 0, terminal_non_merk_tree_child_hash: 0, axis_descent_in_v1_envelope: 0, + sum_budget_in_v1_envelope: 0, }, average_case: GroveDBOperationsAverageCaseVersions { add_average_case_get_merk_at_path: 0, diff --git a/grovedb-version/src/version/v2.rs b/grovedb-version/src/version/v2.rs index 664de0b89..893ab59c8 100644 --- a/grovedb-version/src/version/v2.rs +++ b/grovedb-version/src/version/v2.rs @@ -169,6 +169,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { verify_query_get_parent_tree_info_with_options: 0, terminal_non_merk_tree_child_hash: 0, axis_descent_in_v1_envelope: 0, + sum_budget_in_v1_envelope: 0, }, average_case: GroveDBOperationsAverageCaseVersions { add_average_case_get_merk_at_path: 0, diff --git a/grovedb-version/src/version/v3.rs b/grovedb-version/src/version/v3.rs index a935c1e52..9874dbf86 100644 --- a/grovedb-version/src/version/v3.rs +++ b/grovedb-version/src/version/v3.rs @@ -173,6 +173,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { verify_query_get_parent_tree_info_with_options: 0, terminal_non_merk_tree_child_hash: 0, axis_descent_in_v1_envelope: 0, + sum_budget_in_v1_envelope: 0, }, average_case: GroveDBOperationsAverageCaseVersions { add_average_case_get_merk_at_path: 0, diff --git a/grovedb-version/src/version/v4.rs b/grovedb-version/src/version/v4.rs index 1e7caf985..9af6e579d 100644 --- a/grovedb-version/src/version/v4.rs +++ b/grovedb-version/src/version/v4.rs @@ -40,6 +40,13 @@ //! supplied raw. V1..V3 refuse the shape on both sides. Gated because it //! adds an acceptance rule to the live V1 envelope. //! +//! - `proof.sum_budget_in_v1_envelope: 1` — the V1 proof envelope carries +//! sum-budget windows (`ProofBytes::SumBudgetWindow`): an ordinary Merk +//! proof over exactly the window the budget walk scanned, whose stop +//! condition the verifier attests by replaying the engine's fold over the +//! proved elements. V1..V3 refuse the shape on both sides. Gated because +//! it adds an acceptance rule to the live V1 envelope. +//! //! - `path_query_methods.unified_read_mode: 1` — `PathQuery` read modes //! (axis-ordered and sum-budget reads carried in `Query::read_mode`) are //! served by the unified dispatch (`run_path_query`, and the unified @@ -239,6 +246,7 @@ pub const GROVE_V4: GroveVersion = GroveVersion { verify_query_get_parent_tree_info_with_options: 0, terminal_non_merk_tree_child_hash: 1, // bind terminal non-Merk tree element bytes to the parent value_hash axis_descent_in_v1_envelope: 1, // axis-ordered descents in the V1 envelope (ReadMode::Axis) + sum_budget_in_v1_envelope: 1, // sum-budget windows in the V1 envelope (ReadMode::SumBudget) }, average_case: GroveDBOperationsAverageCaseVersions { add_average_case_get_merk_at_path: 0, diff --git a/grovedb/src/element/aggregate_sum_query/mod.rs b/grovedb/src/element/aggregate_sum_query/mod.rs index d4f753630..066ea9d15 100644 --- a/grovedb/src/element/aggregate_sum_query/mod.rs +++ b/grovedb/src/element/aggregate_sum_query/mod.rs @@ -47,6 +47,10 @@ pub struct AggregateSumQueryResult { /// the query completed naturally. When true, more results may exist /// beyond what was returned. pub hard_limit_reached: bool, + /// Total elements the walk encountered (matched or skipped), + /// including the element that tripped the hard limit if it did. The + /// sum-budget proof shape uses this as its window size. + pub elements_scanned: u16, } /// Options controlling how an aggregate sum query is executed. @@ -302,6 +306,7 @@ impl ElementAggregateSumQueryExtensions for Element { return Ok(AggregateSumQueryResult { results, hard_limit_reached: false, + elements_scanned: 0, }) .wrap_with_cost(cost); } @@ -376,6 +381,7 @@ impl ElementAggregateSumQueryExtensions for Element { Ok(AggregateSumQueryResult { hard_limit_reached: elements_scanned > max_elements_scanned, + elements_scanned, results, }) .wrap_with_cost(cost) diff --git a/grovedb/src/operations/get/run_path_query.rs b/grovedb/src/operations/get/run_path_query.rs index 66f703260..61c655e08 100644 --- a/grovedb/src/operations/get/run_path_query.rs +++ b/grovedb/src/operations/get/run_path_query.rs @@ -290,15 +290,24 @@ impl GroveDb { items: items.to_vec(), left_to_right: path_query.query.query.left_to_right, sum_limit: budget.sum_limit, - limit_of_items_to_check: budget.max_items_checked, + limit_of_items_to_check: budget.match_limit, }, }; + // The unified sum-budget read uses the PROVABLE fold + // semantics — skip non-sum elements, skip references — + // so the trusted read and the sum-budget proof replay + // agree over any state. (The legacy AggregateSumPathQuery + // surface keeps its configurable options.) let result = cost_return_on_error!( &mut cost, - self.query_aggregate_sums( + self.query_aggregate_sums_with_options( &aggregate_sum_path_query, - allow_cache, - error_if_intermediate_path_tree_not_present, + crate::element::aggregate_sum_query::AggregateSumQueryOptions { + allow_cache, + error_if_intermediate_path_tree_not_present, + error_if_non_sum_item_found: false, + ignore_references: true, + }, transaction, grove_version, ) diff --git a/grovedb/src/operations/proof/generate.rs b/grovedb/src/operations/proof/generate.rs index edf838381..22ecfcec6 100644 --- a/grovedb/src/operations/proof/generate.rs +++ b/grovedb/src/operations/proof/generate.rs @@ -114,30 +114,23 @@ impl GroveDb { // gated below once `prove_version` is known. Sum-budget shapes // have no proof form yet. Anything malformed fails closed // rather than being misread as key selection. - let is_axis_shape = if path_query.query.query.has_read_mode_anywhere() { - match path_query.classify() { - Ok(crate::PathQueryShape::AxisRead { .. }) - | Ok(crate::PathQueryShape::BranchedAxisRead { .. }) => true, - Ok(crate::PathQueryShape::SumBudget { .. }) => { - return Err(Error::NotSupported( - "sum-budget path queries have no proof form yet; prove the underlying \ - items with a key-selection query and fold client-side, or use the \ - trusted read (run_path_query)" - .to_string(), - )) - .wrap_with_cost(OperationCost::default()); - } - Ok(_) => { - return Err(Error::CorruptedCodeExecution( - "a read-mode-bearing query classified as a non-read-mode shape", - )) - .wrap_with_cost(OperationCost::default()); + let (is_axis_shape, is_sum_budget_shape) = + if path_query.query.query.has_read_mode_anywhere() { + match path_query.classify() { + Ok(crate::PathQueryShape::AxisRead { .. }) + | Ok(crate::PathQueryShape::BranchedAxisRead { .. }) => (true, false), + Ok(crate::PathQueryShape::SumBudget { .. }) => (false, true), + Ok(_) => { + return Err(Error::CorruptedCodeExecution( + "a read-mode-bearing query classified as a non-read-mode shape", + )) + .wrap_with_cost(OperationCost::default()); + } + Err(e) => return Err(e).wrap_with_cost(OperationCost::default()), } - Err(e) => return Err(e).wrap_with_cost(OperationCost::default()), - } - } else { - false - }; + } else { + (false, false) + }; // Aggregate-count gate: validate at entry so malformed ACOR // queries (invalid inner range, ACOR-hidden-in-subquery, etc.) are // rejected up front instead of being skipped when the recursive @@ -213,6 +206,33 @@ impl GroveDb { } } + // Sum-budget shapes mirror the axis gates: V1 envelope only, and + // a GROVE_V4 capability on both sides. + if is_sum_budget_shape { + if prove_version == 0 { + return Err(Error::NotSupported( + "sum-budget path queries require V1 proof envelopes; upgrade the grove \ + version producing the proof" + .to_string(), + )) + .wrap_with_cost(OperationCost::default()); + } + if grove_version + .grovedb_versions + .operations + .proof + .sum_budget_in_v1_envelope + != 1 + { + return Err(Error::NotSupported( + "sum-budget windows in the V1 proof envelope are not emitted at this \ + grove version" + .to_string(), + )) + .wrap_with_cost(OperationCost::default()); + } + } + if is_acor_query && prove_version == 0 { return Err(Error::NotSupported( "AggregateCountOnRange proofs require V1 proof envelopes; upgrade the grove \ @@ -291,6 +311,117 @@ impl GroveDb { /// from a purely syntactic gate — gives callers an unstable /// error contract that depends on whether the merk happens to /// exist. + /// Build the [`SumBudgetWindowProof`] payload for the sum-budget + /// read at `target_path` (which is `path_query.path` — classify + /// admits the sum-budget node at the query root only). + /// + /// Runs the budget walk with the **provable** fold semantics (skip + /// non-sum elements, ignore references — the two behaviors a window + /// proof can replay deterministically; reference targets live + /// outside the window and cannot be) to learn the scanned window + /// size and stop condition, then emits an ordinary Merk proof over + /// exactly that window: limited to the window size when a stop + /// condition fired, unlimited when the walk exhausted the ranges + /// (so the proof itself attests exhaustion). + fn build_sum_budget_window_payload( + &self, + target_path: &[&[u8]], + path_query: &PathQuery, + transaction: &Transaction, + grove_version: &GroveVersion, + ) -> CostResult { + use grovedb_merk::proofs::query::{AggregateSumQuery, ReadMode}; + + use crate::element::aggregate_sum_query::{ + AggregateSumQueryOptions, ElementAggregateSumQueryExtensions, + }; + + let mut cost = OperationCost::default(); + + let node = &path_query.query.query; + let Some(ReadMode::SumBudget(budget)) = &node.read_mode else { + return Err(Error::CorruptedCodeExecution( + "sum-budget window build without a root sum-budget read", + )) + .wrap_with_cost(cost); + }; + + // 1. Run the budget walk with the provable fold semantics. + let aggregate_sum_path_query = crate::AggregateSumPathQuery { + path: target_path.iter().map(|segment| segment.to_vec()).collect(), + aggregate_sum_query: AggregateSumQuery { + items: node.items.clone(), + left_to_right: node.left_to_right, + sum_limit: budget.sum_limit, + limit_of_items_to_check: budget.match_limit, + }, + }; + let provable_options = AggregateSumQueryOptions { + allow_cache: true, + error_if_intermediate_path_tree_not_present: true, + error_if_non_sum_item_found: false, + ignore_references: true, + }; + let walk = cost_return_on_error!( + &mut cost, + Element::get_aggregate_sum_query( + &self.db, + &aggregate_sum_path_query, + provable_options, + Some(transaction), + grove_version, + ) + ); + + // 2. Determine the stop condition the verifier will replay. + let mut remaining: i64 = cost_return_on_error_no_add!( + cost, + i64::try_from(budget.sum_limit) + .map_err(|_| Error::InvalidQuery("sum-budget limit must fit in i64")) + ); + for (_, value) in &walk.results { + remaining = remaining.saturating_sub(*value); + } + let budget_reached = remaining <= 0; + let match_limit_reached = budget + .match_limit + .is_some_and(|limit| walk.results.len() >= limit as usize); + let exhausted = !budget_reached && !match_limit_reached && !walk.hard_limit_reached; + + // 3. Emit the Merk window proof with the query's own items. + let target_merk = cost_return_on_error!( + &mut cost, + self.open_transactional_merk_at_path( + target_path.into(), + transaction, + None, + grove_version + ) + ); + let mut window_query = grovedb_merk::proofs::Query::new_with_direction(node.left_to_right); + window_query.items = node.items.clone(); + let window_limit = if exhausted { + None + } else { + Some(walk.elements_scanned) + }; + let proof_result = cost_return_on_error!( + &mut cost, + target_merk + .prove(window_query, window_limit, grove_version) + .map_err(|e| Error::CorruptedData(format!( + "sum-budget window: merk proof over the scanned window: {e}" + ))) + ); + + Ok(crate::operations::proof::SumBudgetWindowProof { + exhausted, + window_len: walk.elements_scanned, + merk_proof: proof_result.proof, + }) + .wrap_with_cost(cost) + } + fn check_count_offset_target_tree_type( &self, path_query: &PathQuery, @@ -1903,6 +2034,80 @@ impl GroveDb { has_a_result_at_level |= true; } + // Sum-budget read of a merk-backed tree: the + // query node governing this element carries + // ReadMode::SumBudget, so the layer carries a + // sum-budget window — an ordinary Merk proof + // over exactly the window the budget walk + // scanned — instead of a key-selection + // descent. Matched before every other tree arm + // so the shape can never be silently served as + // a plain descent. + Ok(ref elem) + if !done_with_results && { + let mut lower_path = path.clone(); + lower_path.push(key.as_slice()); + path_query.sum_budget_read_at_path(&lower_path).is_some() + } => + { + use grovedb_merk::element::tree_type::ElementTreeTypeExtensions; + + if matches!( + elem, + Element::MmrTree(..) + | Element::BulkAppendTree(..) + | Element::DenseAppendOnlyFixedSizeTree(..) + | Element::CommitmentTree(..) + ) || elem.tree_type().is_none() + { + return Err(Error::NotSupported( + "sum-budget reads target merk-backed trees; the query \ + path names a different element kind" + .to_string(), + )) + .wrap_with_cost(cost); + } + if grove_version + .grovedb_versions + .operations + .proof + .sum_budget_in_v1_envelope + != 1 + { + return Err(Error::NotSupported( + "sum-budget windows in the V1 proof envelope are not \ + emitted at this grove version" + .to_string(), + )) + .wrap_with_cost(cost); + } + + let mut lower_path = path.clone(); + lower_path.push(key.as_slice()); + let payload = cost_return_on_error!( + &mut cost, + self.build_sum_budget_window_payload( + &lower_path, + path_query, + &tx, + grove_version, + ) + ); + let payload_bytes = + cost_return_on_error_no_add!(cost, payload.encode_canonical()); + lower_layers.insert( + key.clone(), + LayerProof { + merk_proof: + crate::operations::proof::ProofBytes::SumBudgetWindow( + payload_bytes, + ), + lower_layers: Default::default(), + }, + ); + has_a_result_at_level |= true; + } + // MmrTree with subquery → generate MMR proof // root_key is always None for MmrTree (no child Merk data) Ok(Element::MmrTree(mmr_size, _)) diff --git a/grovedb/src/operations/proof/mod.rs b/grovedb/src/operations/proof/mod.rs index 4722e7ad8..6f4a4e1cd 100644 --- a/grovedb/src/operations/proof/mod.rs +++ b/grovedb/src/operations/proof/mod.rs @@ -29,6 +29,8 @@ mod verify; #[cfg(any(feature = "minimal", feature = "verify"))] mod verify_path_query; +#[cfg(any(feature = "minimal", feature = "verify"))] +pub use verify::SumBudgetStop; #[cfg(any(feature = "minimal", feature = "verify"))] pub use verify_path_query::VerifiedPathQuery; @@ -293,6 +295,70 @@ pub enum ProofBytes { /// Appended after [`ProofBytes::IndexedTreeTerminal`] so every /// existing variant keeps its discriminant. IndexedTreeAxisDescent(Vec), + /// Sum-budget window: the layer for a merk-backed tree whose query + /// node carries `ReadMode::SumBudget`. The payload + /// ([`SumBudgetWindowProof`]) carries an ordinary Merk proof over + /// exactly the window of elements the budget walk scanned, plus the + /// window's size and whether the walk exhausted the range; the + /// verifier re-executes the proof with the query's own items and + /// **replays the budget fold** over the proved elements, so a + /// window that stops early, runs long, or hides elements fails. + /// The layer's root binds through the ordinary + /// `combine_hash(H(value), child_root)` parent check. + /// + /// Gated on `proof.sum_budget_in_v1_envelope` (GROVE_V4+). Appended + /// last so every existing variant keeps its discriminant. + SumBudgetWindow(Vec), +} + +/// Payload of [`ProofBytes::SumBudgetWindow`]. +/// +/// Trust model: `merk_proof` is verified cryptographically against the +/// query's items. `window_len` and `exhausted` are prover-supplied but +/// attested by the fold replay — a lying `window_len` disagrees with +/// the executed result set, and a lying `exhausted` either fails the +/// merk execution (a limited proof executed without a limit demands +/// data it does not carry) or contradicts the replayed stop condition. +#[derive(Debug, Clone, PartialEq, Encode, Decode)] +pub struct SumBudgetWindowProof { + /// Whether the walk ended because the query's ranges were exhausted + /// (`true`) rather than because a stop condition fired (`false`). + /// Decides the limit the verifier executes the merk proof with: + /// none when exhausted, `window_len` otherwise. + pub exhausted: bool, + /// Number of elements the walk scanned — the proof's window size. + pub window_len: u16, + /// Ordinary Merk proof over the window, built with the query's own + /// items and direction. + pub merk_proof: Vec, +} + +impl SumBudgetWindowProof { + /// Encode with the same big-endian config as the surrounding + /// envelope. + pub fn encode_canonical(&self) -> Result, Error> { + let config = bincode::config::standard().with_big_endian(); + bincode::encode_to_vec(self, config) + .map_err(|e| Error::CorruptedData(format!("unable to encode sum-budget window: {e}"))) + } + + /// Decode with trailing-byte rejection and a payload size cap. + pub fn decode_canonical(bytes: &[u8]) -> Result { + let config = bincode::config::standard() + .with_big_endian() + .with_limit::<{ 16 * 1024 * 1024 }>(); + let (decoded, consumed): (Self, usize) = bincode::decode_from_slice(bytes, config) + .map_err(|e| { + Error::CorruptedData(format!("unable to decode sum-budget window: {e}")) + })?; + if consumed != bytes.len() { + return Err(Error::CorruptedData(format!( + "sum-budget window payload has {} trailing bytes", + bytes.len() - consumed + ))); + } + Ok(decoded) + } } /// Payload of [`ProofBytes::IndexedTreeAxisDescent`]: everything the @@ -800,6 +866,18 @@ impl fmt::Display for ProofBytes { write!(f, "IndexedTreeTerminal()", bytes.len()) } } + ProofBytes::SumBudgetWindow(bytes) => { + match SumBudgetWindowProof::decode_canonical(bytes) { + Ok(payload) => write!( + f, + "SumBudgetWindow(exhausted={}, window_len={}, merk_proof={} bytes)", + payload.exhausted, + payload.window_len, + payload.merk_proof.len(), + ), + Err(_) => write!(f, "SumBudgetWindow()", bytes.len()), + } + } ProofBytes::IndexedTreeAxisDescent(bytes) => { match AxisDescentProof::decode_canonical(bytes) { Ok(payload) => write!( diff --git a/grovedb/src/operations/proof/verify.rs b/grovedb/src/operations/proof/verify.rs index 548ba91a7..b84d102a1 100644 --- a/grovedb/src/operations/proof/verify.rs +++ b/grovedb/src/operations/proof/verify.rs @@ -44,7 +44,7 @@ pub(crate) struct AxisWalkOutcome { pub(crate) result: AxisWalkResult, } -/// The verified answer of one axis-ordered layer, by traversal family. +/// The verified answer of one read-mode layer, by shape family. #[derive(Debug)] pub(crate) enum AxisWalkResult { /// `TopK` / `Bounded`: the entries in walk order. `skipped` is the @@ -58,6 +58,28 @@ pub(crate) enum AxisWalkResult { Rank { rank: u64 }, /// `RangeAggregate`: the attested aggregate over the value range. Aggregate { value: i128 }, + /// Sum-budget window: the matched `(key, value)` pairs, their net + /// total, and the replay-attested stop condition. + SumBudget { + matches: Vec<(Vec, i64)>, + total: i64, + stop: SumBudgetStop, + }, +} + +/// Why a verified sum-budget walk stopped — attested by the verifier's +/// fold replay over the proved window. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SumBudgetStop { + /// The running net sum reached the budget. + BudgetReached, + /// The match cap fired first. + MatchLimitReached, + /// The grove-version global scan cap fired first; more elements may + /// exist beyond the window. + HardScanCapReached, + /// The query's ranges were exhausted before any stop condition. + Exhausted, } impl GroveDb { @@ -763,7 +785,8 @@ impl GroveDb { | ProofBytes::CommitmentTree(_) | ProofBytes::CountIndexedTree(_) | ProofBytes::IndexedTreeTerminal(_) - | ProofBytes::IndexedTreeAxisDescent(_) => Err(Error::InvalidProof( + | ProofBytes::IndexedTreeAxisDescent(_) + | ProofBytes::SumBudgetWindow(_) => Err(Error::InvalidProof( query.clone(), "Expected Merk proof at this layer, got non-Merk proof type".to_string(), )), @@ -1072,6 +1095,193 @@ impl GroveDb { Ok(()) } + /// Verify one sum-budget window layer: execute the carried Merk + /// proof with the query's own items and **replay the budget fold** + /// — the exact per-element arithmetic of the trusted read engine, + /// with the provable semantics (skip non-sum elements, skip + /// references) — over the proved elements in walk order. + /// + /// The replay attests the stop condition: a window that continues + /// past a fired stop, claims exhaustion while a stop fired, or + /// claims a stop that never fired is rejected. Returns the window + /// proof's reconstructed root hash, which the surrounding walk + /// binds through the ordinary `combine_hash(H(value), child_root)` + /// parent check. + fn verify_sum_budget_window_layer( + payload_bytes: &[u8], + path: &[&[u8]], + axis_outcomes: &mut Vec, + query: &PathQuery, + grove_version: &GroveVersion, + ) -> Result { + use crate::operations::proof::SumBudgetWindowProof; + + // Envelope gate: sum-budget windows are a V4 acceptance rule. + if grove_version + .grovedb_versions + .operations + .proof + .sum_budget_in_v1_envelope + != 1 + { + return Err(Error::NotSupported( + "sum-budget windows in the V1 proof envelope are not accepted at this grove \ + version" + .to_string(), + )); + } + let Some(budget) = query.sum_budget_read_at_path(path).copied() else { + return Err(Error::InvalidProof( + query.clone(), + "the proof carries a sum-budget window at a path whose query node is not a \ + sum-budget read" + .to_string(), + )); + }; + let node = &query.query.query; + + let payload = SumBudgetWindowProof::decode_canonical(payload_bytes)?; + + // Execute the window proof with the query's own items and + // direction. When the prover claims exhaustion the proof must + // stand WITHOUT a limit (the range end is proven); otherwise it + // is limited to the claimed window. + let mut window_query = Query::new_with_direction(node.left_to_right); + window_query.items = node.items.clone(); + let execute_limit = if payload.exhausted { + None + } else { + Some(payload.window_len) + }; + let (window_root, window_result) = window_query + .execute_proof( + &payload.merk_proof, + execute_limit, + node.left_to_right, + PROOF_VERSION_LATEST, + ) + .unwrap() + .map_err(|e| { + Error::InvalidProof( + query.clone(), + format!("sum-budget window proof failed to execute: {e}"), + ) + })?; + + // Present rows only, in walk order; absent keys (value None) + // are covered by the proof but never scanned by the engine. + let window: Vec<(&Vec, &Vec)> = window_result + .result_set + .iter() + .filter_map(|row| row.value.as_ref().map(|value| (&row.key, value))) + .collect(); + if window.len() != payload.window_len as usize { + return Err(Error::InvalidProof( + query.clone(), + format!( + "sum-budget window claims {} scanned elements but the proof yields {}", + payload.window_len, + window.len() + ), + )); + } + + // Replay the budget fold with the engine's exact arithmetic. + let mut remaining: i64 = i64::try_from(budget.sum_limit) + .map_err(|_| Error::InvalidQuery("sum-budget limit must fit in i64"))?; + let mut matches_left = budget.match_limit; + let global_cap = grove_version + .grovedb_versions + .query_limits + .max_aggregate_sum_query_elements_scanned; + let mut scanned: u16 = 0; + let mut matches: Vec<(Vec, i64)> = Vec::new(); + let mut hard_cap_tripped = false; + + for (key, value_bytes) in &window { + // Pre-element conditions (the engine's loop guards): a + // window that continues past a fired stop hides where the + // walk really ended. + if remaining <= 0 || matches_left == Some(0) || hard_cap_tripped { + return Err(Error::InvalidProof( + query.clone(), + "sum-budget window continues past its stop condition".to_string(), + )); + } + scanned = scanned.saturating_add(1); + if scanned > global_cap { + // The engine counts the tripping element but does not + // process it. + hard_cap_tripped = true; + continue; + } + let element = Element::deserialize(value_bytes, grove_version)?; + // Provable fold semantics: references and non-sum elements + // are skipped (they cannot be replayed / do not contribute). + if element.is_reference() || !element.is_sum_item() { + continue; + } + let value = match element.into_underlying() { + Element::SumItem(value, _) => value, + Element::ItemWithSumItem(_, value, _) => value, + _ => { + return Err(Error::InvalidProof( + query.clone(), + "sum-budget window element passed the sum-item check but carries no \ + sum value" + .to_string(), + )); + } + }; + matches.push(((*key).clone(), value)); + if let Some(limit) = matches_left.as_mut() { + *limit = limit.saturating_sub(1); + } + remaining = remaining.saturating_sub(value); + } + + let stop_fired = remaining <= 0 || matches_left == Some(0) || hard_cap_tripped; + if payload.exhausted && stop_fired { + return Err(Error::InvalidProof( + query.clone(), + "sum-budget window claims exhaustion but a stop condition fired within it" + .to_string(), + )); + } + if !payload.exhausted && !stop_fired { + return Err(Error::InvalidProof( + query.clone(), + "sum-budget window claims a stop condition but none fired within it — the \ + window is short of the real stop" + .to_string(), + )); + } + + let stop = if payload.exhausted { + SumBudgetStop::Exhausted + } else if remaining <= 0 { + SumBudgetStop::BudgetReached + } else if matches_left == Some(0) { + SumBudgetStop::MatchLimitReached + } else { + SumBudgetStop::HardScanCapReached + }; + let total = matches + .iter() + .map(|(_, value)| *value) + .fold(0i64, |acc, v| acc.saturating_add(v)); + + axis_outcomes.push(AxisWalkOutcome { + path: path.iter().map(|segment| segment.to_vec()).collect(), + result: AxisWalkResult::SumBudget { + matches, + total, + stop, + }, + }); + Ok(window_root) + } + /// Derive a Merk layer's root hash without reporting any of its rows. /// /// Used when a subset verification stops at a tree ELEMENT that the proof @@ -1569,6 +1779,20 @@ impl GroveDb { // query, which only ever selects rows. let lower_hash = match &lower_layer.merk_proof { ProofBytes::Merk(_) => { + // A sum-budget layer must carry + // the window envelope; a plain + // Merk descent here would let a + // prover serve a read-mode query + // as key selection. + if query.sum_budget_read_at_path(&path).is_some() { + return Err(Error::InvalidProof( + query.clone(), + "the query node at this tree is a \ + sum-budget read; its lower layer must \ + carry ProofBytes::SumBudgetWindow" + .to_string(), + )); + } // Standard Merk subtree - recurse let merk_bytes = Self::merk_bytes_of_layer(lower_layer, query)?; @@ -1591,6 +1815,23 @@ impl GroveDb { Self::merk_layer_root_hash(merk_bytes, query)? } } + ProofBytes::SumBudgetWindow(payload_bytes) => { + if !lower_layer.lower_layers.is_empty() { + return Err(Error::InvalidProof( + query.clone(), + "a sum-budget window is terminal and must \ + not carry further lower layers" + .to_string(), + )); + } + Self::verify_sum_budget_window_layer( + payload_bytes, + &path, + axis_outcomes, + query, + grove_version, + )? + } ProofBytes::MMR(mmr_bytes) => Self::verify_mmr_lower_layer( mmr_bytes, &element, diff --git a/grovedb/src/operations/proof/verify_path_query.rs b/grovedb/src/operations/proof/verify_path_query.rs index 9b78eecde..99248509d 100644 --- a/grovedb/src/operations/proof/verify_path_query.rs +++ b/grovedb/src/operations/proof/verify_path_query.rs @@ -9,8 +9,12 @@ //! Axis shapes verify **GroveDBProof V1** envelopes carrying //! [`ProofBytes::IndexedTreeAxisDescent`](crate::operations::proof::ProofBytes) //! layers, gated on `proof.axis_descent_in_v1_envelope` (GROVE_V4+). +//! Sum-budget shapes verify V1 envelopes carrying +//! [`ProofBytes::SumBudgetWindow`](crate::operations::proof::ProofBytes) +//! layers, gated on `proof.sum_budget_in_v1_envelope` (GROVE_V4+), with +//! the stop condition attested by the verifier's fold replay. //! Key-selection and aggregate shapes route to the existing verifiers -//! unchanged. Sum-budget shapes have no proof form yet. +//! unchanged. use grovedb_merk::{proofs::query::AxisTraversal, CryptoHash}; use grovedb_version::version::GroveVersion; @@ -27,6 +31,8 @@ use crate::{ Error, GroveDb, PathQuery, }; +pub use crate::operations::proof::verify::SumBudgetStop; + /// The verified answer to any provable [`PathQuery`] shape — one /// variant per shape family. Every variant carries the reconstructed /// GroveDB root hash; compare it against the root you trust — @@ -108,6 +114,18 @@ pub enum VerifiedPathQuery { /// a signed sum for the sum axis. value: i128, }, + /// Sum-budget read: the proved window's matched sum items, their + /// net total, and the replay-attested stop condition. + SumBudget { + /// Reconstructed GroveDB root hash. + root_hash: CryptoHash, + /// The matched `(key, value)` pairs, in walk order. + matches: Vec<(Vec, i64)>, + /// Net total of the matched values (saturating). + total: i64, + /// Why the walk stopped, attested by the fold replay. + stop: SumBudgetStop, + }, } impl VerifiedPathQuery { @@ -122,7 +140,8 @@ impl VerifiedPathQuery { | VerifiedPathQuery::AxisEntries { root_hash, .. } | VerifiedPathQuery::BranchedAxisEntries { root_hash, .. } | VerifiedPathQuery::AxisRank { root_hash, .. } - | VerifiedPathQuery::AxisAggregate { root_hash, .. } => root_hash, + | VerifiedPathQuery::AxisAggregate { root_hash, .. } + | VerifiedPathQuery::SumBudget { root_hash, .. } => root_hash, } } } @@ -355,11 +374,63 @@ impl GroveDb { branches, }) } - PathQueryShape::SumBudget { .. } => Err(Error::NotSupported( - "sum-budget path queries have no proof form yet; use the trusted read \ - (run_path_query)" - .to_string(), - )), + PathQueryShape::SumBudget { .. } => { + if grove_version + .grovedb_versions + .operations + .proof + .sum_budget_in_v1_envelope + != 1 + { + return Err(Error::NotSupported( + "sum-budget windows in the V1 proof envelope are not accepted at this \ + grove version" + .to_string(), + )); + } + let decoded = decode_grovedb_proof_canonical(proof)?; + let GroveDBProof::V1(proof_v1) = decoded else { + return Err(Error::NotSupported( + "sum-budget path queries require V1 proof envelopes".to_string(), + )); + }; + let (root_hash, _, outcomes) = + Self::verify_proof_v1_with_axis_outcomes(&proof_v1, path_query, grove_version)?; + let [outcome]: [AxisWalkOutcome; 1] = + outcomes.try_into().map_err(|outcomes: Vec<_>| { + Error::InvalidProof( + path_query.clone(), + format!( + "a sum-budget read must verify exactly one window layer, got {}", + outcomes.len() + ), + ) + })?; + if outcome.path != path_query.path { + return Err(Error::InvalidProof( + path_query.clone(), + "the verified sum-budget window is not at the queried path".to_string(), + )); + } + let AxisWalkResult::SumBudget { + matches, + total, + stop, + } = outcome.result + else { + return Err(Error::InvalidProof( + path_query.clone(), + "the verified outcome does not match the query's sum-budget shape" + .to_string(), + )); + }; + Ok(VerifiedPathQuery::SumBudget { + root_hash, + matches, + total, + stop, + }) + } } } diff --git a/grovedb/src/query/mod.rs b/grovedb/src/query/mod.rs index af44aa19b..2577c7e19 100644 --- a/grovedb/src/query/mod.rs +++ b/grovedb/src/query/mod.rs @@ -659,13 +659,13 @@ impl PathQuery { items: Vec, left_to_right: bool, sum_limit: u64, - max_items_checked: Option, + match_limit: Option, ) -> Self { let mut query = Query::new_with_direction(left_to_right); query.items = items; query.read_mode = Some(ReadMode::SumBudget(SumBudgetRead { sum_limit, - max_items_checked, + match_limit, })); Self::new_unsized(path, query) } @@ -1218,6 +1218,34 @@ impl PathQuery { /// to `None` (a read mode lives on a query node, never mid-path), /// as do paths that diverge from the query entirely. pub fn axis_read_at_path(&self, path: &[&[u8]]) -> Option<&AxisQuery> { + match self.read_mode_at_path(path) { + Some(ReadMode::Axis(axis_query)) => Some(axis_query), + _ => None, + } + } + + /// The sum-budget read governing the subtree at `path`, if any — + /// the sum-budget sibling of [`Self::axis_read_at_path`]. The + /// budget's items live on the same node; callers re-resolve them + /// from the query root (a sum-budget read only classifies at the + /// root node). + pub fn sum_budget_read_at_path(&self, path: &[&[u8]]) -> Option<&SumBudgetRead> { + match self.read_mode_at_path(path) { + Some(ReadMode::SumBudget(budget)) => Some(budget), + _ => None, + } + } + + /// Returns the read mode of the query node resolved at exactly + /// `path`, if any. `path` is a full path from the GroveDB root (the + /// same convention as [`Self::query_items_at_path`]). + /// + /// This is how the proof walk — prover and verifier alike — learns + /// that a layer is a read-mode layer rather than a key-selecting + /// descent. Both sides resolve from the same query through this one + /// function, so they cannot disagree about which layers carry read + /// modes. + fn read_mode_at_path(&self, path: &[&[u8]]) -> Option<&ReadMode> { /// Resolve the query NODE at exactly `path` below `query`, /// following conditional and default subquery branches the same /// way `query_items_at_path`'s resolver does — but returning @@ -1280,10 +1308,7 @@ impl PathQuery { return None; } let node = resolve_node_at_path(&self.query.query, &path[self_path_len..])?; - match &node.read_mode { - Some(ReadMode::Axis(axis_query)) => Some(axis_query), - _ => None, - } + node.read_mode.as_ref() } /// Returns the query items applicable at the given path, if any. diff --git a/grovedb/src/query/shape.rs b/grovedb/src/query/shape.rs index 123fa4cd8..2a969e85a 100644 --- a/grovedb/src/query/shape.rs +++ b/grovedb/src/query/shape.rs @@ -765,7 +765,7 @@ mod tests { match pq.classify().expect("sum-budget constructor must classify") { PathQueryShape::SumBudget { budget, items } => { assert_eq!(budget.sum_limit, 500); - assert_eq!(budget.max_items_checked, Some(20)); + assert_eq!(budget.match_limit, Some(20)); assert_eq!(items.len(), 1); } other => panic!("expected SumBudget, got {other:?}"), @@ -854,7 +854,7 @@ mod tests { let mut terminal = Query::new_single_query_item(range_item()); terminal.read_mode = Some(ReadMode::SumBudget(SumBudgetRead { sum_limit: 1, - max_items_checked: None, + match_limit: None, })); let mut q = Query::new_single_key(b"b".to_vec()); q.set_subquery_path(vec![b"s".to_vec()]); @@ -948,7 +948,7 @@ mod tests { Some(ReadMode::SumBudget( grovedb_merk::proofs::query::SumBudgetRead { sum_limit: 10, - max_items_checked: None, + match_limit: None, }, )), ]; diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index 0333cfbf7..0d6ba8a6c 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -89,6 +89,7 @@ mod replication_session_tests; mod replication_utils_tests; mod run_path_query_tests; mod succinctness_gap_test; +mod sum_budget_proof_tests; mod test_compaction_sizes; mod test_provable_count_fresh; mod tree_hashes_tests; diff --git a/grovedb/src/tests/read_mode_gate_tests.rs b/grovedb/src/tests/read_mode_gate_tests.rs index dddc0eb85..e94a0399e 100644 --- a/grovedb/src/tests/read_mode_gate_tests.rs +++ b/grovedb/src/tests/read_mode_gate_tests.rs @@ -79,23 +79,17 @@ fn query_raw_rejects_read_mode_queries() { fn prove_query_gates_read_mode_queries() { let db = make_empty_grovedb(); - // Axis shapes are served from GROVE_V4 (round-trip coverage lives - // in axis_descent_proof_tests); below V4 the prover refuses them. + // Read-mode shapes are served from GROVE_V4 (round-trip coverage + // lives in axis_descent_proof_tests / sum_budget_proof_tests); + // below V4 the prover refuses them all. let v3 = &grovedb_version::version::GROVE_VERSIONS[2]; assert_eq!(v3.protocol_version, 3); - for path_query in [axis_path_query(), branched_axis_path_query()] { + for path_query in all_read_mode_queries() { assert_not_supported( db.prove_query(&path_query, None, v3).unwrap(), - "prove_query (axis, V3)", + "prove_query (read mode, V3)", ); } - - // Sum-budget shapes have no proof form at any version yet. - assert_not_supported( - db.prove_query(&sum_budget_path_query(), None, GroveVersion::latest()) - .unwrap(), - "prove_query (sum budget)", - ); } #[test] diff --git a/grovedb/src/tests/sum_budget_proof_tests.rs b/grovedb/src/tests/sum_budget_proof_tests.rs new file mode 100644 index 000000000..28fcb744a --- /dev/null +++ b/grovedb/src/tests/sum_budget_proof_tests.rs @@ -0,0 +1,356 @@ +//! End-to-end tests for sum-budget windows in the GroveDBProof V1 +//! envelope (`ProofBytes::SumBudgetWindow`): round trips for every stop +//! condition, read/verify agreement, skip semantics, forgeries, and the +//! GROVE_V4 gates. + +#[cfg(test)] +mod tests { + use grovedb_merk::proofs::query::query_item::QueryItem; + use grovedb_version::version::{GroveVersion, GROVE_VERSIONS}; + + use crate::{ + operations::proof::{ + GroveDBProof, LayerProof, ProofBytes, SumBudgetStop, SumBudgetWindowProof, + VerifiedPathQuery, + }, + tests::{make_test_sum_tree_grovedb, TEST_LEAF}, + Element, Error, GroveDb, PathQuery, + }; + + // ----------------------------------------------------------------- + // Fixtures: TEST_LEAF is a sum tree; keys a..f with mixed values. + // ----------------------------------------------------------------- + + const SUM_ENTRIES: &[(&[u8], i64)] = &[ + (b"a", 7), + (b"b", 5), + (b"c", -3), + (b"d", 11), + (b"e", 2), + (b"f", 40), + ]; + + fn build_sum_tree(db: &GroveDb, grove_version: &GroveVersion) { + for (key, sum) in SUM_ENTRIES { + db.insert( + [TEST_LEAF].as_ref(), + key, + Element::new_sum_item(*sum), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert sum item"); + } + } + + fn budget_query(sum_limit: u64, match_limit: Option) -> PathQuery { + PathQuery::new_sum_budget( + vec![TEST_LEAF.to_vec()], + vec![QueryItem::RangeFull(..)], + true, + sum_limit, + match_limit, + ) + } + + fn root_hash(db: &GroveDb, grove_version: &GroveVersion) -> [u8; 32] { + db.root_hash(None, grove_version).unwrap().expect("root") + } + + fn prove(db: &GroveDb, path_query: &PathQuery, grove_version: &GroveVersion) -> Vec { + db.prove_query(path_query, None, grove_version) + .unwrap() + .expect("prove sum-budget query") + } + + fn verify_budget( + proof: &[u8], + path_query: &PathQuery, + grove_version: &GroveVersion, + ) -> ([u8; 32], Vec<(Vec, i64)>, i64, SumBudgetStop) { + match GroveDb::verify_path_query(proof, path_query, grove_version) + .expect("sum-budget proof must verify") + { + VerifiedPathQuery::SumBudget { + root_hash, + matches, + total, + stop, + } => (root_hash, matches, total, stop), + other => panic!("expected SumBudget, got {other:?}"), + } + } + + // ----------------------------------------------------------------- + // Round trips per stop condition + // ----------------------------------------------------------------- + + #[test] + fn budget_stop_round_trip() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + build_sum_tree(&db, grove_version); + + // Budget 10: a(7) leaves 3, b(5) drives it to -2 → stop after b. + // (c and beyond never scanned.) + let pq = budget_query(10, None); + let (verified_root, matches, total, stop) = + verify_budget(&prove(&db, &pq, grove_version), &pq, grove_version); + assert_eq!(verified_root, root_hash(&db, grove_version)); + assert_eq!(stop, SumBudgetStop::BudgetReached); + assert_eq!(total, 12); + assert_eq!( + matches, + vec![(b"a".to_vec(), 7), (b"b".to_vec(), 5)], + "budget stop must fire exactly after b" + ); + } + + #[test] + fn negative_values_give_budget_back() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + build_sum_tree(&db, grove_version); + + // Budget 13: a(7)+b(5)=12 leaves 1; c(-3) gives budget back + // (remaining 4); d(11) drives it to -7 → stop after d. + let pq = budget_query(13, None); + let (_, matches, total, stop) = + verify_budget(&prove(&db, &pq, grove_version), &pq, grove_version); + assert_eq!(stop, SumBudgetStop::BudgetReached); + assert_eq!(matches.len(), 4); + assert_eq!(total, 20); + } + + #[test] + fn match_limit_stop_round_trip() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + build_sum_tree(&db, grove_version); + + // Huge budget, match limit 3 → stop after c. + let pq = budget_query(1_000_000, Some(3)); + let (_, matches, total, stop) = + verify_budget(&prove(&db, &pq, grove_version), &pq, grove_version); + assert_eq!(stop, SumBudgetStop::MatchLimitReached); + assert_eq!(matches.len(), 3); + assert_eq!(total, 9); + } + + #[test] + fn exhaustion_round_trip() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + build_sum_tree(&db, grove_version); + + // Budget larger than the whole tree's net sum (62) → exhausted. + let pq = budget_query(1_000_000, None); + let (_, matches, total, stop) = + verify_budget(&prove(&db, &pq, grove_version), &pq, grove_version); + assert_eq!(stop, SumBudgetStop::Exhausted); + assert_eq!(matches.len(), SUM_ENTRIES.len()); + assert_eq!(total, 62); + } + + // ----------------------------------------------------------------- + // Read / verify agreement (incl. skip semantics) + // ----------------------------------------------------------------- + + #[test] + fn verified_matches_equal_the_trusted_read() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + build_sum_tree(&db, grove_version); + + for (sum_limit, match_limit) in [(10u64, None), (13, None), (1_000_000, Some(3u16))] { + let pq = budget_query(sum_limit, match_limit); + let run = db + .run_path_query( + &pq, + true, + true, + true, + crate::query_result_type::QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("trusted read"); + let crate::operations::get::PathQueryRun::SumBudget(read) = run else { + panic!("expected SumBudget run"); + }; + let (_, matches, _, _) = + verify_budget(&prove(&db, &pq, grove_version), &pq, grove_version); + assert_eq!( + matches, read.results, + "sum_limit={sum_limit} match_limit={match_limit:?}: read and verified matches \ + must agree" + ); + } + } + + #[test] + fn non_sum_elements_are_scanned_and_skipped_identically() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + // a(7), then a plain item (skipped), then b(5). + db.insert( + [TEST_LEAF].as_ref(), + b"a", + Element::new_sum_item(7), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert a"); + db.insert( + [TEST_LEAF].as_ref(), + b"aa", + Element::new_item(b"not a sum".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert plain item"); + db.insert( + [TEST_LEAF].as_ref(), + b"b", + Element::new_sum_item(5), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert b"); + + // Budget 10 → a(7) leaves 3, the plain item is scanned and + // skipped, b(5) drives it negative → stop after b, two matches. + let pq = budget_query(10, None); + let (_, matches, total, stop) = + verify_budget(&prove(&db, &pq, grove_version), &pq, grove_version); + assert_eq!(stop, SumBudgetStop::BudgetReached); + assert_eq!(matches, vec![(b"a".to_vec(), 7), (b"b".to_vec(), 5)]); + assert_eq!(total, 12); + } + + // ----------------------------------------------------------------- + // Forgeries + // ----------------------------------------------------------------- + + fn tamper_window(proof: &[u8], mutate: impl FnOnce(&mut SumBudgetWindowProof)) -> Vec { + let config = bincode::config::standard().with_big_endian(); + let (mut decoded, _): (GroveDBProof, usize) = + bincode::decode_from_slice(proof, config).expect("decode envelope"); + let GroveDBProof::V1(ref mut v1) = decoded else { + panic!("expected V1 envelope"); + }; + fn find_window(layer: &mut LayerProof) -> Option<&mut LayerProof> { + if matches!(layer.merk_proof, ProofBytes::SumBudgetWindow(_)) { + return Some(layer); + } + layer.lower_layers.values_mut().find_map(find_window) + } + let window = find_window(&mut v1.root_layer).expect("envelope has a sum-budget window"); + let ProofBytes::SumBudgetWindow(bytes) = &window.merk_proof else { + unreachable!(); + }; + let mut payload = SumBudgetWindowProof::decode_canonical(bytes).expect("decode payload"); + mutate(&mut payload); + window.merk_proof = + ProofBytes::SumBudgetWindow(payload.encode_canonical().expect("re-encode")); + bincode::encode_to_vec(&decoded, config).expect("re-encode envelope") + } + + #[test] + fn understating_the_window_is_rejected() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + build_sum_tree(&db, grove_version); + let pq = budget_query(10, None); + let proof = prove(&db, &pq, grove_version); + GroveDb::verify_path_query(&proof, &pq, grove_version).expect("honest verifies"); + + // Claim the walk stopped one element earlier: the replay finds + // no stop condition fired within the shortened window. + let tampered = tamper_window(&proof, |payload| { + payload.window_len -= 1; + }); + GroveDb::verify_path_query(&tampered, &pq, grove_version) + .expect_err("an understated window must be rejected"); + } + + #[test] + fn lying_about_exhaustion_is_rejected() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + build_sum_tree(&db, grove_version); + + // Budget stops after b; claiming exhaustion must fail (the + // replay sees the budget fire inside the window). + let pq = budget_query(10, None); + let proof = prove(&db, &pq, grove_version); + let tampered = tamper_window(&proof, |payload| { + payload.exhausted = true; + }); + GroveDb::verify_path_query(&tampered, &pq, grove_version) + .expect_err("claiming exhaustion over a budget stop must be rejected"); + + // Conversely: a genuinely exhausted walk claiming a stop. + let pq = budget_query(1_000_000, None); + let proof = prove(&db, &pq, grove_version); + let tampered = tamper_window(&proof, |payload| { + payload.exhausted = false; + }); + GroveDb::verify_path_query(&tampered, &pq, grove_version) + .expect_err("claiming a stop over an exhausted walk must be rejected"); + } + + #[test] + fn plain_descent_at_a_sum_budget_position_is_rejected() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + build_sum_tree(&db, grove_version); + + // Prove the same window as a PLAIN key-selection query, then + // verify against the sum-budget query: the walk must reject the + // Merk layer where the window envelope is required. + let plain = PathQuery::new_unsized( + vec![TEST_LEAF.to_vec()], + grovedb_merk::proofs::Query::new_single_query_item(QueryItem::RangeFull(..)), + ); + let plain_proof = prove(&db, &plain, grove_version); + let pq = budget_query(10, None); + GroveDb::verify_path_query(&plain_proof, &pq, grove_version) + .expect_err("a plain descent at a sum-budget position must be rejected"); + } + + // ----------------------------------------------------------------- + // Gates + // ----------------------------------------------------------------- + + #[test] + fn sum_budget_windows_are_gated_to_grove_v4_on_both_sides() { + let v3 = &GROVE_VERSIONS[2]; + assert_eq!(v3.protocol_version, 3); + let v4 = GroveVersion::latest(); + + let db = make_test_sum_tree_grovedb(v4); + build_sum_tree(&db, v4); + let pq = budget_query(10, None); + + match db.prove_query(&pq, None, v3).unwrap() { + Err(Error::NotSupported(_)) => {} + other => panic!("V3 prover must refuse sum-budget shapes, got {other:?}"), + } + + let proof = prove(&db, &pq, v4); + match GroveDb::verify_path_query(&proof, &pq, v3) { + Err(Error::NotSupported(_)) => {} + other => panic!("V3 verifier must reject sum-budget shapes, got {other:?}"), + } + } +}