Skip to main content

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}