From f823c10c9d8163b2ef15809579399f7b4218575b Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 09:18:11 -0500 Subject: [PATCH 1/2] fix(sdk): cap aggregate verifier limits at the server's query limit The COUNT, SUM and AVG proof verifiers accepted request limits from 101 through 65535: they only guarded the u16 narrowing, while the server's aggregate dispatchers refuse anything above max_query_limit with InvalidLimit before producing proof bytes. When the matching range holds fewer entries than either limit, a genuine proof for a server-valid query also satisfies the wider path query, so an untrusted transport could pair a server-invalid request with a genuine proof. Gate all three verifiers behind one shared aggregate_limit check that mirrors the server cap (the compile-time default, pinned to stay within max_query_limit's default by a const assertion) and translate the unset-limit sentinel per walk shape in one place. Extracted unchanged from the trust-boundary hardening review rounds of #4389; the regression test drives all three FromProof entry points with a provider that panics if proof machinery is reached. --- .../src/documents/aggregate_limit.rs | 140 ++++++++++++++++++ .../src/documents/average_proof_helpers.rs | 37 ++--- .../src/documents/count_proof_helpers.rs | 59 +++----- .../src/documents/mod.rs | 1 + .../src/documents/sum_proof_helpers.rs | 38 ++--- .../tests/aggregate_limit_parity.rs | 105 +++++++++++++ 6 files changed, 289 insertions(+), 91 deletions(-) create mode 100644 packages/dash-platform-queries/src/documents/aggregate_limit.rs create mode 100644 packages/dash-platform-queries/tests/aggregate_limit_parity.rs diff --git a/packages/dash-platform-queries/src/documents/aggregate_limit.rs b/packages/dash-platform-queries/src/documents/aggregate_limit.rs new file mode 100644 index 00000000000..cc812e78dd7 --- /dev/null +++ b/packages/dash-platform-queries/src/documents/aggregate_limit.rs @@ -0,0 +1,140 @@ +//! Shared limit cap for the aggregate (COUNT / SUM / AVG) proof +//! verifiers. +//! +//! Server counterpart: the prove-path arms of drive's aggregate +//! dispatchers (`rs-drive`'s +//! `query/drive_document_{count,sum,average}_query/drive_dispatcher.rs`) +//! refuse any request whose limit exceeds +//! `drive_config.max_query_limit` with +//! `QuerySyntaxError::InvalidLimit` *before* producing proof bytes. +//! The verifier mirrors that gate against the compile-time +//! [`DEFAULT_MAX_QUERY_LIMIT`] — `max_query_limit`'s config default +//! (100); the SDK cannot see an operator's runtime tuning, and proof +//! bytes never depend on it — so a request the server would refuse +//! can never reach a proof primitive. Without the cap, an untrusted +//! transport could pair a server-invalid request (limit 101..=65535 +//! fits the wire's `u32` and even a `u16`) with a genuine proof +//! produced for a different, server-permitted query. +//! +//! The `0` sentinel ("no limit set on the wire": V0's `limit: 0`, +//! V1's `limit: None`) is translated per walk shape, mirroring the +//! server dispatchers exactly: +//! - distinct walk (`RangeDistinctProof`): `0` → +//! [`DEFAULT_QUERY_LIMIT`], because the server applies +//! `limit.unwrap_or(DEFAULT_QUERY_LIMIT)` before building the +//! path query; +//! - carrier walk (`RangeAggregateCarrierProof`): `0` → `None`, +//! because the server keeps an unset limit as an unbounded outer +//! walk. + +use drive::config::{DEFAULT_MAX_QUERY_LIMIT, DEFAULT_QUERY_LIMIT}; + +// The distinct-walk fallback (`DEFAULT_QUERY_LIMIT`, mirroring the +// server's `limit.unwrap_or(DEFAULT_QUERY_LIMIT)`) must itself pass +// the cap, exactly as the server checks its own fallback against +// `max_query_limit`. Both are 100 today with no compile-time link; +// this pin makes a future divergence a build error here instead of +// a silent parity break. +const _: () = assert!(DEFAULT_QUERY_LIMIT <= DEFAULT_MAX_QUERY_LIMIT); + +/// Reject a limit the server's aggregate prove paths would refuse +/// with `InvalidLimit`. Run this before any proof or +/// context-provider machinery; the walk-shape converters below +/// assume it has already passed. `0` (unset sentinel) always +/// passes — its meaning is resolved per walk shape. +pub(crate) fn check_within_server_cap( + limit: u32, + surface: &str, +) -> Result<(), drive_proof_verifier::Error> { + if limit > u32::from(DEFAULT_MAX_QUERY_LIMIT) { + return Err(drive_proof_verifier::Error::RequestError { + error: format!( + "limit {limit} exceeds the server's max_query_limit {DEFAULT_MAX_QUERY_LIMIT} \ + on the prove path ({surface}); the server refuses such requests with \ + InvalidLimit before producing proof bytes, so no proved response can \ + belong to this request" + ), + }); + } + Ok(()) +} + +/// Distinct-walk (`RangeDistinctProof`) limit: `0` falls back to +/// [`DEFAULT_QUERY_LIMIT`], mirroring the server's +/// `limit.unwrap_or(DEFAULT_QUERY_LIMIT)`. Callers must have run +/// [`check_within_server_cap`] first, which is what makes the +/// narrowing cast exact. +pub(crate) fn distinct_walk_limit(limit: u32) -> u16 { + debug_assert!(limit <= u32::from(DEFAULT_MAX_QUERY_LIMIT)); + if limit == 0 { + DEFAULT_QUERY_LIMIT + } else { + limit as u16 + } +} + +/// Carrier-walk (`RangeAggregateCarrierProof`) limit: `0` stays +/// `None` (unbounded outer walk), mirroring the server keeping an +/// unset request limit as `None`. Callers must have run +/// [`check_within_server_cap`] first, which is what makes the +/// narrowing cast exact. +pub(crate) fn carrier_walk_limit(limit: u32) -> Option { + debug_assert!(limit <= u32::from(DEFAULT_MAX_QUERY_LIMIT)); + if limit == 0 { + None + } else { + Some(limit as u16) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The cap is inclusive at `DEFAULT_MAX_QUERY_LIMIT`, exclusive + /// one past it, and the `0` sentinel always passes (its meaning + /// is resolved per walk shape) — the same acceptance set as the + /// server dispatchers' `> max_query_limit` rejection. + #[test] + fn cap_boundaries() { + assert!(check_within_server_cap(0, "TEST").is_ok()); + assert!(check_within_server_cap(1, "TEST").is_ok()); + assert!(check_within_server_cap(u32::from(DEFAULT_MAX_QUERY_LIMIT), "TEST").is_ok()); + + let error = check_within_server_cap(u32::from(DEFAULT_MAX_QUERY_LIMIT) + 1, "TEST") + .expect_err("one past the cap must be rejected"); + assert!( + error + .to_string() + .contains("exceeds the server's max_query_limit"), + "unexpected error: {error}" + ); + } + + /// Distinct walks translate the `0` sentinel to + /// `DEFAULT_QUERY_LIMIT`, mirroring the server's + /// `limit.unwrap_or(DEFAULT_QUERY_LIMIT)`; in-range values pass + /// through untouched. + #[test] + fn distinct_walk_sentinel_translation() { + assert_eq!(distinct_walk_limit(0), DEFAULT_QUERY_LIMIT); + assert_eq!(distinct_walk_limit(1), 1); + assert_eq!( + distinct_walk_limit(u32::from(DEFAULT_MAX_QUERY_LIMIT)), + DEFAULT_MAX_QUERY_LIMIT + ); + } + + /// Carrier walks keep the `0` sentinel as `None` (unbounded + /// outer walk), mirroring the server keeping an unset request + /// limit as `None`; in-range values pass through untouched. + #[test] + fn carrier_walk_sentinel_translation() { + assert_eq!(carrier_walk_limit(0), None); + assert_eq!(carrier_walk_limit(1), Some(1)); + assert_eq!( + carrier_walk_limit(u32::from(DEFAULT_MAX_QUERY_LIMIT)), + Some(DEFAULT_MAX_QUERY_LIMIT) + ); + } +} diff --git a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs index 5ab4e9bf77a..328dbc11c3d 100644 --- a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs @@ -101,6 +101,10 @@ pub(super) fn verify_average_query( platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + // First gate: a limit above the server's cap is refused on every + // server route with `InvalidLimit`, so no proof can belong to + // such a request — reject before any proof or provider machinery. + super::aggregate_limit::check_within_server_cap(request.limit, "AVG")?; let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -234,18 +238,11 @@ pub(super) fn verify_average_query( )) } DocumentSumMode::RangeDistinctProof => { - let limit_u16 = if request.limit == 0 { - drive::config::DEFAULT_QUERY_LIMIT - } else { - u16::try_from(request.limit).map_err(|_| { - drive_proof_verifier::Error::RequestError { - error: format!( - "limit {} exceeds u16::MAX for distinct AVG proof", - request.limit - ), - } - })? - }; + // `0` falls back to the compile-time + // `DEFAULT_QUERY_LIMIT` the server's dispatcher reads, + // matching SUM's distinct arm. Cap already enforced at + // the top of this function. + let limit_u16 = super::aggregate_limit::distinct_walk_limit(request.limit); let left_to_right = request .order_by_clauses .first() @@ -263,18 +260,10 @@ pub(super) fn verify_average_query( Ok((Some(entries), mtd.clone(), proof.clone())) } DocumentSumMode::RangeAggregateCarrierProof => { - let limit_u16 = if request.limit == 0 { - None - } else { - Some(u16::try_from(request.limit).map_err(|_| { - drive_proof_verifier::Error::RequestError { - error: format!( - "limit {} exceeds u16::MAX for carrier-aggregate AVG proof", - request.limit - ), - } - })?) - }; + // `0` stays `None` (unbounded outer walk), mirroring the + // server's carrier arm. Cap already enforced at the top + // of this function. + let limit_u16 = super::aggregate_limit::carrier_walk_limit(request.limit); let left_to_right = request .order_by_clauses .first() diff --git a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs index e19e9074f53..def1af52752 100644 --- a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs @@ -62,35 +62,6 @@ pub(super) fn assert_select_is_count( Ok(()) } -/// Translate the SDK's `u32`-with-`0`-sentinel limit into the -/// `u16` the proof verifier wants to rebuild the prover's path -/// query. -/// -/// `0` falls back to [`drive::config::DEFAULT_QUERY_LIMIT`] — the -/// same compile-time constant the server's prove-distinct -/// dispatcher reads (NOT the operator-tunable -/// `drive_config.default_query_limit`, which the SDK can't see). -/// With both sides anchored to the shared constant the path-query -/// bytes match byte-for-byte across operators, so merk-root -/// recomputation succeeds regardless of any operator's tuning. -/// -/// Non-zero values must fit in `u16` since the wire's -/// `optional uint32` is wider than the verifier's path-query -/// representation. We `try_from` rather than truncate so a caller -/// passing `limit > u16::MAX` fails loudly at the SDK boundary -/// rather than silently producing a mismatched path query. -fn limit_to_u16_or_default(limit: u32) -> Result { - if limit == 0 { - return Ok(drive::config::DEFAULT_QUERY_LIMIT); - } - u16::try_from(limit).map_err(|_| drive_proof_verifier::Error::RequestError { - error: format!( - "limit {} exceeds u16::MAX; the prove-distinct path query cannot represent it", - limit - ), - }) -} - /// Verify a count-shape proof and return per-branch entries. /// /// Single source of truth for the count-proof dispatch. Picks @@ -143,6 +114,10 @@ pub(super) fn verify_count_query( platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + // First gate: a limit above the server's cap is refused on every + // server route with `InvalidLimit`, so no proof can belong to + // such a request — reject before any proof or provider machinery. + super::aggregate_limit::check_within_server_cap(request.limit, "COUNT")?; let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -271,7 +246,13 @@ pub(super) fn verify_count_query( )) } DocumentCountMode::RangeDistinctProof => { - let limit_u16 = limit_to_u16_or_default(request.limit)?; + // `0` falls back to the compile-time + // `DEFAULT_QUERY_LIMIT` the server's prove-distinct + // dispatcher reads (NOT the operator-tunable runtime + // value, which the SDK can't see), so the path-query + // bytes match byte-for-byte across operators. Cap + // already enforced at the top of this function. + let limit_u16 = super::aggregate_limit::distinct_walk_limit(request.limit); let left_to_right = request .order_by_clauses .first() @@ -290,17 +271,13 @@ pub(super) fn verify_count_query( } DocumentCountMode::RangeAggregateCarrierProof => { // Carrier-ACOR (grovedb #663) — one verified `u64` per - // present In branch. `limit` cap on the per-branch - // walk follows the same `validate-don't-clamp` - // contract the distinct path uses; pass through what - // the caller asked for (with the `0` → default - // sentinel) so the path-query bytes match the - // server's exactly. - let limit_u16 = if request.limit == 0 { - None - } else { - Some(limit_to_u16_or_default(request.limit)?) - }; + // present In branch. `limit` on the per-branch walk + // follows the same `validate-don't-clamp` contract the + // distinct path uses; `0` stays `None` (unbounded outer + // walk, mirroring the server) so the path-query bytes + // match the server's exactly. Cap already enforced at + // the top of this function. + let limit_u16 = super::aggregate_limit::carrier_walk_limit(request.limit); let left_to_right = request .order_by_clauses .first() diff --git a/packages/dash-platform-queries/src/documents/mod.rs b/packages/dash-platform-queries/src/documents/mod.rs index 65cde6af086..fe81d09f98e 100644 --- a/packages/dash-platform-queries/src/documents/mod.rs +++ b/packages/dash-platform-queries/src/documents/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod aggregate_limit; pub(crate) mod average_proof_helpers; pub(crate) mod count_proof_helpers; pub mod document_average; diff --git a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs index fadfd00cfd8..1cf1731ba66 100644 --- a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs @@ -94,6 +94,10 @@ pub(super) fn verify_sum_query( platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + // First gate: a limit above the server's cap is refused on every + // server route with `InvalidLimit`, so no proof can belong to + // such a request — reject before any proof or provider machinery. + super::aggregate_limit::check_within_server_cap(request.limit, "SUM")?; let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -232,20 +236,10 @@ pub(super) fn verify_sum_query( // path — the server rejects over-max // (`max_query_limit`) requests with a typed // `InvalidLimit` error before producing proof bytes, - // so the SDK never sees a clamped value to - // un-clamp. - let limit_u16 = if request.limit == 0 { - drive::config::DEFAULT_QUERY_LIMIT - } else { - u16::try_from(request.limit).map_err(|_| { - drive_proof_verifier::Error::RequestError { - error: format!( - "limit {} exceeds u16::MAX for distinct SUM proof", - request.limit - ), - } - })? - }; + // and the cap check at the top of this function + // mirrors that rejection, so the SDK never sees a + // clamped value to un-clamp. + let limit_u16 = super::aggregate_limit::distinct_walk_limit(request.limit); let left_to_right = request .order_by_clauses .first() @@ -263,18 +257,10 @@ pub(super) fn verify_sum_query( Ok((Some(entries), mtd.clone(), proof.clone())) } DocumentSumMode::RangeAggregateCarrierProof => { - let limit_u16 = if request.limit == 0 { - None - } else { - Some(u16::try_from(request.limit).map_err(|_| { - drive_proof_verifier::Error::RequestError { - error: format!( - "limit {} exceeds u16::MAX for carrier-aggregate SUM proof", - request.limit - ), - } - })?) - }; + // `0` stays `None` (unbounded outer walk), mirroring the + // server's carrier arm. Cap already enforced at the top + // of this function. + let limit_u16 = super::aggregate_limit::carrier_walk_limit(request.limit); let left_to_right = request .order_by_clauses .first() diff --git a/packages/dash-platform-queries/tests/aggregate_limit_parity.rs b/packages/dash-platform-queries/tests/aggregate_limit_parity.rs new file mode 100644 index 00000000000..016ca5e4e8e --- /dev/null +++ b/packages/dash-platform-queries/tests/aggregate_limit_parity.rs @@ -0,0 +1,105 @@ +//! The aggregate verify paths (COUNT / SUM / AVG) must refuse every +//! request limit the server's aggregate dispatchers refuse — before +//! any proof or context-provider machinery runs. See +//! `src/documents/aggregate_limit.rs` for the server counterpart. + +use std::sync::Arc; + +use dapi_grpc::platform::v0::GetDocumentsResponse; +use dash_context_provider::{ContextProvider, ContextProviderError}; +use dash_platform_queries::documents::document_query::DocumentQuery; +use dpp::dashcore::Network; +use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; +use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; +use dpp::tests::fixtures::get_data_contract_fixture; +use dpp::version::PlatformVersion; +use drive::query::SelectProjection; +use drive_proof_verifier::{DocumentAverage, DocumentCount, DocumentSum, FromProof}; + +fn test_contract() -> Arc { + let platform_version = PlatformVersion::latest(); + Arc::new( + get_data_contract_fixture(None, 0, platform_version.protocol_version).data_contract_owned(), + ) +} + +fn documents_query() -> DocumentQuery { + DocumentQuery::new(test_contract(), "niceDocument").expect("document type exists") +} + +/// Fails the test if proof verification is reached at all. +struct NeverCalledProvider; + +impl ContextProvider for NeverCalledProvider { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + panic!("request must be rejected before proof verification starts") + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + panic!("request must be rejected before proof verification starts") + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + panic!("request must be rejected before proof verification starts") + } + + fn get_platform_activation_height(&self) -> Result { + panic!("request must be rejected before proof verification starts") + } +} + +/// The aggregate verify paths (COUNT / SUM / AVG) share the same +/// server-side limit cap — drive's aggregate dispatchers refuse +/// over-`max_query_limit` requests with `InvalidLimit` before +/// producing proof bytes — so their verifiers must refuse the +/// same requests before any proof machinery. Exercised through +/// all three `FromProof` entry points, so dropping the shared +/// `aggregate_limit::check_within_server_cap` gate from any one +/// of them fails this test. The panicking provider pins that the +/// rejection precedes all proof machinery, and the asserted +/// message pins that the limit gate (not the missing proof in +/// the default response) is what fired. +#[test] +fn rejects_aggregate_limit_above_server_cap() { + fn assert_over_cap_rejected(select: SelectProjection, surface: &str) + where + T: FromProof + + std::fmt::Debug, + { + for limit in [101u32, 65_535, u32::MAX] { + let query = documents_query() + .with_select(select.clone()) + .with_limit(limit); + let error = T::maybe_from_proof_with_metadata( + query, + GetDocumentsResponse::default(), + Network::Testnet, + PlatformVersion::latest(), + &NeverCalledProvider, + ) + .expect_err("an over-cap limit on an aggregate verify path must be rejected"); + assert!( + error + .to_string() + .contains("exceeds the server's max_query_limit 100"), + "unexpected error for {surface} limit {limit}: {error}" + ); + } + } + + assert_over_cap_rejected::(SelectProjection::count_star(), "COUNT"); + assert_over_cap_rejected::(SelectProjection::sum("age"), "SUM"); + assert_over_cap_rejected::(SelectProjection::avg("age"), "AVG"); +} From 7769e3177335444dd4b08bd80164378990cde3b6 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 20 Aug 2026 09:18:28 -0500 Subject: [PATCH 2/2] fix(sdk): make COUNT carrier limit translation shape-aware The shared carrier converter treated every carrier proof as the In-outer shape, but the COUNT dispatcher applies shape-dependent rules: range-outer (G8) lowers an unset limit to MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT and refuses explicit limits above that cap, while In-outer (G7) keeps None and refuses every explicit limit. The old translation both rejected honest G8 proofs (reconstructing limit: None where the server proved limit: 10) and accepted explicit limits the server refuses. Mirror the server's own shape test (two range operators among the where clauses) and its per-shape lowering. The cap check now returns a ServerCappedLimit witness whose methods perform the walk conversions, so the check-before-convert ordering is enforced by the type system instead of a debug_assert. --- .../src/documents/aggregate_limit.rs | 205 ++++++++++++++---- .../src/documents/average_proof_helpers.rs | 6 +- .../src/documents/count_proof_helpers.rs | 26 ++- .../src/documents/sum_proof_helpers.rs | 6 +- 4 files changed, 189 insertions(+), 54 deletions(-) diff --git a/packages/dash-platform-queries/src/documents/aggregate_limit.rs b/packages/dash-platform-queries/src/documents/aggregate_limit.rs index cc812e78dd7..af55ea36cca 100644 --- a/packages/dash-platform-queries/src/documents/aggregate_limit.rs +++ b/packages/dash-platform-queries/src/documents/aggregate_limit.rs @@ -23,9 +23,13 @@ //! [`DEFAULT_QUERY_LIMIT`], because the server applies //! `limit.unwrap_or(DEFAULT_QUERY_LIMIT)` before building the //! path query; -//! - carrier walk (`RangeAggregateCarrierProof`): `0` → `None`, -//! because the server keeps an unset limit as an unbounded outer -//! walk. +//! - carrier walk (`RangeAggregateCarrierProof`), SUM / AVG: `0` → +//! `None`, because those servers keep an unset limit as an +//! unbounded outer walk; +//! - carrier walk, COUNT: shape-dependent — see +//! [`ServerCappedLimit::count_carrier_walk_limit`], whose +//! range-outer arm enforces the stricter compile-time cap the +//! COUNT dispatcher applies. use drive::config::{DEFAULT_MAX_QUERY_LIMIT, DEFAULT_QUERY_LIMIT}; @@ -37,15 +41,24 @@ use drive::config::{DEFAULT_MAX_QUERY_LIMIT, DEFAULT_QUERY_LIMIT}; // a silent parity break. const _: () = assert!(DEFAULT_QUERY_LIMIT <= DEFAULT_MAX_QUERY_LIMIT); +/// A request limit that has passed the server's aggregate +/// prove-path cap. Constructing one via [`check_within_server_cap`] +/// is the only way to reach the walk-shape converters, so the +/// "cap check runs first" ordering — what makes their narrowing +/// casts exact — is enforced by the type system rather than a +/// `debug_assert!` that vanishes from release builds. +#[derive(Debug)] +pub(crate) struct ServerCappedLimit(u32); + /// Reject a limit the server's aggregate prove paths would refuse /// with `InvalidLimit`. Run this before any proof or -/// context-provider machinery; the walk-shape converters below -/// assume it has already passed. `0` (unset sentinel) always +/// context-provider machinery; the returned [`ServerCappedLimit`] +/// carries the proof that it passed. `0` (unset sentinel) always /// passes — its meaning is resolved per walk shape. pub(crate) fn check_within_server_cap( limit: u32, surface: &str, -) -> Result<(), drive_proof_verifier::Error> { +) -> Result { if limit > u32::from(DEFAULT_MAX_QUERY_LIMIT) { return Err(drive_proof_verifier::Error::RequestError { error: format!( @@ -56,34 +69,91 @@ pub(crate) fn check_within_server_cap( ), }); } - Ok(()) + Ok(ServerCappedLimit(limit)) } -/// Distinct-walk (`RangeDistinctProof`) limit: `0` falls back to -/// [`DEFAULT_QUERY_LIMIT`], mirroring the server's -/// `limit.unwrap_or(DEFAULT_QUERY_LIMIT)`. Callers must have run -/// [`check_within_server_cap`] first, which is what makes the -/// narrowing cast exact. -pub(crate) fn distinct_walk_limit(limit: u32) -> u16 { - debug_assert!(limit <= u32::from(DEFAULT_MAX_QUERY_LIMIT)); - if limit == 0 { - DEFAULT_QUERY_LIMIT - } else { - limit as u16 +impl ServerCappedLimit { + /// Distinct-walk (`RangeDistinctProof`) limit: `0` falls back + /// to [`DEFAULT_QUERY_LIMIT`], mirroring the server's + /// `limit.unwrap_or(DEFAULT_QUERY_LIMIT)`. + pub(crate) fn distinct_walk_limit(&self) -> u16 { + if self.0 == 0 { + DEFAULT_QUERY_LIMIT + } else { + self.0 as u16 + } + } + + /// Carrier-walk (`RangeAggregateCarrierProof`) limit for SUM / + /// AVG: `0` stays `None` (unbounded outer walk), mirroring + /// those servers keeping an unset request limit as `None`. + /// COUNT must use [`Self::count_carrier_walk_limit`] instead — + /// its dispatcher applies shape-dependent rules this converter + /// does not know about. + pub(crate) fn carrier_walk_limit(&self) -> Option { + if self.0 == 0 { + None + } else { + Some(self.0 as u16) + } } -} -/// Carrier-walk (`RangeAggregateCarrierProof`) limit: `0` stays -/// `None` (unbounded outer walk), mirroring the server keeping an -/// unset request limit as `None`. Callers must have run -/// [`check_within_server_cap`] first, which is what makes the -/// narrowing cast exact. -pub(crate) fn carrier_walk_limit(limit: u32) -> Option { - debug_assert!(limit <= u32::from(DEFAULT_MAX_QUERY_LIMIT)); - if limit == 0 { - None - } else { - Some(limit as u16) + /// COUNT carrier-walk (`RangeAggregateCarrierProof`) limit, + /// mirroring the two shape-dependent rules of the COUNT + /// dispatcher's carrier arm (`drive_document_count_query/ + /// drive_dispatcher.rs`) exactly — both change the + /// proof-sensitive `SizedQuery::limit` bytes, so a mismatch + /// either rejects honest proofs or verifies request/proof + /// pairings no server produced: + /// + /// - **Range-outer carrier (G8)** — `GROUP BY` one range field + /// with two range clauses on distinct fields + /// (`has_outer_range`): the server lowers an unset limit to + /// the compile-time cap + /// [`MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT`] and refuses + /// explicit limits above it with `InvalidLimit` before + /// producing proof bytes. + /// - **In-outer carrier (G7)**: the `In` array already bounds + /// the walk. An unset limit stays `None`; the server refuses + /// every explicit limit here, so the verifier must too. + /// + /// [`MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT`]: + /// drive::query::drive_document_count_query::MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT + pub(crate) fn count_carrier_walk_limit( + &self, + has_outer_range: bool, + ) -> Result, drive_proof_verifier::Error> { + use drive::query::drive_document_count_query::MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT; + + if has_outer_range { + if self.0 == 0 { + return Ok(Some(MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT)); + } + if self.0 > u32::from(MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT) { + return Err(drive_proof_verifier::Error::RequestError { + error: format!( + "limit {} exceeds the carrier-aggregate range-outer cap \ + {MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT} (COUNT); the server \ + refuses such requests with InvalidLimit before producing proof \ + bytes, so no proved response can belong to this request", + self.0 + ), + }); + } + Ok(Some(self.0 as u16)) + } else { + if self.0 != 0 { + return Err(drive_proof_verifier::Error::RequestError { + error: format!( + "limit {} on a carrier-aggregate In-outer COUNT; the server refuses \ + every explicit limit here (the In array bounds the walk), so no \ + proved response can belong to this request", + self.0 + ), + }); + } + Ok(None) + } } } @@ -111,30 +181,87 @@ mod tests { ); } + fn capped(limit: u32) -> ServerCappedLimit { + check_within_server_cap(limit, "TEST").expect("limit within cap") + } + /// Distinct walks translate the `0` sentinel to /// `DEFAULT_QUERY_LIMIT`, mirroring the server's /// `limit.unwrap_or(DEFAULT_QUERY_LIMIT)`; in-range values pass /// through untouched. #[test] fn distinct_walk_sentinel_translation() { - assert_eq!(distinct_walk_limit(0), DEFAULT_QUERY_LIMIT); - assert_eq!(distinct_walk_limit(1), 1); + assert_eq!(capped(0).distinct_walk_limit(), DEFAULT_QUERY_LIMIT); + assert_eq!(capped(1).distinct_walk_limit(), 1); assert_eq!( - distinct_walk_limit(u32::from(DEFAULT_MAX_QUERY_LIMIT)), + capped(u32::from(DEFAULT_MAX_QUERY_LIMIT)).distinct_walk_limit(), DEFAULT_MAX_QUERY_LIMIT ); } - /// Carrier walks keep the `0` sentinel as `None` (unbounded - /// outer walk), mirroring the server keeping an unset request - /// limit as `None`; in-range values pass through untouched. + /// SUM / AVG carrier walks keep the `0` sentinel as `None` + /// (unbounded outer walk), mirroring those servers keeping an + /// unset request limit as `None`; in-range values pass through + /// untouched. #[test] fn carrier_walk_sentinel_translation() { - assert_eq!(carrier_walk_limit(0), None); - assert_eq!(carrier_walk_limit(1), Some(1)); + assert_eq!(capped(0).carrier_walk_limit(), None); + assert_eq!(capped(1).carrier_walk_limit(), Some(1)); assert_eq!( - carrier_walk_limit(u32::from(DEFAULT_MAX_QUERY_LIMIT)), + capped(u32::from(DEFAULT_MAX_QUERY_LIMIT)).carrier_walk_limit(), Some(DEFAULT_MAX_QUERY_LIMIT) ); } + + /// COUNT range-outer (G8) carriers mirror the COUNT + /// dispatcher: `0` lowers to the compile-time carrier cap, + /// explicit limits pass through up to that cap inclusively, + /// and anything above it is rejected even though it fits the + /// shared 100-item cap. + #[test] + fn count_carrier_range_outer_translation() { + use drive::query::drive_document_count_query::MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT; + let cap = MAX_CARRIER_AGGREGATE_OUTER_RANGE_LIMIT; + + assert_eq!( + capped(0).count_carrier_walk_limit(true).unwrap(), + Some(cap), + "unset must lower to the carrier cap, not None" + ); + assert_eq!(capped(1).count_carrier_walk_limit(true).unwrap(), Some(1)); + assert_eq!( + capped(u32::from(cap)) + .count_carrier_walk_limit(true) + .unwrap(), + Some(cap) + ); + + let error = capped(u32::from(cap) + 1) + .count_carrier_walk_limit(true) + .expect_err("one past the carrier cap must be rejected"); + assert!( + error + .to_string() + .contains("exceeds the carrier-aggregate range-outer cap"), + "unexpected error: {error}" + ); + } + + /// COUNT In-outer (G7) carriers mirror the COUNT dispatcher: + /// `0` stays `None` (the In array bounds the walk) and every + /// explicit limit is rejected. + #[test] + fn count_carrier_in_outer_translation() { + assert_eq!(capped(0).count_carrier_walk_limit(false).unwrap(), None); + + let error = capped(1) + .count_carrier_walk_limit(false) + .expect_err("explicit In-outer limits must be rejected"); + assert!( + error + .to_string() + .contains("carrier-aggregate In-outer COUNT"), + "unexpected error: {error}" + ); + } } diff --git a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs index 328dbc11c3d..b38364bbc9b 100644 --- a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs @@ -104,7 +104,7 @@ pub(super) fn verify_average_query( // First gate: a limit above the server's cap is refused on every // server route with `InvalidLimit`, so no proof can belong to // such a request — reject before any proof or provider machinery. - super::aggregate_limit::check_within_server_cap(request.limit, "AVG")?; + let capped_limit = super::aggregate_limit::check_within_server_cap(request.limit, "AVG")?; let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -242,7 +242,7 @@ pub(super) fn verify_average_query( // `DEFAULT_QUERY_LIMIT` the server's dispatcher reads, // matching SUM's distinct arm. Cap already enforced at // the top of this function. - let limit_u16 = super::aggregate_limit::distinct_walk_limit(request.limit); + let limit_u16 = capped_limit.distinct_walk_limit(); let left_to_right = request .order_by_clauses .first() @@ -263,7 +263,7 @@ pub(super) fn verify_average_query( // `0` stays `None` (unbounded outer walk), mirroring the // server's carrier arm. Cap already enforced at the top // of this function. - let limit_u16 = super::aggregate_limit::carrier_walk_limit(request.limit); + let limit_u16 = capped_limit.carrier_walk_limit(); let left_to_right = request .order_by_clauses .first() diff --git a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs index def1af52752..c681ae925d1 100644 --- a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs @@ -117,7 +117,7 @@ pub(super) fn verify_count_query( // First gate: a limit above the server's cap is refused on every // server route with `InvalidLimit`, so no proof can belong to // such a request — reject before any proof or provider machinery. - super::aggregate_limit::check_within_server_cap(request.limit, "COUNT")?; + let capped_limit = super::aggregate_limit::check_within_server_cap(request.limit, "COUNT")?; let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -252,7 +252,7 @@ pub(super) fn verify_count_query( // value, which the SDK can't see), so the path-query // bytes match byte-for-byte across operators. Cap // already enforced at the top of this function. - let limit_u16 = super::aggregate_limit::distinct_walk_limit(request.limit); + let limit_u16 = capped_limit.distinct_walk_limit(); let left_to_right = request .order_by_clauses .first() @@ -271,13 +271,21 @@ pub(super) fn verify_count_query( } DocumentCountMode::RangeAggregateCarrierProof => { // Carrier-ACOR (grovedb #663) — one verified `u64` per - // present In branch. `limit` on the per-branch walk - // follows the same `validate-don't-clamp` contract the - // distinct path uses; `0` stays `None` (unbounded outer - // walk, mirroring the server) so the path-query bytes - // match the server's exactly. Cap already enforced at - // the top of this function. - let limit_u16 = super::aggregate_limit::carrier_walk_limit(request.limit); + // outer branch. `limit` on the outer walk follows the + // same `validate-don't-clamp` contract the distinct + // path uses, but the COUNT dispatcher's rules are + // shape-dependent: the range-outer (G8) shape lowers an + // unset limit to the compile-time carrier cap and + // refuses explicit limits above it, while the In-outer + // (G7) shape keeps `None` and refuses every explicit + // limit. Mirror the server's own shape test. + let has_outer_range = request + .where_clauses + .iter() + .filter(|wc| DriveDocumentCountQuery::is_range_operator(wc.operator)) + .count() + == 2; + let limit_u16 = capped_limit.count_carrier_walk_limit(has_outer_range)?; let left_to_right = request .order_by_clauses .first() diff --git a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs index 1cf1731ba66..212214d1ee4 100644 --- a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs @@ -97,7 +97,7 @@ pub(super) fn verify_sum_query( // First gate: a limit above the server's cap is refused on every // server route with `InvalidLimit`, so no proof can belong to // such a request — reject before any proof or provider machinery. - super::aggregate_limit::check_within_server_cap(request.limit, "SUM")?; + let capped_limit = super::aggregate_limit::check_within_server_cap(request.limit, "SUM")?; let document_type = request .data_contract .document_type_for_name(&request.document_type_name) @@ -239,7 +239,7 @@ pub(super) fn verify_sum_query( // and the cap check at the top of this function // mirrors that rejection, so the SDK never sees a // clamped value to un-clamp. - let limit_u16 = super::aggregate_limit::distinct_walk_limit(request.limit); + let limit_u16 = capped_limit.distinct_walk_limit(); let left_to_right = request .order_by_clauses .first() @@ -260,7 +260,7 @@ pub(super) fn verify_sum_query( // `0` stays `None` (unbounded outer walk), mirroring the // server's carrier arm. Cap already enforced at the top // of this function. - let limit_u16 = super::aggregate_limit::carrier_walk_limit(request.limit); + let limit_u16 = capped_limit.carrier_walk_limit(); let left_to_right = request .order_by_clauses .first()