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::{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}