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