Skip to main content

dash_platform_queries/documents/
document_average.rs

1//! `FromProof` + `Fetch` for [`DocumentAverage`] — the single-row
2//! aggregate `(count, sum)` view of the unified `getDocuments`
3//! endpoint.
4//!
5//! Callers build a [`DocumentQuery`] with
6//! `.with_select(Select::Avg)` and `.with_select_field("<prop>")`;
7//! whatever the request shape, this impl returns a single
8//! `DocumentAverage { count, sum }`. Per-shape proof dispatch lives
9//! in [`super::average_proof_helpers::verify_average_query`] — this
10//! impl folds the verified entries into a single pair.
11//!
12//! Empty entries (a verifier that emitted `None` for a queried-but-
13//! absent branch — same forward-compat for absence proofs as count)
14//! contribute 0 to both axes via `filter_map(|e| e.<field>)`.
15//!
16//! A `containsAny` over several elements of a typed array is refused, even
17//! grouped by the array: folded, a document holding two of the elements would
18//! be counted and summed twice. `DocumentSplitAverages` keeps one pair per
19//! element.
20
21use crate::documents::average_proof_helpers::{
22    assert_select_is_avg, check_average_request, verify_average_query,
23};
24use crate::documents::document_query::DocumentQuery;
25use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata};
26use dash_context_provider::ContextProvider;
27use dpp::dashcore::Network;
28use dpp::version::PlatformVersion;
29use drive_proof_verifier::{AverageEntry, DocumentAverage, FromProof};
30
31/// Fold per-branch `(count, sum)` into a single aggregate
32/// `(count, sum)`. Uses `checked_add` on BOTH axes so a multi-entry
33/// fold that exceeds `u64::MAX` (count) or `i64::MAX` / underflows
34/// below `i64::MIN` (sum) surfaces as a `RequestError` rather than
35/// silently saturating.
36///
37/// The prior `saturating_add` was unsafe: a saturated count or sum
38/// could pin the computed average to a wrong value (e.g., a sum
39/// that saturated at `i64::MAX` divided by an accurate count would
40/// understate the true average). Extracted into a free function so
41/// the overflow paths are unit-testable.
42fn fold_average_entries(
43    entries: &[AverageEntry],
44) -> Result<DocumentAverage, drive_proof_verifier::Error> {
45    let mut total_count: u64 = 0;
46    let mut total_sum: i64 = 0;
47    for e in entries {
48        if let Some(c) = e.count {
49            total_count = total_count.checked_add(c).ok_or_else(|| {
50                drive_proof_verifier::Error::RequestError {
51                    error: "DocumentAverage: u64 overflow folding per-branch counts into a \
52                            single aggregate. The proof itself verified, but the requested \
53                            total count doesn't fit in u64. Use DocumentSplitAverages to \
54                            receive per-branch (u64, i64) and fold with your own arithmetic."
55                        .to_string(),
56                }
57            })?;
58        }
59        if let Some(s) = e.sum {
60            total_sum = total_sum.checked_add(s).ok_or_else(|| {
61                drive_proof_verifier::Error::RequestError {
62                    error: "DocumentAverage: i64 over/underflow folding per-branch sums into \
63                            a single aggregate. The proof itself verified, but the requested \
64                            total sum doesn't fit in i64. Use DocumentSplitAverages to \
65                            receive per-branch (u64, i64) and fold with your own arithmetic \
66                            (e.g. i128)."
67                        .to_string(),
68                }
69            })?;
70        }
71    }
72    Ok(DocumentAverage {
73        count: total_count,
74        sum: total_sum,
75    })
76}
77
78impl FromProof<DocumentQuery> for DocumentAverage {
79    type Request = DocumentQuery;
80    type Response = GetDocumentsResponse;
81
82    /// One total adds up every verified entry, so the verifier refuses a `containsAny`
83    /// that reads several elements of a typed array even grouped by the array, which
84    /// the server answers with one total per element.
85    fn check_request(
86        request: &Self::Request,
87        platform_version: &PlatformVersion,
88    ) -> Result<(), drive_proof_verifier::Error> {
89        check_average_request(request, true, platform_version)
90    }
91
92    fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
93        request: I,
94        response: O,
95        _network: Network,
96        platform_version: &PlatformVersion,
97        provider: &'a dyn ContextProvider,
98    ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
99    where
100        Self: 'a,
101    {
102        let request: Self::Request = request.into();
103        assert_select_is_avg(&request)?;
104        let response: Self::Response = response.into();
105        let (entries, mtd, proof) =
106            verify_average_query(request, response, true, platform_version, provider)?;
107        // Fold per-branch (count, sum) into a single aggregate via
108        // `fold_average_entries` — checked arithmetic on both axes,
109        // see helper docstring.
110        let avg = match entries {
111            None => None,
112            Some(es) => Some(fold_average_entries(&es)?),
113        };
114        Ok((avg, mtd, proof))
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    //! Unit tests for the AVG fold. The fold logic is extracted
121    //! into `fold_average_entries` so we can pin overflow /
122    //! underflow behavior on both axes without driving a full
123    //! proof flow. The prior `saturating_add` implementation
124    //! would silently pin overflow results to numeric bounds and
125    //! produce a wrong average — these tests lock the explicit-
126    //! error behavior.
127
128    use super::*;
129
130    fn entry(in_key: Option<Vec<u8>>, count: Option<u64>, sum: Option<i64>) -> AverageEntry {
131        AverageEntry {
132            in_key,
133            key: vec![0u8],
134            count,
135            sum,
136        }
137    }
138
139    /// Single-branch fold: pass-through. Smoke test.
140    #[test]
141    fn fold_average_entries_single_branch_passes_through() {
142        let entries = vec![entry(None, Some(10), Some(250))];
143        let avg = fold_average_entries(&entries).expect("single branch should fold cleanly");
144        assert_eq!(
145            avg,
146            DocumentAverage {
147                count: 10,
148                sum: 250
149            }
150        );
151    }
152
153    /// Multi-branch with absent (verifier-emitted `None`) branches:
154    /// `None` on either axis contributes 0 to that axis.
155    #[test]
156    fn fold_average_entries_multi_branch_with_absent_axes() {
157        let entries = vec![
158            entry(Some(vec![1]), Some(5), Some(100)),
159            entry(Some(vec![2]), None, None), // fully absent → contributes (0, 0)
160            entry(Some(vec![3]), Some(3), Some(50)),
161            entry(Some(vec![4]), Some(2), None), // sum absent, count present
162        ];
163        let avg = fold_average_entries(&entries)
164            .expect("absent axes must contribute 0 on their respective axis");
165        assert_eq!(
166            avg,
167            DocumentAverage {
168                count: 10,
169                sum: 150
170            }
171        );
172    }
173
174    /// `u64::MAX` count + 1 → must error, not saturate. Regression:
175    /// the prior `saturating_add` would pin the count to
176    /// `u64::MAX` and produce a wrong average (saturated count /
177    /// accurate sum understates the average).
178    #[test]
179    fn fold_average_entries_count_overflow_returns_error() {
180        let entries = vec![
181            entry(Some(vec![1]), Some(u64::MAX), Some(0)),
182            entry(Some(vec![2]), Some(1), Some(0)),
183        ];
184        let err = fold_average_entries(&entries)
185            .expect_err("u64 count overflow must surface as RequestError, not saturate");
186        let msg = format!("{err:?}");
187        assert!(
188            msg.contains("u64 overflow") && msg.contains("DocumentSplitAverages"),
189            "error must name the count overflow + hint at DocumentSplitAverages; got {msg}"
190        );
191    }
192
193    /// `i64::MAX` sum + positive → must error. Same regression as
194    /// count overflow.
195    #[test]
196    fn fold_average_entries_positive_sum_overflow_returns_error() {
197        let entries = vec![
198            entry(Some(vec![1]), Some(0), Some(i64::MAX)),
199            entry(Some(vec![2]), Some(0), Some(1)),
200        ];
201        let err = fold_average_entries(&entries)
202            .expect_err("positive i64 sum overflow must surface as RequestError");
203        let msg = format!("{err:?}");
204        assert!(
205            msg.contains("i64 over/underflow") && msg.contains("DocumentSplitAverages"),
206            "error must name the sum over/underflow + hint at DocumentSplitAverages; got {msg}"
207        );
208    }
209
210    /// `i64::MIN` sum + negative → must error (the underflow
211    /// direction). Symmetric to the positive case so a future
212    /// switch to `saturating_*` can't silently regress only one
213    /// direction.
214    #[test]
215    fn fold_average_entries_negative_sum_underflow_returns_error() {
216        let entries = vec![
217            entry(Some(vec![1]), Some(0), Some(i64::MIN)),
218            entry(Some(vec![2]), Some(0), Some(-1)),
219        ];
220        let err = fold_average_entries(&entries)
221            .expect_err("negative i64 sum underflow must surface as RequestError");
222        let msg = format!("{err:?}");
223        assert!(msg.contains("i64 over/underflow"));
224    }
225
226    /// Empty fold returns `(0, 0)` — same as count's `0` empty
227    /// fold and SUM's `0`.
228    #[test]
229    fn fold_average_entries_empty_returns_zero_pair() {
230        let avg = fold_average_entries(&[]).expect("empty fold must succeed");
231        assert_eq!(avg, DocumentAverage { count: 0, sum: 0 });
232    }
233}