diff --git a/grovedb-query/src/read_mode.rs b/grovedb-query/src/read_mode.rs index 64caf0a2d..b0e92a11d 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 32f2faf25..790404fdf 100644 --- a/grovedb-query/tests/query_encoding_golden.rs +++ b/grovedb-query/tests/query_encoding_golden.rs @@ -91,7 +91,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(Box::new(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 4d4ce5916..27288719a 100644 --- a/grovedb/src/operations/get/run_path_query.rs +++ b/grovedb/src/operations/get/run_path_query.rs @@ -319,15 +319,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 daecc88f0..f59b32fa9 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.as_deref() 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, @@ -1940,6 +2071,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 5dab15177..2e0e679f3 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 @@ -1592,6 +1802,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)?; @@ -1614,6 +1838,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 a080771d3..d33839d82 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 — @@ -114,6 +120,18 @@ pub enum VerifiedPathQuery { /// applies. 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 { @@ -128,7 +146,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, } } } @@ -353,11 +372,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 6e4a78398..36e50a5df 100644 --- a/grovedb/src/query/mod.rs +++ b/grovedb/src/query/mod.rs @@ -670,13 +670,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(Box::new(ReadMode::SumBudget(SumBudgetRead { sum_limit, - max_items_checked, + match_limit, }))); Self::new_unsized(path, query) } @@ -1229,6 +1229,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 @@ -1291,10 +1319,7 @@ impl PathQuery { return None; } let node = resolve_node_at_path(&self.query.query, &path[self_path_len..])?; - match node.read_mode.as_deref() { - Some(ReadMode::Axis(axis_query)) => Some(axis_query), - _ => None, - } + node.read_mode.as_deref() } /// 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 3cf0b9aec..cc1fcd10b 100644 --- a/grovedb/src/query/shape.rs +++ b/grovedb/src/query/shape.rs @@ -783,7 +783,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:?}"), @@ -872,7 +872,7 @@ mod tests { let mut terminal = Query::new_single_query_item(range_item()); terminal.read_mode = Some(Box::new(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()]); @@ -971,7 +971,7 @@ mod tests { Some(Box::new(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 756c04561..e1bd945ba 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:?}"), + } + } +} diff --git a/grovedb/src/tests/verify_path_query_shape_tests.rs b/grovedb/src/tests/verify_path_query_shape_tests.rs index 730552e35..32e9dbfc1 100644 --- a/grovedb/src/tests/verify_path_query_shape_tests.rs +++ b/grovedb/src/tests/verify_path_query_shape_tests.rs @@ -10,7 +10,7 @@ mod tests { use grovedb_version::version::GroveVersion; use crate::{ - operations::proof::VerifiedPathQuery, + operations::proof::{SumBudgetStop, VerifiedPathQuery}, tests::{make_test_grovedb, TEST_LEAF}, Element, Error, GroveDb, PathQuery, SizedQuery, }; @@ -387,7 +387,7 @@ mod tests { } #[test] - fn sum_budget_path_queries_have_no_proof_form_yet() { + fn sum_budget_verifies_through_the_unified_entry() { let grove_version = GroveVersion::latest(); let db = make_test_grovedb(grove_version); build_pst(&db, b"pst", &[(b"a", 5), (b"b", 7)], grove_version); @@ -399,22 +399,48 @@ mod tests { 10, None, ); + let proof = prove(&db, &pq, grove_version); - // The prover refuses by name... - match db.prove_query(&pq, None, grove_version).unwrap() { - Err(Error::NotSupported(message)) => { - assert!(message.contains("sum-budget"), "got: {message}") - } - other => panic!("sum-budget proving must be refused, got {other:?}"), - } + // The proved window must agree with the trusted read over the + // same state. + let direct = db + .run_path_query( + &pq, + true, + true, + true, + crate::query_result_type::QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("trusted sum-budget read"); + let crate::operations::get::PathQueryRun::SumBudget(direct) = direct else { + panic!("expected PathQueryRun::SumBudget, got {direct:?}"); + }; - // ...and so does the verifier, pointing at the trusted read - // rather than verifying the shape as key selection. - match GroveDb::verify_path_query(&[], &pq, grove_version) { - Err(Error::NotSupported(message)) => { - assert!(message.contains("run_path_query"), "got: {message}") + match GroveDb::verify_path_query(&proof, &pq, grove_version) + .expect("unified sum-budget verify") + { + VerifiedPathQuery::SumBudget { + root_hash, + matches, + total, + stop, + } => { + assert_eq!( + root_hash, + db.root_hash(None, grove_version).unwrap().expect("root") + ); + // Budget 10 over 5 then 7: the walk stops on the entry + // that crosses the limit, so both are matched. + assert_eq!(matches, vec![(b"a".to_vec(), 5i64), (b"b".to_vec(), 7i64)]); + assert_eq!(total, 12); + assert_eq!(matches, direct.results); + // 5 + 7 crosses the budget of 10 on the second entry. + assert_eq!(stop, SumBudgetStop::BudgetReached); } - other => panic!("sum-budget verification must be refused, got {other:?}"), + other => panic!("expected SumBudget, got {other:?}"), } }