dash_platform_queries/documents/document_sum.rs
1//! `FromProof` + `Fetch` for [`DocumentSum`] — the single-value
2//! aggregate sum view of the unified `getDocuments` endpoint.
3//!
4//! Sum-side analog of [`super::document_count`]. Callers build a
5//! `DocumentQuery` with `.with_select(Select::Sum)` and
6//! `.with_select_field("amount")`; whatever the request shape,
7//! this impl returns a single `i64` (the aggregate sum).
8//!
9//! Empty entries (verifier emitted `None` for a queried-but-absent
10//! branch) contribute 0 to the sum via `filter_map(|e| e.sum)`.
11//!
12//! A `containsAny` over several elements of a typed array is refused, even
13//! grouped by the array: folded, a document holding two of the elements would
14//! be summed twice. `DocumentSplitSums` keeps one sum per element.
15//!
16//! Overflow handling: the fold uses [`i64::checked_add`] and returns
17//! a `RequestError` on overflow rather than panicking (debug) or
18//! wrapping (release). A grovedb sum-tree's per-node aggregate
19//! itself fits in `i64` by construction, but a multi-entry fold
20//! across carrier-aggregate / distinct branches can in principle
21//! exceed that — the verifier surfaces it explicitly so callers
22//! can switch to `DocumentSplitSums` (which preserves per-branch
23//! `i64`s and lets the caller pick its own arithmetic).
24
25use crate::documents::document_query::DocumentQuery;
26use crate::documents::sum_proof_helpers::{
27 assert_select_is_sum, check_sum_request, verify_sum_query,
28};
29use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata};
30use dash_context_provider::ContextProvider;
31use dpp::dashcore::Network;
32use dpp::version::PlatformVersion;
33use drive_proof_verifier::{DocumentSum, FromProof, SumEntry};
34
35/// Fold per-branch sums into a single `i64`. Returns `RequestError`
36/// on overflow rather than panicking (debug) or wrapping (release).
37/// Extracted into a free function so the overflow path is
38/// unit-testable without driving a full proof flow.
39fn fold_sum_entries(entries: &[SumEntry]) -> Result<i64, drive_proof_verifier::Error> {
40 let mut total: i64 = 0;
41 for e in entries {
42 if let Some(s) = e.sum {
43 total =
44 total
45 .checked_add(s)
46 .ok_or_else(|| drive_proof_verifier::Error::RequestError {
47 error: "DocumentSum: i64 overflow folding per-branch sums into a single \
48 aggregate. The proof itself verified, but the requested sum \
49 doesn't fit in i64. Use DocumentSplitSums to receive per-branch \
50 i64s and fold them with your own arithmetic (e.g. i128)."
51 .to_string(),
52 })?;
53 }
54 }
55 Ok(total)
56}
57
58impl FromProof<DocumentQuery> for DocumentSum {
59 type Request = DocumentQuery;
60 type Response = GetDocumentsResponse;
61
62 /// One total adds up every verified entry, so the verifier refuses a `containsAny`
63 /// that reads several elements of a typed array even grouped by the array, which
64 /// the server answers with one total per element.
65 fn check_request(
66 request: &Self::Request,
67 platform_version: &PlatformVersion,
68 ) -> Result<(), drive_proof_verifier::Error> {
69 check_sum_request(request, true, platform_version)
70 }
71
72 fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
73 request: I,
74 response: O,
75 _network: Network,
76 platform_version: &PlatformVersion,
77 provider: &'a dyn ContextProvider,
78 ) -> Result<(Option<Self>, ResponseMetadata, Proof), drive_proof_verifier::Error>
79 where
80 Self: 'a,
81 {
82 let request: Self::Request = request.into();
83 assert_select_is_sum(&request)?;
84 let response: Self::Response = response.into();
85 let (entries, mtd, proof) =
86 verify_sum_query(request, response, true, platform_version, provider)?;
87 // Fold per-branch sums into a single `i64` using
88 // `checked_add` (via `fold_sum_entries`). A multi-entry
89 // fold (carrier-aggregate across many In branches, or
90 // distinct-mode across many range buckets) can in
91 // principle overflow even though each branch is itself a
92 // valid grovedb `i64` sum_value. Surface this as a
93 // `RequestError` rather than panicking (debug) or
94 // wrapping (release) — callers can switch to
95 // `DocumentSplitSums` to preserve per-branch numbers.
96 let sum = match entries {
97 None => None,
98 Some(es) => Some(DocumentSum(fold_sum_entries(&es)?)),
99 };
100 Ok((sum, mtd, proof))
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 //! Unit tests for the SUM fold. The fold logic is extracted
107 //! into `fold_sum_entries` so we can pin its overflow
108 //! behavior without driving a full proof flow.
109
110 use super::*;
111
112 fn entry(in_key: Option<Vec<u8>>, sum: Option<i64>) -> SumEntry {
113 SumEntry {
114 in_key,
115 key: vec![0u8],
116 sum,
117 }
118 }
119
120 /// Single in-range branch: fold returns the branch's value.
121 /// Smoke test that the helper hasn't regressed on the
122 /// non-overflow path. Mirrors `verify_count_query`'s single-
123 /// branch unit-test shape.
124 #[test]
125 fn fold_sum_entries_single_branch_passes_through() {
126 let entries = vec![entry(None, Some(42))];
127 let sum = fold_sum_entries(&entries).expect("single branch should fold cleanly");
128 assert_eq!(sum, 42);
129 }
130
131 /// Multi-branch fold sums all the `Some` values. `None` entries
132 /// (verifier emitted `None` for a queried-but-absent branch)
133 /// contribute 0, matching the count helper's three-valued
134 /// semantics.
135 #[test]
136 fn fold_sum_entries_multi_branch_with_absent_branches() {
137 let entries = vec![
138 entry(Some(vec![1]), Some(100)),
139 entry(Some(vec![2]), None), // absent branch → contributes 0
140 entry(Some(vec![3]), Some(50)),
141 ];
142 let sum = fold_sum_entries(&entries).expect("absent branches must contribute 0");
143 assert_eq!(sum, 150);
144 }
145
146 /// Positive overflow: two branches summing to `> i64::MAX` must
147 /// surface as a `RequestError`. Pre-fix this would panic in
148 /// debug or wrap in release (`.sum::<i64>()`) — both unsafe in
149 /// a verifier context. Regression: lock the explicit-error
150 /// behavior.
151 #[test]
152 fn fold_sum_entries_positive_overflow_returns_error() {
153 let entries = vec![
154 entry(Some(vec![1]), Some(i64::MAX)),
155 entry(Some(vec![2]), Some(1)),
156 ];
157 let err = fold_sum_entries(&entries)
158 .expect_err("positive overflow must surface as RequestError, not panic/wrap");
159 let msg = format!("{err:?}");
160 assert!(
161 msg.contains("i64 overflow") && msg.contains("DocumentSplitSums"),
162 "error must name the overflow + hint at DocumentSplitSums; got {msg}"
163 );
164 }
165
166 /// Symmetric negative-overflow guard: two branches summing to
167 /// `< i64::MIN` must surface as a `RequestError`. `checked_add`
168 /// reports both directions; we pin both so a future switch to
169 /// `saturating_*` can't silently regress only one direction.
170 #[test]
171 fn fold_sum_entries_negative_overflow_returns_error() {
172 let entries = vec![
173 entry(Some(vec![1]), Some(i64::MIN)),
174 entry(Some(vec![2]), Some(-1)),
175 ];
176 let err =
177 fold_sum_entries(&entries).expect_err("negative overflow must surface as RequestError");
178 let msg = format!("{err:?}");
179 assert!(msg.contains("i64 overflow"));
180 }
181
182 /// Empty fold: zero entries → zero sum. Pre-empts a regression
183 /// where an empty-branch optimization would return `None` and
184 /// FromProof callers would surface "no proof" errors instead
185 /// of "verified zero".
186 #[test]
187 fn fold_sum_entries_empty_returns_zero() {
188 let sum = fold_sum_entries(&[]).expect("empty fold must succeed");
189 assert_eq!(sum, 0);
190 }
191}