drive_proof_verifier/proof/document_ranked.rs
1//! Verified **ranked** (`GROUP BY … ORDER BY <aggregate> LIMIT n`)
2//! document results.
3//!
4//! A ranked query answers "which `n` groups score highest (or lowest)
5//! on an aggregate?" — `SELECT AVG(grade) GROUP BY restaurantId
6//! ORDER BY grade DESC LIMIT 5`. The answer is read straight out of
7//! the per-axis *secondary* Merk of an indexed tree (grovedb PR #657),
8//! so it costs `O(log n + k)` and comes with a proof that commits to
9//! exactly the `k` returned `(aggregate, group key)` pairs — plus the
10//! `OFFSET`, which grovedb counts from the subtree aggregates rather
11//! than by walking the skipped region — and additionally attests, on
12//! this proved path — so a deep page costs `O(log n + k)` like any
13//! other rather than growing with the offset.
14//!
15//! This module holds the client-facing result type
16//! ([`DocumentRankedEntries`]), the tenderdash-composition wrapper
17//! around rs-drive's merk-level verifier
18//! ([`verify_ranked_top_k_proof`]), and the decoder for the unproven
19//! `ResultData.ranked` wire payload
20//! ([`DocumentRankedEntries::from_unproved_response`]).
21//!
22//! Per-shape routing (which index covers the axis, which
23//! `(axis, descending, k, offset)` tuple the request resolves to) lives
24//! in rs-sdk's `ranked_proof_helpers`, exactly as count's four-way
25//! dispatch lives in `count_proof_helpers` — it needs the data
26//! contract, which this crate does not carry.
27
28use crate::error::MapGroveDbError;
29use crate::verify::{document_query_proof_bytes, verify_tenderdash_proof};
30use crate::{ContextProvider, Error, FromProof};
31use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::{
32 ranked_entry, result_data, RankedEntry as ProtoRankedEntry, ResultData,
33};
34use dapi_grpc::platform::v0::get_documents_response::{
35 get_documents_response_v1, Version as ResponseVersion,
36};
37use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata};
38use dpp::dashcore::Network;
39use dpp::version::PlatformVersion;
40use drive::query::{
41 DriveDocumentQuery, DriveDocumentRankedQuery, RankedEntry, RankedEntryValue, RankedPage,
42 RANKED_AVG_SCALE,
43};
44use drive::verify::RootHash;
45
46/// One page of a `GROUP BY … ORDER BY <aggregate> LIMIT n [OFFSET m]`
47/// query: the ranked groups, plus the rank the page starts at.
48///
49/// **Entry order is the ranking order** — best-first for `DESC`,
50/// worst-first for `ASC`. Callers must not re-sort; ties (groups with
51/// equal aggregates) come back in group-key order *in the direction of
52/// the walk*, which is descending group-key order for `DESC`.
53///
54/// Fewer than `n` entries is normal — the index simply holds fewer
55/// groups than were asked for — and is not an error.
56///
57/// Each [`RankedEntry`]'s `key` is the raw index-key bytes of the
58/// `GROUP BY` property's value (for a `string` property, its UTF-8
59/// bytes); its `value` is the aggregate, one of
60/// [`RankedEntryValue::Count`] / [`RankedEntryValue::Sum`] /
61/// [`RankedEntryValue::AvgFixedPoint`]. Averages are fixed-point
62/// integers scaled by [`crate::RANKED_AVG_SCALE`]; divide by it (or
63/// call [`RankedEntryValue::as_f64`]) to render one.
64///
65/// The fixed point is **exact on the proved path only**. A page built
66/// by [`Self::from_verified`] carries the very integer the proof
67/// commits to; one built by [`Self::from_unproved_response`] carries a
68/// best-effort reconstruction from the wire's `double` — see that
69/// method for what that costs.
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct DocumentRankedEntries {
72 /// The 0-based rank of `entries[0]` — the query's `OFFSET`, as
73 /// actually honoured.
74 ///
75 /// This is what turns a page back into a *ranking*: entry `i` is
76 /// the group at rank `starting_rank + i`. Without it a caller who
77 /// asked for `ORDER BY avg(grade) DESC LIMIT 1 OFFSET 4` receives
78 /// one entry and has no way to tell it really is the 5th-best
79 /// group rather than the best.
80 ///
81 /// On the **proved** path this is grovedb's cryptographically
82 /// attested count, re-derived by the verifier from the counted
83 /// subtree commitments in the proof bytes rather than trusted from
84 /// the response. It equals the requested offset unless the walk ran
85 /// out of groups first, in which case `entries` is empty and this
86 /// is a *proof* that the ranking holds exactly this many groups in
87 /// total — an offset past the end is a positive answer, not an
88 /// error.
89 ///
90 /// On the **unproven** decode it is whatever the node put on the
91 /// wire (`0` when the field is absent), and carries no more weight
92 /// than the entries beside it.
93 pub starting_rank: u64,
94 /// The groups on this page, **in ranking order**.
95 pub entries: Vec<RankedEntry>,
96}
97
98impl DocumentRankedEntries {
99 /// Build a [`DocumentRankedEntries`] from a verifier-side
100 /// [`RankedPage`] — the shape rs-drive's merk-level verifier
101 /// returns, carrying the attested skip alongside the entries.
102 ///
103 /// Mirrors
104 /// [`DocumentSplitCounts::from_verified`](crate::DocumentSplitCounts::from_verified),
105 /// except that it is not the identity: `RankedPage` is rs-drive's
106 /// internal type and this is the client-facing one, so the rename
107 /// of `skipped` → `starting_rank` happens here, where the value
108 /// stops being "how far the walk skipped" and starts being "which
109 /// rank you are looking at".
110 pub fn from_verified(page: RankedPage) -> Self {
111 DocumentRankedEntries {
112 starting_rank: page.skipped,
113 entries: page.entries,
114 }
115 }
116
117 /// Decode the **unproven** ranked payload of a `getDocuments`
118 /// response — the `ResultData.ranked` variant a node returns for a
119 /// ranked request sent with `prove = false`.
120 ///
121 /// Order is preserved verbatim: the server emits entries in
122 /// ranking order and this decoder never re-sorts.
123 ///
124 /// This is a plain wire decode with **no cryptographic guarantee
125 /// whatsoever** — it is the "trust the node" path, and that applies
126 /// to [`Self::starting_rank`] every bit as much as to the entries:
127 /// an unproven page claiming to start at rank 4 is a claim, not a
128 /// fact. Prefer [`verify_ranked_top_k_proof`] (via rs-sdk's
129 /// `DocumentRankedEntries::fetch`) unless you are deliberately
130 /// reading from a node you already trust.
131 ///
132 /// A node that predates the wire `skipped` field leaves it unset;
133 /// that decodes to `starting_rank == 0`, which is the right answer
134 /// for the offset-less queries such a node could serve at all.
135 ///
136 /// ## Averages come back approximate here
137 ///
138 /// The wire's `avg` is a `double`, deliberately: these entries only
139 /// exist on this path, and a proof-verifying client reconstructs
140 /// the exact fixed point from the proof instead. To keep one
141 /// [`RankedEntryValue`] type across both paths this decoder
142 /// multiplies the double back up by [`crate::RANKED_AVG_SCALE`] and
143 /// rounds, so the [`RankedEntryValue::AvgFixedPoint`] it yields is a
144 /// **best-effort reconstruction, not the committed integer** — its
145 /// low digits are noise beyond `f64`'s ~15–16 significant decimal
146 /// digits. Render it, compare it loosely, but do not treat it as the
147 /// value grovedb ranked on; ask for the proof if you need that.
148 ///
149 /// # Errors
150 ///
151 /// - [`Error::EmptyVersion`] when the response carries no version.
152 /// - [`Error::ResponseDecodeError`] when the response is a V0
153 /// response (which has no ranked shape), carries a proof rather
154 /// than data, or carries a non-ranked `ResultData` variant.
155 /// - [`Error::ResponseDecodeError`] when an entry's `value` oneof
156 /// is unset, or its `avg` is not a finite double that scales into
157 /// `i128` range.
158 pub fn from_unproved_response(
159 response: &GetDocumentsResponse,
160 ) -> Result<(Self, ResponseMetadata), Error> {
161 let version = response.version.as_ref().ok_or(Error::EmptyVersion)?;
162 let ResponseVersion::V1(v1) = version else {
163 return Err(Error::ResponseDecodeError {
164 error: "ranked results are a V1-only response shape; got a V0 getDocuments \
165 response. Ranked queries require protocol version 14+."
166 .to_string(),
167 });
168 };
169 let metadata = v1.metadata.clone().ok_or(Error::EmptyResponseMetadata)?;
170 let (starting_rank, entries) = match v1.result.as_ref() {
171 Some(get_documents_response_v1::Result::Data(ResultData {
172 variant: Some(result_data::Variant::Ranked(ranked)),
173 })) => (
174 ranked.skipped.unwrap_or(0),
175 ranked
176 .entries
177 .iter()
178 .map(ranked_entry_from_proto)
179 .collect::<Result<Vec<_>, _>>()?,
180 ),
181 Some(get_documents_response_v1::Result::Proof(_)) => {
182 return Err(Error::ResponseDecodeError {
183 error: "the response carries a proof, not unproven ranked entries; verify \
184 it with `verify_ranked_top_k_proof` instead of decoding it"
185 .to_string(),
186 });
187 }
188 other => {
189 return Err(Error::ResponseDecodeError {
190 error: format!(
191 "expected a `ResultData.ranked` payload for a ranked request, got \
192 {}. A response on another variant means the node routed the \
193 request to a different executor — check that the request carries a \
194 `group_by` and a single `order_by` naming the single `select`'s \
195 aggregate (`$count` for `COUNT(*)`).",
196 result_variant_name(other)
197 ),
198 });
199 }
200 };
201 Ok((
202 DocumentRankedEntries {
203 starting_rank,
204 entries,
205 },
206 metadata,
207 ))
208 }
209}
210
211/// The received-but-unexpected shape of a `getDocuments` V1 result, by
212/// **name only** — never the payload. Interpolating the payload into an
213/// error would make the message (and any log line carrying it) grow
214/// with an untrusted response, and could copy returned document bytes
215/// into logs. Shared by the ranked and having-range decoders.
216pub(crate) fn result_variant_name(
217 result: Option<&get_documents_response_v1::Result>,
218) -> &'static str {
219 match result {
220 None => "an absent result",
221 Some(get_documents_response_v1::Result::Proof(_)) => "a proof",
222 Some(get_documents_response_v1::Result::Data(ResultData { variant })) => match variant {
223 None => "a ResultData with no variant",
224 Some(result_data::Variant::Documents(_)) => "a ResultData.documents payload",
225 Some(result_data::Variant::Counts(_)) => "a ResultData.counts payload",
226 Some(result_data::Variant::Sums(_)) => "a ResultData.sums payload",
227 Some(result_data::Variant::Averages(_)) => "a ResultData.averages payload",
228 Some(result_data::Variant::Ranked(_)) => "a ResultData.ranked payload",
229 Some(result_data::Variant::Chained(_)) => "a ResultData.chained payload",
230 Some(result_data::Variant::Composite(_)) => "a ResultData.composite payload",
231 },
232 }
233}
234
235/// Decode one wire [`ProtoRankedEntry`] into rs-drive's
236/// [`RankedEntry`].
237///
238/// The `avg` arm re-scales the wire's `double` into the fixed-point
239/// `i128` [`RankedEntryValue`] carries, so callers see one type
240/// regardless of which path produced the page. The round trip is lossy
241/// in one direction only — the server divided an exact integer by
242/// [`RANKED_AVG_SCALE`], we multiply back and round — so the result is
243/// the closest fixed point to what the node reported, not necessarily
244/// the one it committed to. `from_unproved_response` documents that;
245/// exactness lives on the proof path.
246///
247/// A non-finite `avg`, or one that scales past `i128`, is rejected
248/// rather than saturated: `as` casts would silently turn `NaN` into
249/// `0` (an average of zero — a plausible-looking lie) and an
250/// out-of-range double into `i128::MIN`/`MAX`. Every legitimate value
251/// fits comfortably, since `|sum| ≤ i64::MAX` bounds the true fixed
252/// point at `i64::MAX * 10^19 ≈ 9.2e37 < i128::MAX`.
253pub(crate) fn ranked_entry_from_proto(entry: &ProtoRankedEntry) -> Result<RankedEntry, Error> {
254 let value = match entry.value.as_ref() {
255 Some(ranked_entry::Value::Count(count)) => RankedEntryValue::Count(*count),
256 Some(ranked_entry::Value::Sum(sum)) => RankedEntryValue::Sum(*sum),
257 Some(ranked_entry::Value::Avg(avg)) => {
258 let scaled = avg * (RANKED_AVG_SCALE as f64);
259 // `i128::MIN as f64` is exactly −2^127; `i128::MAX as f64`
260 // rounds *up* to 2^127, hence the asymmetric comparisons.
261 if !scaled.is_finite() || scaled < (i128::MIN as f64) || scaled >= -(i128::MIN as f64) {
262 return Err(Error::ResponseDecodeError {
263 error: format!(
264 "`avg` must be a finite double that scales into i128 range when \
265 multiplied by {RANKED_AVG_SCALE}, got {avg}"
266 ),
267 });
268 }
269 RankedEntryValue::AvgFixedPoint(scaled.round() as i128)
270 }
271 None => {
272 return Err(Error::ResponseDecodeError {
273 error: "ranked entry carries no `value`; the server always sets exactly one \
274 of `count` / `sum` / `avg`"
275 .to_string(),
276 });
277 }
278 };
279 Ok(RankedEntry {
280 // Present exactly on `IN`-pinned responses; the wire's absent
281 // state maps to the drive type's `None` untouched.
282 in_key: entry.in_key.clone(),
283 key: entry.key.clone(),
284 value,
285 })
286}
287
288/// Verify a grovedb indexed-axis top-k proof **and the surrounding
289/// tenderdash commit**, returning the reconstructed root hash and the
290/// [`RankedPage`] it commits to.
291///
292/// The page is returned whole rather than as a bare entry list because
293/// [`RankedPage::skipped`] is verified evidence in its own right: it is
294/// re-derived from the counted subtree commitments in the proof bytes,
295/// so it pins each entry to an absolute rank, and on a page past the
296/// end of the ranking it is the *only* payload — an attested total
297/// population under an empty entry list.
298///
299/// Thin tenderdash-composition wrapper over
300/// [`DriveDocumentRankedQuery::verify_ranked_top_k_proof`] in rs-drive
301/// (which does the merk-level verification). Both sides derive the
302/// proved subtree from the same
303/// `DriveDocumentRankedQuery::indexed_property_name_tree_path`, so
304/// prover and verifier cannot drift on *which* ranking is being
305/// checked, and grovedb re-executes the proof against the
306/// `(axis, k, offset, descending)` traversal rebuilt from the request —
307/// a proof of one ranking does not cover another.
308///
309/// ## The root hash is the whole point
310///
311/// The merk-level verifier returning `Ok` is **not** by itself
312/// evidence of anything. Sweeping every bit of a real ranked envelope
313/// shows why: most flips do error out, but roughly 9% of them (bytes
314/// of sibling-subtree hashes inside the ancestor layer proofs) verify
315/// cleanly and return the correct entries — under a *different*
316/// reconstructed root hash. What rejects those is the
317/// [`verify_tenderdash_proof`] call below, which checks the
318/// reconstructed root against the quorum-signed app hash for the
319/// response's block. This function exists so that composition can
320/// never be skipped by accident: there is no way to obtain the entries
321/// from it without the binding having run.
322///
323/// The `RootHash` is returned as well, already bound, so callers can
324/// log or cross-check it (e.g. against a root hash they verified for a
325/// different query at the same height). Callers must not treat it as
326/// something they still have to check.
327///
328/// `platform_version` must be the protocol version `mtd` names, or the latest known
329/// version when `mtd` names a newer one; otherwise the answer is refused with
330/// [`Error::ProofProtocolVersionMismatch`].
331pub fn verify_ranked_top_k_proof(
332 query: &DriveDocumentRankedQuery,
333 proof: &Proof,
334 mtd: &ResponseMetadata,
335 platform_version: &PlatformVersion,
336 provider: &dyn ContextProvider,
337) -> Result<(RootHash, RankedPage), Error> {
338 let (root_hash, page) = query
339 .verify_ranked_top_k_proof(
340 document_query_proof_bytes(proof, mtd, platform_version)?,
341 platform_version,
342 )
343 .map_drive_error(proof, mtd)?;
344
345 verify_tenderdash_proof(proof, mtd, &root_hash, provider)?;
346
347 Ok((root_hash, page))
348}
349
350/// Reject the generic [`FromProof`] entry point for
351/// [`DocumentRankedEntries`].
352///
353/// `DocumentRankedEntries` is reached from rs-sdk via the
354/// `FromProof<DocumentQuery>` impl defined alongside the SDK's
355/// `DocumentQuery` type (see
356/// `rs-sdk/src/platform/documents/document_ranked_entries.rs`), which
357/// resolves the `(axis, descending, k, offset)` tuple and the covering
358/// index from the request's `(select, group_by, order_by, limit,
359/// offset)` shape plus the data contract. The generic
360/// `FromProof<Q: TryInto<DriveDocumentQuery>>` path carries neither —
361/// `DriveDocumentQuery` has no notion of a ranking — so it errors out
362/// explicitly rather than verifying the wrong thing; calling this impl
363/// directly is a programmer mistake.
364impl<'dq, Q> FromProof<Q> for DocumentRankedEntries
365where
366 Q: TryInto<DriveDocumentQuery<'dq>> + Clone + 'dq,
367 Q::Error: std::fmt::Display,
368{
369 type Request = Q;
370 type Response = GetDocumentsResponse;
371
372 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
373 _request: I,
374 _response: O,
375 _network: Network,
376 _platform_version: &PlatformVersion,
377 _provider: &'a dyn ContextProvider,
378 ) -> Result<(Option<Self>, ResponseMetadata, Proof), Error>
379 where
380 Self: 'a,
381 {
382 Err(Error::RequestError {
383 error: "DocumentRankedEntries can't be verified via the generic FromProof path; \
384 call DocumentRankedEntries::fetch on a DocumentQuery carrying \
385 .with_select(<aggregate>), .with_group_by(<property>), \
386 .order_by_selected_aggregate(<direction>) and .with_limit(n), which \
387 resolves the ranking axis and the covering index from the data contract"
388 .to_string(),
389 })
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 //! Offline tests for the parts of the ranked surface that need
396 //! neither a grovedb proof nor a populated Drive:
397 //!
398 //! - the unproven `ResultData.ranked` decode, including order
399 //! preservation, negative fixed-point averages, and the
400 //! malformed-length rejection;
401 //! - the response-shape rejections (proof instead of data, wrong
402 //! `ResultData` variant, V0 response);
403 //! - the generic `FromProof<Q>` impl that intentionally errors to
404 //! prevent a silently-wrong verification.
405 //!
406 //! Proof verification itself is exercised end-to-end by rs-drive's
407 //! `drive_document_ranked_query::tests` (prover and verifier run
408 //! against a real Drive, with a bit-flip sweep asserting no tamper
409 //! survives with the honest root hash) and by rs-drive-abci's
410 //! `ranked_tests` (wire encoding of the same values). Reproducing
411 //! it here would need a populated Drive, which is outside this
412 //! crate's feature surface.
413 use super::*;
414 use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::{
415 Documents, RankedEntries,
416 };
417 use dapi_grpc::platform::v0::get_documents_response::GetDocumentsResponseV1;
418 use drive::query::RANKED_AVG_SCALE;
419
420 fn count_entry(key: &str, count: u64) -> ProtoRankedEntry {
421 ProtoRankedEntry {
422 in_key: None,
423 key: key.as_bytes().to_vec(),
424 value: Some(ranked_entry::Value::Count(count)),
425 }
426 }
427
428 /// An entry as the *server* would emit it for a group whose exact
429 /// fixed-point average is `fixed_point`: the wire carries
430 /// `fixed_point as f64 / RANKED_AVG_SCALE as f64`, the same
431 /// conversion `RankedEntryValue::as_f64` performs in rs-drive-abci.
432 fn avg_entry(key: &str, fixed_point: i128) -> ProtoRankedEntry {
433 avg_entry_raw(key, (fixed_point as f64) / (RANKED_AVG_SCALE as f64))
434 }
435
436 /// An entry carrying an arbitrary double, for the malformed cases
437 /// that no fixed point maps to.
438 fn avg_entry_raw(key: &str, avg: f64) -> ProtoRankedEntry {
439 ProtoRankedEntry {
440 in_key: None,
441 key: key.as_bytes().to_vec(),
442 value: Some(ranked_entry::Value::Avg(avg)),
443 }
444 }
445
446 fn response_with(result: get_documents_response_v1::Result) -> GetDocumentsResponse {
447 GetDocumentsResponse {
448 version: Some(ResponseVersion::V1(GetDocumentsResponseV1 {
449 result: Some(result),
450 metadata: Some(ResponseMetadata {
451 height: 42,
452 ..Default::default()
453 }),
454 })),
455 }
456 }
457
458 fn ranked_response(entries: Vec<ProtoRankedEntry>) -> GetDocumentsResponse {
459 paged_ranked_response(entries, None)
460 }
461
462 fn paged_ranked_response(
463 entries: Vec<ProtoRankedEntry>,
464 skipped: Option<u64>,
465 ) -> GetDocumentsResponse {
466 response_with(get_documents_response_v1::Result::Data(ResultData {
467 variant: Some(result_data::Variant::Ranked(RankedEntries {
468 entries,
469 skipped,
470 })),
471 }))
472 }
473
474 /// The headline decode: `SELECT AVG(grade) … GROUP BY restaurantId
475 /// ORDER BY grade DESC LIMIT 3`. Entry order is the ranking order
476 /// and must survive the decode verbatim, and each double must
477 /// re-scale into the fixed point it was rendered from.
478 ///
479 /// These particular averages survive the double round trip *bit for
480 /// bit* — `95`, `85` and `10.5` times `10^19` all fit in `f64`'s 53
481 /// significand bits — so the assertion can be exact. That is a
482 /// property of the fixtures, not a guarantee of the wire format:
483 /// `from_unproved_response` reconstructs a best-effort fixed point,
484 /// and an average with more significant digits than `f64` holds
485 /// would come back slightly off. Exactness lives on the proof path.
486 #[test]
487 fn decodes_avg_entries_preserving_ranking_order() {
488 let response = ranked_response(vec![
489 avg_entry("gamma", 95 * RANKED_AVG_SCALE),
490 avg_entry("alpha", 85 * RANKED_AVG_SCALE),
491 // 21/2 = 10.5 — a non-integral average, so the fixed-point
492 // floor is actually exercised rather than a round multiple.
493 avg_entry("epsilon", (21 * RANKED_AVG_SCALE).div_euclid(2)),
494 ]);
495
496 let (decoded, metadata) = DocumentRankedEntries::from_unproved_response(&response)
497 .expect("a well-formed ranked payload decodes");
498
499 assert_eq!(metadata.height, 42, "metadata rides along with the entries");
500 let keys: Vec<&[u8]> = decoded.entries.iter().map(|e| e.key.as_slice()).collect();
501 assert_eq!(
502 keys,
503 vec![
504 b"gamma".as_slice(),
505 b"alpha".as_slice(),
506 b"epsilon".as_slice()
507 ],
508 "entry order is the ranking order and must not be re-sorted"
509 );
510 assert_eq!(
511 decoded.entries[2].value,
512 RankedEntryValue::AvgFixedPoint((21 * RANKED_AVG_SCALE).div_euclid(2))
513 );
514 assert_eq!(
515 decoded.entries[2].value.as_f64(),
516 10.5,
517 "dividing by RANKED_AVG_SCALE recovers the average a caller renders"
518 );
519 }
520
521 /// Averages are signed: a group whose summable property is
522 /// negative ranks below zero, and the sign must survive the double
523 /// round trip. A decoder that mishandled it would turn `-0.5` into
524 /// a positive number and silently invert the ranking's meaning.
525 ///
526 /// The last entry is the largest magnitude the axis can actually
527 /// produce — `sum = i64::MIN` over a single document — which pins
528 /// that the `i128`-range guard rejects only genuinely impossible
529 /// doubles, not legitimate extremes.
530 #[test]
531 fn decodes_negative_averages() {
532 let negative_half = (-RANKED_AVG_SCALE).div_euclid(2);
533 let extreme = (i64::MIN as i128) * RANKED_AVG_SCALE;
534 let response = ranked_response(vec![
535 avg_entry("above", RANKED_AVG_SCALE),
536 avg_entry("below", negative_half),
537 avg_entry("floor", extreme),
538 ]);
539
540 let (decoded, _) = DocumentRankedEntries::from_unproved_response(&response)
541 .expect("negative averages are well-formed");
542
543 assert_eq!(
544 decoded.entries[1].value,
545 RankedEntryValue::AvgFixedPoint(negative_half)
546 );
547 assert_eq!(decoded.entries[1].value.as_f64(), -0.5);
548 assert_eq!(
549 decoded.entries[2].value,
550 RankedEntryValue::AvgFixedPoint(extreme),
551 "the most negative average the axis can hold is decoded, not rejected"
552 );
553 }
554
555 /// Count entries decode to `u64` untouched — including values
556 /// above 2^53, which is why the wire field is `jstype = JS_STRING`.
557 #[test]
558 fn decodes_count_entries() {
559 let response = ranked_response(vec![
560 count_entry("delta", 4),
561 count_entry("beta", 3),
562 count_entry("huge", u64::MAX),
563 ]);
564
565 let (decoded, _) = DocumentRankedEntries::from_unproved_response(&response)
566 .expect("count entries are well-formed");
567
568 assert_eq!(
569 decoded.entries.iter().map(|e| e.value).collect::<Vec<_>>(),
570 vec![
571 RankedEntryValue::Count(4),
572 RankedEntryValue::Count(3),
573 RankedEntryValue::Count(u64::MAX)
574 ]
575 );
576 }
577
578 /// Sums are signed `sint64` on the wire, so a negative running
579 /// total (a group of refunds) must decode as-is.
580 #[test]
581 fn decodes_signed_sum_entries() {
582 let response = ranked_response(vec![ProtoRankedEntry {
583 in_key: None,
584 key: b"refunds".to_vec(),
585 value: Some(ranked_entry::Value::Sum(-1_000)),
586 }]);
587
588 let (decoded, _) = DocumentRankedEntries::from_unproved_response(&response)
589 .expect("signed sums are well-formed");
590
591 assert_eq!(decoded.entries[0].value, RankedEntryValue::Sum(-1_000));
592 }
593
594 /// An empty ranking is a legitimate answer, not an error: the
595 /// index simply has no groups yet.
596 #[test]
597 fn decodes_an_empty_ranking() {
598 let (decoded, _) = DocumentRankedEntries::from_unproved_response(&ranked_response(vec![]))
599 .expect("an empty ranking is well-formed");
600 assert!(decoded.entries.is_empty());
601 assert_eq!(decoded.starting_rank, 0);
602 }
603
604 /// `skipped` is what makes a page a ranking rather than a list.
605 /// A single entry at rank 4 is the *5th* best group, and the decode
606 /// must carry that through — dropping it would leave the caller
607 /// unable to distinguish it from the winner.
608 #[test]
609 fn decodes_the_starting_rank_of_a_page() {
610 let (decoded, _) = DocumentRankedEntries::from_unproved_response(&paged_ranked_response(
611 vec![avg_entry("epsilon", 10 * RANKED_AVG_SCALE)],
612 Some(4),
613 ))
614 .expect("a paged ranked payload decodes");
615 assert_eq!(decoded.starting_rank, 4);
616 assert_eq!(decoded.entries.len(), 1);
617 }
618
619 /// An offset past the end: no entries, but `skipped` still carries
620 /// the population. Empty entries plus a positive rank is a
621 /// meaningful answer ("there are only 12 groups"), not a
622 /// contradiction to be normalized away.
623 #[test]
624 fn decodes_a_page_past_the_end_of_the_ranking() {
625 let (decoded, _) =
626 DocumentRankedEntries::from_unproved_response(&paged_ranked_response(vec![], Some(12)))
627 .expect("a past-the-end page decodes");
628 assert!(decoded.entries.is_empty());
629 assert_eq!(decoded.starting_rank, 12);
630 }
631
632 /// A node that predates the wire field leaves `skipped` unset.
633 /// That must read as rank 0 — the right answer for the offset-less
634 /// queries such a node could serve at all — rather than as a decode
635 /// failure.
636 #[test]
637 fn an_absent_skipped_field_decodes_as_rank_zero() {
638 let (decoded, _) = DocumentRankedEntries::from_unproved_response(&paged_ranked_response(
639 vec![count_entry("delta", 4)],
640 None,
641 ))
642 .expect("an absent `skipped` is not a decode failure");
643 assert_eq!(decoded.starting_rank, 0);
644 }
645
646 /// An `avg` that cannot be a scaled fixed point is rejected rather
647 /// than cast. `as` casts on `f64 -> i128` saturate and map `NaN` to
648 /// `0`, so a malformed wire value would otherwise decode as a
649 /// confident average of zero (or of `i128::MIN`) — a
650 /// plausible-looking lie is far worse than a loud failure.
651 ///
652 /// This is the double-shaped replacement for the old
653 /// exactly-16-bytes length check: a `double` field has no length to
654 /// validate, but it does have values no legitimate average maps to.
655 #[test]
656 fn rejects_an_avg_that_is_not_a_scaled_fixed_point() {
657 for avg in [
658 f64::NAN,
659 f64::INFINITY,
660 f64::NEG_INFINITY,
661 // Past i128 range once multiplied by 10^19.
662 1e30,
663 -1e30,
664 ] {
665 let response = ranked_response(vec![avg_entry_raw("alpha", avg)]);
666 let err = match DocumentRankedEntries::from_unproved_response(&response) {
667 Err(err) => err,
668 Ok(decoded) => panic!("an `avg` of {avg} must be rejected, decoded {decoded:?}"),
669 };
670 let message = format!("{err}");
671 assert!(
672 message.contains("finite double") && message.contains("i128 range"),
673 "the rejection must name the contract it violates; got {message}"
674 );
675 }
676 }
677
678 /// A `RankedEntry` with no `value` set means the server (or a
679 /// middlebox) produced a message this client cannot interpret.
680 /// Defaulting it to zero would fabricate a ranking position.
681 #[test]
682 fn rejects_an_entry_with_no_value() {
683 let response = ranked_response(vec![ProtoRankedEntry {
684 in_key: None,
685 key: b"alpha".to_vec(),
686 value: None,
687 }]);
688 let err = DocumentRankedEntries::from_unproved_response(&response)
689 .expect_err("an entry with no value must be rejected");
690 assert!(format!("{err}").contains("no `value`"));
691 }
692
693 /// A proved response reaching the unproven decoder is a caller
694 /// mistake with a security consequence — silently returning
695 /// nothing (or erroring vaguely) would let a caller believe an
696 /// unverified path was the verified one. Name the right entry
697 /// point in the error.
698 #[test]
699 fn rejects_a_proof_response() {
700 let response = response_with(get_documents_response_v1::Result::Proof(Proof::default()));
701 let err = DocumentRankedEntries::from_unproved_response(&response)
702 .expect_err("a proof is not an unproven ranked payload");
703 assert!(format!("{err}").contains("verify_ranked_top_k_proof"));
704 }
705
706 /// A response on another `ResultData` variant means the node
707 /// routed the request somewhere else entirely (no ranking operand
708 /// reached it, or it landed on the count / sum / average
709 /// executor). Report the shape rather than an empty list.
710 #[test]
711 fn rejects_a_non_ranked_result_variant() {
712 let response = response_with(get_documents_response_v1::Result::Data(ResultData {
713 variant: Some(result_data::Variant::Documents(Documents {
714 documents: Vec::new(),
715 })),
716 }));
717 let err = DocumentRankedEntries::from_unproved_response(&response)
718 .expect_err("a documents payload is not a ranked one");
719 assert!(format!("{err}").contains("ResultData.ranked"));
720 }
721
722 /// The V0 `getDocuments` response predates the whole SQL-shaped
723 /// surface and has no ranked variant; a node answering V0 is a
724 /// node that cannot serve this query at all.
725 #[test]
726 fn rejects_a_v0_response() {
727 let response = GetDocumentsResponse {
728 version: Some(ResponseVersion::V0(Default::default())),
729 };
730 let err = DocumentRankedEntries::from_unproved_response(&response)
731 .expect_err("V0 has no ranked shape");
732 assert!(format!("{err}").contains("V1-only"));
733 }
734
735 /// A versionless response is a decode failure, not an empty
736 /// ranking.
737 #[test]
738 fn rejects_a_versionless_response() {
739 let err =
740 DocumentRankedEntries::from_unproved_response(&GetDocumentsResponse { version: None })
741 .expect_err("a versionless response must be rejected");
742 assert!(matches!(err, Error::EmptyVersion));
743 }
744
745 /// `from_verified` is what the SDK's proof path wraps a verified
746 /// [`RankedPage`] with. Both halves must survive: dropping
747 /// `skipped` here would silently turn every proved page into a
748 /// claim about rank 0.
749 #[test]
750 fn from_verified_carries_both_halves_of_the_page() {
751 let entries = vec![
752 RankedEntry {
753 in_key: None,
754 key: b"gamma".to_vec(),
755 value: RankedEntryValue::Count(9),
756 },
757 RankedEntry {
758 in_key: None,
759 key: b"alpha".to_vec(),
760 value: RankedEntryValue::Count(2),
761 },
762 ];
763 let wrapped = DocumentRankedEntries::from_verified(RankedPage {
764 skipped: 7,
765 entries: entries.clone(),
766 });
767 assert_eq!(wrapped.entries, entries);
768 assert_eq!(
769 wrapped.starting_rank, 7,
770 "the attested skip is the page's rank base and must not be dropped"
771 );
772 }
773
774 // The generic `FromProof<Q>` rejection is covered by the SDK's
775 // tests, which can construct a valid `DriveDocumentQuery` via
776 // dpp's `fixtures-and-mocks` feature. drive-proof-verifier itself
777 // doesn't depend on `dpp/fixtures-and-mocks` outside dev-deps for
778 // the contract fixtures that path needs.
779}