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::{supported_grovedb_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.
169pub fn verify_having_range_proof(
170 query: &DriveDocumentHavingQuery,
171 proof: &Proof,
172 mtd: &ResponseMetadata,
173 platform_version: &PlatformVersion,
174 provider: &dyn ContextProvider,
175) -> Result<(RootHash, Vec<RankedEntry>), Error> {
176 let (root_hash, entries) = query
177 .verify_having_range_proof(supported_grovedb_proof_bytes(proof)?, platform_version)
178 .map_drive_error(proof, mtd)?;
179
180 verify_tenderdash_proof(proof, mtd, &root_hash, provider)?;
181
182 Ok((root_hash, entries))
183}
184
185/// Reject the generic [`FromProof`] entry point for
186/// [`DocumentHavingEntries`] — same guard rail, same rationale as the
187/// [`crate::DocumentRankedEntries`] blanket impl: the generic
188/// `FromProof<Q: TryInto<DriveDocumentQuery>>` path carries neither the
189/// bounds nor the covering index, so it errors out explicitly rather
190/// than verifying the wrong thing.
191impl<'dq, Q> FromProof<Q> for DocumentHavingEntries
192where
193 Q: TryInto<DriveDocumentQuery<'dq>> + Clone + 'dq,
194 Q::Error: std::fmt::Display,
195{
196 type Request = Q;
197 type Response = GetDocumentsResponse;
198
199 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
200 _request: I,
201 _response: O,
202 _network: Network,
203 _platform_version: &PlatformVersion,
204 _provider: &'a dyn ContextProvider,
205 ) -> Result<(Option<Self>, ResponseMetadata, Proof), Error>
206 where
207 Self: 'a,
208 {
209 Err(Error::RequestError {
210 error: "DocumentHavingEntries can't be verified via the generic FromProof path; \
211 call DocumentHavingEntries::fetch on a DocumentQuery carrying \
212 .with_select(<aggregate>), .with_group_by(<property>), \
213 .with_having(<one clause bounding the selected aggregate>) and \
214 .with_limit(n), which resolves the bounds and the covering index from \
215 the data contract"
216 .to_string(),
217 })
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 //! Offline tests for the unproven decode and the response-shape
224 //! rejections. Proof verification is exercised end-to-end by
225 //! rs-drive's `drive_document_having_query::tests` (prover and
226 //! merk-level verifier against a real Drive), rs-drive-abci's
227 //! `having_range_tests` (wire encoding of the same values), and
228 //! rs-drive-abci's `having_trust_boundary` suite, which runs this
229 //! crate's [`verify_having_range_proof`] wrapper — including the
230 //! tenderdash signature binding — against server-generated proofs.
231 //! The tenderdash-composition tests live on the server side so this
232 //! client crate keeps building drive with `verify` only.
233 use super::*;
234 use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::{
235 ranked_entry, Documents, RankedEntries, RankedEntry as ProtoRankedEntry,
236 };
237 use dapi_grpc::platform::v0::get_documents_response::GetDocumentsResponseV1;
238 use drive::query::RankedEntryValue;
239
240 fn count_entry(key: &str, count: u64) -> ProtoRankedEntry {
241 ProtoRankedEntry {
242 in_key: None,
243 key: key.as_bytes().to_vec(),
244 value: Some(ranked_entry::Value::Count(count)),
245 }
246 }
247
248 fn response_with(result: get_documents_response_v1::Result) -> GetDocumentsResponse {
249 GetDocumentsResponse {
250 version: Some(ResponseVersion::V1(GetDocumentsResponseV1 {
251 result: Some(result),
252 metadata: Some(ResponseMetadata {
253 height: 42,
254 ..Default::default()
255 }),
256 })),
257 }
258 }
259
260 fn having_response(
261 entries: Vec<ProtoRankedEntry>,
262 skipped: Option<u64>,
263 ) -> GetDocumentsResponse {
264 response_with(get_documents_response_v1::Result::Data(ResultData {
265 variant: Some(result_data::Variant::Ranked(RankedEntries {
266 entries,
267 skipped,
268 })),
269 }))
270 }
271
272 /// The headline decode: `HAVING $count > 100`-shaped entries come
273 /// back in axis order, untouched, with `skipped` (unset on a
274 /// having page) ignored.
275 #[test]
276 fn decodes_entries_preserving_axis_order() {
277 let response = having_response(
278 vec![count_entry("dash", 101), count_entry("evo", 250)],
279 None,
280 );
281 let (decoded, metadata) = DocumentHavingEntries::from_unproved_response(&response)
282 .expect("a well-formed having payload decodes");
283 assert_eq!(metadata.height, 42);
284 assert_eq!(
285 decoded.entries.iter().map(|e| e.value).collect::<Vec<_>>(),
286 vec![RankedEntryValue::Count(101), RankedEntryValue::Count(250)]
287 );
288 }
289
290 /// A stray `skipped` from a non-conforming node is ignored, not a
291 /// decode failure: the field cannot describe anything on a
292 /// value-bounded page, and failing on it would break against a
293 /// node that reused its ranked encoder wholesale.
294 #[test]
295 fn a_stray_skipped_field_is_ignored() {
296 let response = having_response(vec![count_entry("dash", 101)], Some(7));
297 let (decoded, _) = DocumentHavingEntries::from_unproved_response(&response)
298 .expect("a stray skipped is not a decode failure");
299 assert_eq!(decoded.entries.len(), 1);
300 }
301
302 /// No groups matching the bound is a legitimate answer.
303 #[test]
304 fn decodes_an_empty_match_set() {
305 let (decoded, _) =
306 DocumentHavingEntries::from_unproved_response(&having_response(vec![], None))
307 .expect("an empty match set is well-formed");
308 assert!(decoded.entries.is_empty());
309 }
310
311 /// Same caller-mistake guard as the ranked decoder: a proof must
312 /// be verified, not decoded.
313 #[test]
314 fn rejects_a_proof_response() {
315 let response = response_with(get_documents_response_v1::Result::Proof(Proof::default()));
316 let err = DocumentHavingEntries::from_unproved_response(&response)
317 .expect_err("a proof is not an unproven having payload");
318 assert!(format!("{err}").contains("verify_having_range_proof"));
319 }
320
321 /// A response on another variant means the node routed the request
322 /// somewhere else entirely.
323 #[test]
324 fn rejects_a_non_ranked_result_variant() {
325 let response = response_with(get_documents_response_v1::Result::Data(ResultData {
326 variant: Some(result_data::Variant::Documents(Documents {
327 documents: Vec::new(),
328 })),
329 }));
330 let err = DocumentHavingEntries::from_unproved_response(&response)
331 .expect_err("a documents payload is not a having one");
332 assert!(format!("{err}").contains("ResultData.ranked"));
333 }
334
335 /// V0 predates the SQL-shaped surface entirely.
336 #[test]
337 fn rejects_a_v0_response() {
338 let response = GetDocumentsResponse {
339 version: Some(ResponseVersion::V0(Default::default())),
340 };
341 let err = DocumentHavingEntries::from_unproved_response(&response)
342 .expect_err("V0 has no having shape");
343 assert!(format!("{err}").contains("V1-only"));
344 }
345}