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