ic_query/nns/proposals/report/
collection.rs1#[cfg(feature = "nns-host")]
8use super::NnsProposalHostError;
9use super::{
10 NnsProposalError,
11 model::{NnsProposalListReport, NnsProposalListRequest},
12 source::{
13 NnsProposalSource, build_nns_proposal_list_report_with_source, validate_proposal_page_size,
14 },
15};
16#[cfg(feature = "nns-host")]
17use crate::nns::LiveNnsSource;
18use crate::nns::governance::{
19 GovernanceCollectionState, NnsGovernanceCollectionStatus, NnsGovernanceRequest,
20 NnsGovernanceSourceProvenance, NnsGovernanceSourceSelection, validate_governance_continuation,
21 validate_governance_request,
22};
23use serde::{Deserialize, Serialize};
24
25pub const NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
27
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub struct NnsProposalCollectionState {
36 #[serde(flatten)]
37 collection: GovernanceCollectionState,
38 proposals_fetched: u64,
39 next_before_proposal_id: Option<u64>,
40}
41
42impl NnsProposalCollectionState {
43 pub fn new(
45 request: &NnsGovernanceRequest,
46 page_size: u32,
47 max_pages: u32,
48 ) -> Result<Self, NnsProposalError> {
49 validate_governance_request(request)?;
50 validate_proposal_page_size(page_size)?;
51 if max_pages == 0 {
52 return Err(NnsProposalError::InvalidCollectionMaxPages);
53 }
54 Ok(Self {
55 collection: GovernanceCollectionState::new(request, page_size, max_pages),
56 proposals_fetched: 0,
57 next_before_proposal_id: None,
58 })
59 }
60
61 #[cfg(feature = "nns-host")]
62 pub(in crate::nns) const fn from_complete_collection(
63 collection: GovernanceCollectionState,
64 rows: u64,
65 ) -> Self {
66 Self {
67 collection,
68 proposals_fetched: rows,
69 next_before_proposal_id: None,
70 }
71 }
72
73 #[cfg(feature = "nns-host")]
74 pub(in crate::nns) fn collection_evidence(
75 &self,
76 ) -> crate::nns::governance::collection::NnsGovernanceCollectionEvidence {
77 crate::nns::governance::collection::NnsGovernanceCollectionEvidence::from_state(
78 &self.collection,
79 )
80 }
81
82 #[must_use]
84 pub const fn schema_version(&self) -> u32 {
85 self.collection.schema_version
86 }
87
88 #[must_use]
90 pub fn network(&self) -> &str {
91 &self.collection.network
92 }
93
94 #[must_use]
96 pub fn governance_canister_id(&self) -> &str {
97 &self.collection.governance_canister_id
98 }
99
100 #[must_use]
102 pub const fn requested_source(&self) -> &NnsGovernanceSourceSelection {
103 &self.collection.requested_source
104 }
105
106 #[must_use]
108 pub const fn source(&self) -> Option<&NnsGovernanceSourceProvenance> {
109 self.collection.source.as_ref()
110 }
111
112 #[must_use]
114 pub const fn page_size(&self) -> u32 {
115 self.collection.page_size
116 }
117
118 #[must_use]
120 pub const fn max_pages(&self) -> u32 {
121 self.collection.max_pages
122 }
123
124 #[must_use]
126 pub const fn pages_fetched(&self) -> u32 {
127 self.collection.pages_fetched
128 }
129
130 #[must_use]
132 pub const fn proposals_fetched(&self) -> u64 {
133 self.proposals_fetched
134 }
135
136 #[must_use]
138 pub const fn next_before_proposal_id(&self) -> Option<u64> {
139 self.next_before_proposal_id
140 }
141
142 #[must_use]
144 pub fn started_at(&self) -> &str {
145 &self.collection.started_at
146 }
147
148 #[must_use]
150 pub fn updated_at(&self) -> &str {
151 &self.collection.updated_at
152 }
153
154 #[must_use]
156 pub const fn status(&self) -> NnsGovernanceCollectionStatus {
157 self.collection.status
158 }
159
160 #[must_use]
162 pub const fn is_complete(&self) -> bool {
163 matches!(
164 self.collection.status,
165 NnsGovernanceCollectionStatus::Complete
166 )
167 }
168}
169
170#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
177pub struct NnsProposalCollectionStep {
178 pub page: NnsProposalListReport,
180 pub state: NnsProposalCollectionState,
182}
183
184#[cfg(feature = "nns-host")]
186pub async fn advance_nns_proposal_collection(
187 request: &NnsGovernanceRequest,
188 state: &NnsProposalCollectionState,
189) -> Result<NnsProposalCollectionStep, NnsProposalHostError> {
190 Ok(
191 advance_nns_proposal_collection_with_source(request, state, &LiveNnsSource::default())
192 .await?,
193 )
194}
195
196pub async fn advance_nns_proposal_collection_with_source(
198 request: &NnsGovernanceRequest,
199 state: &NnsProposalCollectionState,
200 source: &dyn NnsProposalSource,
201) -> Result<NnsProposalCollectionStep, NnsProposalError> {
202 validate_governance_request(request)?;
203 validate_collection_state(state)?;
204 validate_governance_continuation!(request, state, NnsProposalError)?;
205
206 let mut page_request = NnsProposalListRequest::new(request.clone(), state.collection.page_size);
207 page_request.before_proposal_id = state.next_before_proposal_id;
208 let page = build_nns_proposal_list_report_with_source(&page_request, source).await?;
209 if let Some(expected) = &state.collection.source
210 && *expected != page.context.source
211 {
212 return Err(NnsProposalError::CollectionSourceChanged {
213 expected: expected.clone(),
214 actual: page.context.source,
215 });
216 }
217
218 let page_count = u32::try_from(page.proposal_count)
219 .map_err(|_| NnsProposalError::CollectionAccountingOverflow)?;
220 let (pages_fetched, proposals_fetched) = crate::nns::governance::advance_collection_counts(
221 state.collection.pages_fetched,
222 state.proposals_fetched,
223 u64::from(page_count),
224 )
225 .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
226 let next_before_proposal_id = (page_count == state.collection.page_size)
227 .then(|| {
228 page.proposals
229 .iter()
230 .filter_map(|proposal| proposal.proposal_id)
231 .min()
232 .filter(|proposal_id| *proposal_id > 1)
233 })
234 .flatten();
235 let next_state = NnsProposalCollectionState {
236 collection: state.collection.after_page(
237 request,
238 page.context.source.clone(),
239 pages_fetched,
240 next_before_proposal_id.is_some(),
241 ),
242 proposals_fetched,
243 next_before_proposal_id,
244 };
245 Ok(NnsProposalCollectionStep {
246 page,
247 state: next_state,
248 })
249}
250
251pub(super) fn validate_collection_state(
252 state: &NnsProposalCollectionState,
253) -> Result<(), NnsProposalError> {
254 let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
255 state
256 .collection
257 .validate(NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION, invalid)?;
258 validate_proposal_page_size(state.collection.page_size)?;
259
260 let proposals_fetched = state.proposals_fetched;
261 let maximum_rows = crate::nns::governance::validate_governance_collection_row_bounds(
262 "proposals_fetched",
263 proposals_fetched,
264 state.collection.pages_fetched,
265 state.collection.page_size,
266 invalid,
267 )?;
268
269 if state
270 .next_before_proposal_id
271 .is_some_and(|cursor| cursor <= 1)
272 {
273 return Err(invalid(
274 "next_before_proposal_id must be greater than one".to_string(),
275 ));
276 }
277
278 let valid_lifecycle = crate::nns::governance::collection_lifecycle_matches(
279 state.collection.status,
280 state.collection.pages_fetched,
281 state.collection.max_pages,
282 state.proposals_fetched,
283 maximum_rows,
284 state.next_before_proposal_id.is_some(),
285 state.collection.source.is_some(),
286 );
287 if !valid_lifecycle {
288 return Err(invalid(format!(
289 "status {} disagrees with cursor, provenance, or counters",
290 state.collection.status
291 )));
292 }
293 Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::nns::governance::{NnsGovernanceError, validate_governance_time_interval};
300
301 struct UnexpectedSource;
302 impl NnsProposalSource for UnexpectedSource {
303 fn fetch_proposals<'a>(
304 &'a self,
305 _: &'a NnsGovernanceRequest,
306 _: u32,
307 _: Option<u64>,
308 _: crate::nns::proposals::NnsProposalStatusFilter,
309 _: crate::nns::proposals::NnsProposalRewardStatusFilter,
310 ) -> crate::nns::proposals::NnsProposalSourceFuture<
311 'a,
312 Vec<crate::nns::proposals::NnsProposalRow>,
313 > {
314 panic!("unexpected source call")
315 }
316 fn fetch_proposal<'a>(
317 &'a self,
318 _: &'a NnsGovernanceRequest,
319 _: u64,
320 ) -> crate::nns::proposals::NnsProposalSourceFuture<'a, crate::nns::proposals::NnsProposalRow>
321 {
322 panic!("unexpected source call")
323 }
324 }
325
326 #[tokio::test(flavor = "current_thread")]
327 async fn collection_rejects_backward_continuations_and_restored_intervals() {
328 let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
329 let state = NnsProposalCollectionState::new(&start, 2, 3).unwrap();
330 let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
331 let mut future = std::pin::pin!(advance_nns_proposal_collection_with_source(
332 &before,
333 &state,
334 &UnexpectedSource
335 ));
336 let mut context = std::task::Context::from_waker(std::task::Waker::noop());
337 let result = std::future::Future::poll(future.as_mut(), &mut context);
338 assert!(matches!(
339 result,
340 std::task::Poll::Ready(Err(NnsProposalError::Governance(
341 NnsGovernanceError::InvalidTimestampOrder { .. }
342 )))
343 ));
344 let mut value = serde_json::to_value(&state).unwrap();
345 value["updated_at"] = serde_json::json!(before.fetched_at);
346 let restored = serde_json::from_value(value).unwrap();
347 assert!(matches!(
348 validate_collection_state(&restored),
349 Err(NnsProposalError::Governance(
350 NnsGovernanceError::InvalidTimestampOrder { .. }
351 ))
352 ));
353 assert!(
354 validate_governance_time_interval(
355 "updated_at",
356 &state.collection.updated_at,
357 "fetched_at",
358 &start.fetched_at
359 )
360 .is_ok()
361 );
362 }
363
364 #[test]
365 fn collection_validates_request_and_restored_timestamps() {
366 let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
367 "ic",
368 1_700_000_000,
369 );
370 let valid_state = NnsProposalCollectionState::new(&request, 2, 3).expect("valid state");
371 for (field, value) in [
372 ("governance_canister_id", serde_json::json!("aaaaa-aa")),
373 ("max_pages", serde_json::json!(0)),
374 ("pages_fetched", serde_json::json!(4)),
375 ] {
376 let mut restored = serde_json::to_value(&valid_state).unwrap();
377 restored[field] = value;
378 let restored = serde_json::from_value(restored).unwrap();
379 assert!(
380 matches!(
381 validate_collection_state(&restored),
382 Err(NnsProposalError::InvalidCollectionState { .. })
383 ),
384 "invalid restored {field}"
385 );
386 }
387 for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
388 request.fetched_at = timestamp.to_string();
389 assert!(matches!(
390 NnsProposalCollectionState::new(&request, 2, 3),
391 Err(NnsProposalError::Governance(
392 NnsGovernanceError::InvalidTimestamp {
393 field: "fetched_at",
394 ..
395 }
396 ))
397 ));
398 for field in ["started_at", "updated_at"] {
399 let mut value = serde_json::to_value(&valid_state).expect("serialize state");
400 value[field] = serde_json::json!(timestamp);
401 let state = serde_json::from_value(value).expect("restore caller state");
402 assert!(matches!(
403 validate_collection_state(&state),
404 Err(NnsProposalError::Governance(
405 NnsGovernanceError::InvalidTimestamp { value, .. }
406 )) if value == timestamp
407 ));
408 }
409 }
410 }
411}