drive_proof_verifier/proof/document_having.rs
1//! Verified **having-range**
2//! (`GROUP BY … HAVING <aggregate> <op> <value> LIMIT n`) document
3//! results.
4//!
5//! A having-range query answers "which groups' aggregate falls inside a
6//! value bound?" — `SELECT COUNT(*) GROUP BY hashtag HAVING $count > 100
7//! LIMIT 100`. The answer is a value-bounded range read of the same
8//! per-axis *secondary* Merk the ranked query walks, so it costs
9//! `O(log n + k)` and comes with a proof that commits to exactly the
10//! returned `(aggregate, group key)` pairs **and their completeness**:
11//! the Merk range proof commits its boundaries, so an in-range group the
12//! node omitted fails verification.
13//!
14//! This module holds the client-facing result type
15//! ([`DocumentHavingEntries`]), the tenderdash-composition wrapper
16//! around rs-drive's merk-level verifier
17//! ([`verify_having_range_proof`]), and the decoder for the unproven
18//! wire payload ([`DocumentHavingEntries::from_unproved_response`]) —
19//! which rides the same `ResultData.ranked` variant the ranked surface
20//! uses, since a having page is the same "group key + aggregate value"
21//! entry list.
22//!
23//! Per-shape routing (which index covers the axis, which bounds the
24//! clause translates to) lives in rs-sdk's `having_proof_helpers`,
25//! exactly as the ranked equivalents live in `ranked_proof_helpers` —
26//! it needs the data contract, which this crate does not carry.
27
28use crate::error::MapGroveDbError;
29use crate::proof::document_ranked::{ranked_entry_from_proto, result_variant_name};
30use crate::verify::{document_query_proof_bytes, verify_tenderdash_proof};
31use crate::{ContextProvider, Error, FromProof};
32use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::{
33 result_data, ResultData,
34};
35use dapi_grpc::platform::v0::get_documents_response::{
36 get_documents_response_v1, Version as ResponseVersion,
37};
38use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata};
39use dpp::dashcore::Network;
40use dpp::version::PlatformVersion;
41use drive::query::{DriveDocumentHavingQuery, DriveDocumentQuery, RankedEntry};
42use drive::verify::RootHash;
43
44/// One page of a `GROUP BY … HAVING <aggregate> <op> <value> LIMIT n`
45/// query: the groups whose aggregate falls inside the bound.
46///
47/// **Entry order is axis order in the walk direction** — ascending by
48/// default, descending when the request ordered by the aggregate
49/// descending. Callers must not re-sort; ties (groups with equal
50/// aggregates) come back in group-key order in the direction of the
51/// walk, same as on the ranked surface.
52///
53/// Fewer than `n` entries means fewer groups matched — not an error.
54/// **Exactly `n` entries may mean the match set was cut at the limit**;
55/// nothing in the page marks the cut. Tightening the bound past the
56/// last aggregate value seen continues past *distinct* values only — a
57/// cut inside a tie (several groups sharing the boundary aggregate)
58/// cannot be continued, so size the limit above the widest expected
59/// tie.
60///
61/// Entry semantics ([`RankedEntry`]) are identical to the ranked
62/// surface's, including the fixed-point average scaling and the
63/// exact-on-the-proved-path-only caveat.
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub struct DocumentHavingEntries {
66 /// The matching groups, in axis order in the walk direction.
67 pub entries: Vec<RankedEntry>,
68}
69
70impl DocumentHavingEntries {
71 /// Build a [`DocumentHavingEntries`] from the verifier-side entry
72 /// list — the shape rs-drive's merk-level verifier returns.
73 pub fn from_verified(entries: Vec<RankedEntry>) -> Self {
74 DocumentHavingEntries { entries }
75 }
76
77 /// Decode the **unproven** having-range payload of a `getDocuments`
78 /// response — the `ResultData.ranked` variant a node returns for a
79 /// having-range request sent with `prove = false`. (The wire reuses
80 /// the ranked entries message; a having page leaves its `skipped`
81 /// field unset, and this decoder ignores it either way, because a
82 /// value-bounded page has no rank base for it to describe.)
83 ///
84 /// Order is preserved verbatim. This is a plain wire decode with
85 /// **no cryptographic guarantee whatsoever** — and unlike the ranked
86 /// surface the missing guarantee here includes *completeness*: an
87 /// unproven page is free to omit matching groups, which for a
88 /// spam-resistance query is precisely the interesting attack. Prefer
89 /// [`verify_having_range_proof`] (via rs-sdk's
90 /// `DocumentHavingEntries::fetch`) unless you deliberately trust the
91 /// node.
92 ///
93 /// # Errors
94 ///
95 /// - [`Error::EmptyVersion`] when the response carries no version.
96 /// - [`Error::ResponseDecodeError`] when the response is a V0
97 /// response, carries a proof rather than data, carries a
98 /// non-ranked `ResultData` variant, or an entry's `value` oneof is
99 /// unset / out of domain.
100 pub fn from_unproved_response(
101 response: &GetDocumentsResponse,
102 ) -> Result<(Self, ResponseMetadata), Error> {
103 let version = response.version.as_ref().ok_or(Error::EmptyVersion)?;
104 let ResponseVersion::V1(v1) = version else {
105 return Err(Error::ResponseDecodeError {
106 error: "having-range results are a V1-only response shape; got a V0 \
107 getDocuments response. Having-range queries require protocol \
108 version 14+."
109 .to_string(),
110 });
111 };
112 let metadata = v1.metadata.clone().ok_or(Error::EmptyResponseMetadata)?;
113 let entries = match v1.result.as_ref() {
114 Some(get_documents_response_v1::Result::Data(ResultData {
115 variant: Some(result_data::Variant::Ranked(ranked)),
116 })) => ranked
117 .entries
118 .iter()
119 .map(ranked_entry_from_proto)
120 .collect::<Result<Vec<_>, _>>()?,
121 Some(get_documents_response_v1::Result::Proof(_)) => {
122 return Err(Error::ResponseDecodeError {
123 error: "the response carries a proof, not unproven having-range entries; \
124 verify it with `verify_having_range_proof` instead of decoding it"
125 .to_string(),
126 });
127 }
128 other => {
129 return Err(Error::ResponseDecodeError {
130 error: format!(
131 "expected a `ResultData.ranked` payload for a having-range request, \
132 got {}. A response on another variant means the node routed \
133 the request to a different executor — check that the request \
134 carries a `group_by` and exactly one `having` clause bounding the \
135 single `select`'s aggregate.",
136 result_variant_name(other)
137 ),
138 });
139 }
140 };
141 Ok((DocumentHavingEntries { entries }, metadata))
142 }
143}
144
145/// Verify a grovedb indexed-axis range proof **and the surrounding
146/// tenderdash commit**, returning the reconstructed root hash and the
147/// matching groups it commits to.
148///
149/// Thin tenderdash-composition wrapper over
150/// [`DriveDocumentHavingQuery::verify_having_range_proof`] in rs-drive
151/// (which does the merk-level verification). Both sides derive the
152/// proved subtree from the same
153/// `DriveDocumentHavingQuery::indexed_property_name_tree_path` and the
154/// bounded traversal from the same
155/// `AxisRangeBounds::inclusive_bounds_i128`, so prover and verifier
156/// cannot drift on *which bound over which tree* is being checked, and
157/// grovedb re-executes the proof against that reconstruction — a proof
158/// of one bound does not cover another (the limit binds as a cap: an
159/// exhausted proof verifies under any admitting cap, a truncated one
160/// fails a larger cap for missing coverage).
161///
162/// ## The root hash is the whole point
163///
164/// Same as on the ranked surface: the merk-level verifier returning
165/// `Ok` is not by itself evidence of anything — the binding to the
166/// quorum-signed app hash in [`verify_tenderdash_proof`] is what makes
167/// the entries (and their completeness) attested facts. This function
168/// exists so that composition can never be skipped by accident.
169///
170/// `platform_version` must be the protocol version `mtd` names, or the latest known
171/// version when `mtd` names a newer one; otherwise the answer is refused with
172/// [`Error::ProofProtocolVersionMismatch`].
173pub fn verify_having_range_proof(
174 query: &DriveDocumentHavingQuery,
175 proof: &Proof,
176 mtd: &ResponseMetadata,
177 platform_version: &PlatformVersion,
178 provider: &dyn ContextProvider,
179) -> Result<(RootHash, Vec<RankedEntry>), Error> {
180 let (root_hash, entries) = query
181 .verify_having_range_proof(
182 document_query_proof_bytes(proof, mtd, platform_version)?,
183 platform_version,
184 )
185 .map_drive_error(proof, mtd)?;
186
187 verify_tenderdash_proof(proof, mtd, &root_hash, provider)?;
188
189 Ok((root_hash, entries))
190}
191
192/// Reject the generic [`FromProof`] entry point for
193/// [`DocumentHavingEntries`] — same guard rail, same rationale as the
194/// [`crate::DocumentRankedEntries`] blanket impl: the generic
195/// `FromProof<Q: TryInto<DriveDocumentQuery>>` path carries neither the
196/// bounds nor the covering index, so it errors out explicitly rather
197/// than verifying the wrong thing.
198impl<'dq, Q> FromProof<Q> for DocumentHavingEntries
199where
200 Q: TryInto<DriveDocumentQuery<'dq>> + Clone + 'dq,
201 Q::Error: std::fmt::Display,
202{
203 type Request = Q;
204 type Response = GetDocumentsResponse;
205
206 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
207 _request: I,
208 _response: O,
209 _network: Network,
210 _platform_version: &PlatformVersion,
211 _provider: &'a dyn ContextProvider,
212 ) -> Result<(Option<Self>, ResponseMetadata, Proof), Error>
213 where
214 Self: 'a,
215 {
216 Err(Error::RequestError {
217 error: "DocumentHavingEntries can't be verified via the generic FromProof path; \
218 call DocumentHavingEntries::fetch on a DocumentQuery carrying \
219 .with_select(<aggregate>), .with_group_by(<property>), \
220 .with_having(<one clause bounding the selected aggregate>) and \
221 .with_limit(n), which resolves the bounds and the covering index from \
222 the data contract"
223 .to_string(),
224 })
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 //! Offline tests for the unproven decode and the response-shape
231 //! rejections. Proof verification is exercised end-to-end by
232 //! rs-drive's `drive_document_having_query::tests` (prover and
233 //! merk-level verifier against a real Drive), rs-drive-abci's
234 //! `having_range_tests` (wire encoding of the same values), and
235 //! rs-drive-abci's `having_trust_boundary` suite, which runs this
236 //! crate's [`verify_having_range_proof`] wrapper — including the
237 //! tenderdash signature binding — against server-generated proofs.
238 //! The tenderdash-composition tests live on the server side so this
239 //! client crate keeps building drive with `verify` only.
240 use super::*;
241 use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::{
242 ranked_entry, Documents, RankedEntries, RankedEntry as ProtoRankedEntry,
243 };
244 use dapi_grpc::platform::v0::get_documents_response::GetDocumentsResponseV1;
245 use drive::query::RankedEntryValue;
246
247 fn count_entry(key: &str, count: u64) -> ProtoRankedEntry {
248 ProtoRankedEntry {
249 in_key: None,
250 key: key.as_bytes().to_vec(),
251 value: Some(ranked_entry::Value::Count(count)),
252 }
253 }
254
255 fn response_with(result: get_documents_response_v1::Result) -> GetDocumentsResponse {
256 GetDocumentsResponse {
257 version: Some(ResponseVersion::V1(GetDocumentsResponseV1 {
258 result: Some(result),
259 metadata: Some(ResponseMetadata {
260 height: 42,
261 ..Default::default()
262 }),
263 })),
264 }
265 }
266
267 fn having_response(
268 entries: Vec<ProtoRankedEntry>,
269 skipped: Option<u64>,
270 ) -> GetDocumentsResponse {
271 response_with(get_documents_response_v1::Result::Data(ResultData {
272 variant: Some(result_data::Variant::Ranked(RankedEntries {
273 entries,
274 skipped,
275 })),
276 }))
277 }
278
279 /// The headline decode: `HAVING $count > 100`-shaped entries come
280 /// back in axis order, untouched, with `skipped` (unset on a
281 /// having page) ignored.
282 #[test]
283 fn decodes_entries_preserving_axis_order() {
284 let response = having_response(
285 vec![count_entry("dash", 101), count_entry("evo", 250)],
286 None,
287 );
288 let (decoded, metadata) = DocumentHavingEntries::from_unproved_response(&response)
289 .expect("a well-formed having payload decodes");
290 assert_eq!(metadata.height, 42);
291 assert_eq!(
292 decoded.entries.iter().map(|e| e.value).collect::<Vec<_>>(),
293 vec![RankedEntryValue::Count(101), RankedEntryValue::Count(250)]
294 );
295 }
296
297 /// A stray `skipped` from a non-conforming node is ignored, not a
298 /// decode failure: the field cannot describe anything on a
299 /// value-bounded page, and failing on it would break against a
300 /// node that reused its ranked encoder wholesale.
301 #[test]
302 fn a_stray_skipped_field_is_ignored() {
303 let response = having_response(vec![count_entry("dash", 101)], Some(7));
304 let (decoded, _) = DocumentHavingEntries::from_unproved_response(&response)
305 .expect("a stray skipped is not a decode failure");
306 assert_eq!(decoded.entries.len(), 1);
307 }
308
309 /// No groups matching the bound is a legitimate answer.
310 #[test]
311 fn decodes_an_empty_match_set() {
312 let (decoded, _) =
313 DocumentHavingEntries::from_unproved_response(&having_response(vec![], None))
314 .expect("an empty match set is well-formed");
315 assert!(decoded.entries.is_empty());
316 }
317
318 /// Same caller-mistake guard as the ranked decoder: a proof must
319 /// be verified, not decoded.
320 #[test]
321 fn rejects_a_proof_response() {
322 let response = response_with(get_documents_response_v1::Result::Proof(Proof::default()));
323 let err = DocumentHavingEntries::from_unproved_response(&response)
324 .expect_err("a proof is not an unproven having payload");
325 assert!(format!("{err}").contains("verify_having_range_proof"));
326 }
327
328 /// A response on another variant means the node routed the request
329 /// somewhere else entirely.
330 #[test]
331 fn rejects_a_non_ranked_result_variant() {
332 let response = response_with(get_documents_response_v1::Result::Data(ResultData {
333 variant: Some(result_data::Variant::Documents(Documents {
334 documents: Vec::new(),
335 })),
336 }));
337 let err = DocumentHavingEntries::from_unproved_response(&response)
338 .expect_err("a documents payload is not a having one");
339 assert!(format!("{err}").contains("ResultData.ranked"));
340 }
341
342 /// V0 predates the SQL-shaped surface entirely.
343 #[test]
344 fn rejects_a_v0_response() {
345 let response = GetDocumentsResponse {
346 version: Some(ResponseVersion::V0(Default::default())),
347 };
348 let err = DocumentHavingEntries::from_unproved_response(&response)
349 .expect_err("V0 has no having shape");
350 assert!(format!("{err}").contains("V1-only"));
351 }
352}