1#[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::{
19 MAINNET_GOVERNANCE_CANISTER_ID,
20 governance::{
21 NnsGovernanceCollectionStatus, NnsGovernanceRequest, NnsGovernanceSourceProvenance,
22 NnsGovernanceSourceSelection, validate_governance_collection_identity,
23 validate_governance_collection_page_bounds, validate_governance_continuation_request,
24 validate_governance_request, validate_governance_time_interval, validate_source_provenance,
25 },
26};
27use serde::{Deserialize, Serialize};
28
29pub const NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39pub struct NnsProposalCollectionState {
40 schema_version: u32,
41 network: String,
42 governance_canister_id: String,
43 requested_source: NnsGovernanceSourceSelection,
44 source: Option<NnsGovernanceSourceProvenance>,
45 page_size: u32,
46 max_pages: u32,
47 pages_fetched: u32,
48 proposals_fetched: u64,
49 next_before_proposal_id: Option<u64>,
50 started_at: String,
51 updated_at: String,
52 status: NnsGovernanceCollectionStatus,
53}
54
55impl NnsProposalCollectionState {
56 pub fn new(
58 request: &NnsGovernanceRequest,
59 page_size: u32,
60 max_pages: u32,
61 ) -> Result<Self, NnsProposalError> {
62 validate_governance_request(request)?;
63 validate_proposal_page_size(page_size)?;
64 if max_pages == 0 {
65 return Err(NnsProposalError::InvalidCollectionMaxPages);
66 }
67 Ok(Self {
68 schema_version: NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION,
69 network: request.network.clone(),
70 governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
71 requested_source: request.source.clone(),
72 source: None,
73 page_size,
74 max_pages,
75 pages_fetched: 0,
76 proposals_fetched: 0,
77 next_before_proposal_id: None,
78 started_at: request.fetched_at.clone(),
79 updated_at: request.fetched_at.clone(),
80 status: NnsGovernanceCollectionStatus::Ready,
81 })
82 }
83
84 #[must_use]
86 pub const fn schema_version(&self) -> u32 {
87 self.schema_version
88 }
89
90 #[must_use]
92 pub fn network(&self) -> &str {
93 &self.network
94 }
95
96 #[must_use]
98 pub fn governance_canister_id(&self) -> &str {
99 &self.governance_canister_id
100 }
101
102 #[must_use]
104 pub const fn requested_source(&self) -> &NnsGovernanceSourceSelection {
105 &self.requested_source
106 }
107
108 #[must_use]
110 pub const fn source(&self) -> Option<&NnsGovernanceSourceProvenance> {
111 self.source.as_ref()
112 }
113
114 #[must_use]
116 pub const fn page_size(&self) -> u32 {
117 self.page_size
118 }
119
120 #[must_use]
122 pub const fn max_pages(&self) -> u32 {
123 self.max_pages
124 }
125
126 #[must_use]
128 pub const fn pages_fetched(&self) -> u32 {
129 self.pages_fetched
130 }
131
132 #[must_use]
134 pub const fn proposals_fetched(&self) -> u64 {
135 self.proposals_fetched
136 }
137
138 #[must_use]
140 pub const fn next_before_proposal_id(&self) -> Option<u64> {
141 self.next_before_proposal_id
142 }
143
144 #[must_use]
146 pub fn started_at(&self) -> &str {
147 &self.started_at
148 }
149
150 #[must_use]
152 pub fn updated_at(&self) -> &str {
153 &self.updated_at
154 }
155
156 #[must_use]
158 pub const fn status(&self) -> NnsGovernanceCollectionStatus {
159 self.status
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(
204 request,
205 &state.network,
206 &state.requested_source,
207 |field, expected, actual| NnsProposalError::CollectionRequestMismatch {
208 field,
209 expected,
210 actual,
211 },
212 )?;
213 validate_governance_time_interval(
214 "updated_at",
215 &state.updated_at,
216 "fetched_at",
217 &request.fetched_at,
218 )?;
219 match state.status {
220 NnsGovernanceCollectionStatus::Complete => {
221 return Err(NnsProposalError::CollectionComplete {
222 pages_fetched: state.pages_fetched,
223 });
224 }
225 NnsGovernanceCollectionStatus::PageLimitReached => {
226 return Err(NnsProposalError::CollectionPageLimitReached {
227 pages_fetched: state.pages_fetched,
228 max_pages: state.max_pages,
229 });
230 }
231 NnsGovernanceCollectionStatus::Ready | NnsGovernanceCollectionStatus::Collecting => {}
232 }
233
234 let mut page_request = NnsProposalListRequest::new(request.clone(), state.page_size);
235 page_request.before_proposal_id = state.next_before_proposal_id;
236 let page = build_nns_proposal_list_report_with_source(&page_request, source).await?;
237 if let Some(expected) = &state.source
238 && *expected != page.context.source
239 {
240 return Err(NnsProposalError::CollectionSourceChanged {
241 expected: expected.clone(),
242 actual: page.context.source,
243 });
244 }
245
246 let page_count = u32::try_from(page.proposal_count)
247 .map_err(|_| NnsProposalError::CollectionAccountingOverflow)?;
248 let pages_fetched = state
249 .pages_fetched
250 .checked_add(1)
251 .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
252 let proposals_fetched = state
253 .proposals_fetched
254 .checked_add(u64::from(page_count))
255 .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
256 let next_before_proposal_id = (page_count == state.page_size)
257 .then(|| {
258 page.proposals
259 .iter()
260 .filter_map(|proposal| proposal.proposal_id)
261 .min()
262 .filter(|proposal_id| *proposal_id > 1)
263 })
264 .flatten();
265 let status = if next_before_proposal_id.is_none() {
266 NnsGovernanceCollectionStatus::Complete
267 } else if pages_fetched == state.max_pages {
268 NnsGovernanceCollectionStatus::PageLimitReached
269 } else {
270 NnsGovernanceCollectionStatus::Collecting
271 };
272 let next_state = NnsProposalCollectionState {
273 source: Some(page.context.source.clone()),
274 pages_fetched,
275 proposals_fetched,
276 next_before_proposal_id,
277 updated_at: request.fetched_at.clone(),
278 status,
279 ..state.clone()
280 };
281 Ok(NnsProposalCollectionStep {
282 page,
283 state: next_state,
284 })
285}
286
287pub(super) fn validate_collection_state(
288 state: &NnsProposalCollectionState,
289) -> Result<(), NnsProposalError> {
290 let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
291 if state.schema_version != NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION {
292 return Err(invalid(format!(
293 "schema_version is {}, expected {NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION}",
294 state.schema_version
295 )));
296 }
297 validate_governance_collection_identity(
298 &state.network,
299 &state.governance_canister_id,
300 &state.requested_source,
301 (&state.started_at, &state.updated_at),
302 invalid,
303 )?;
304 validate_proposal_page_size(state.page_size)?;
305 validate_governance_collection_page_bounds(state.max_pages, state.pages_fetched, invalid)?;
306 if let Some(source) = &state.source {
307 validate_source_provenance(&state.requested_source, source)?;
308 }
309
310 let page_size = u64::from(state.page_size);
311 let pages_fetched = u64::from(state.pages_fetched);
312 let proposals_fetched = state.proposals_fetched;
313 let maximum_rows = pages_fetched * page_size;
315 let minimum_rows = pages_fetched.saturating_sub(1) * page_size;
316 if proposals_fetched < minimum_rows || proposals_fetched > maximum_rows {
317 return Err(invalid(format!(
318 "proposals_fetched {} is outside {}..={} for {} pages of size {}",
319 state.proposals_fetched,
320 minimum_rows,
321 maximum_rows,
322 state.pages_fetched,
323 state.page_size
324 )));
325 }
326 if state
327 .next_before_proposal_id
328 .is_some_and(|cursor| cursor <= 1)
329 {
330 return Err(invalid(
331 "next_before_proposal_id must be greater than one".to_string(),
332 ));
333 }
334
335 let valid_lifecycle = match state.status {
336 NnsGovernanceCollectionStatus::Ready => {
337 state.pages_fetched == 0
338 && state.proposals_fetched == 0
339 && state.next_before_proposal_id.is_none()
340 && state.source.is_none()
341 }
342 NnsGovernanceCollectionStatus::Collecting => {
343 state.pages_fetched > 0
344 && state.pages_fetched < state.max_pages
345 && proposals_fetched == maximum_rows
346 && state.next_before_proposal_id.is_some()
347 && state.source.is_some()
348 }
349 NnsGovernanceCollectionStatus::Complete => {
350 state.pages_fetched > 0
351 && state.next_before_proposal_id.is_none()
352 && state.source.is_some()
353 }
354 NnsGovernanceCollectionStatus::PageLimitReached => {
355 state.pages_fetched == state.max_pages
356 && proposals_fetched == maximum_rows
357 && state.next_before_proposal_id.is_some()
358 && state.source.is_some()
359 }
360 };
361 if !valid_lifecycle {
362 return Err(invalid(format!(
363 "status {} disagrees with cursor, provenance, or counters",
364 state.status
365 )));
366 }
367 Ok(())
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::nns::governance::NnsGovernanceError;
374
375 struct UnexpectedSource;
376 impl NnsProposalSource for UnexpectedSource {
377 fn fetch_proposals<'a>(
378 &'a self,
379 _: &'a NnsGovernanceRequest,
380 _: u32,
381 _: Option<u64>,
382 _: crate::nns::proposals::NnsProposalStatusFilter,
383 _: crate::nns::proposals::NnsProposalRewardStatusFilter,
384 ) -> crate::nns::proposals::NnsProposalSourceFuture<
385 'a,
386 Vec<crate::nns::proposals::NnsProposalRow>,
387 > {
388 panic!("unexpected source call")
389 }
390 fn fetch_proposal<'a>(
391 &'a self,
392 _: &'a NnsGovernanceRequest,
393 _: u64,
394 ) -> crate::nns::proposals::NnsProposalSourceFuture<'a, crate::nns::proposals::NnsProposalRow>
395 {
396 panic!("unexpected source call")
397 }
398 }
399
400 #[tokio::test(flavor = "current_thread")]
401 async fn collection_rejects_backward_continuations_and_restored_intervals() {
402 let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
403 let state = NnsProposalCollectionState::new(&start, 2, 3).unwrap();
404 let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
405 let mut future = std::pin::pin!(advance_nns_proposal_collection_with_source(
406 &before,
407 &state,
408 &UnexpectedSource
409 ));
410 let mut context = std::task::Context::from_waker(std::task::Waker::noop());
411 let result = std::future::Future::poll(future.as_mut(), &mut context);
412 assert!(matches!(
413 result,
414 std::task::Poll::Ready(Err(NnsProposalError::Governance(
415 NnsGovernanceError::InvalidTimestampOrder { .. }
416 )))
417 ));
418 let mut value = serde_json::to_value(&state).unwrap();
419 value["updated_at"] = serde_json::json!(before.fetched_at);
420 let restored = serde_json::from_value(value).unwrap();
421 assert!(matches!(
422 validate_collection_state(&restored),
423 Err(NnsProposalError::Governance(
424 NnsGovernanceError::InvalidTimestampOrder { .. }
425 ))
426 ));
427 assert!(
428 validate_governance_time_interval(
429 "updated_at",
430 &state.updated_at,
431 "fetched_at",
432 &start.fetched_at
433 )
434 .is_ok()
435 );
436 }
437
438 #[test]
439 fn collection_validates_request_and_restored_timestamps() {
440 let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
441 "ic",
442 1_700_000_000,
443 );
444 let valid_state = NnsProposalCollectionState::new(&request, 2, 3).expect("valid state");
445 for (field, value) in [
446 ("governance_canister_id", serde_json::json!("aaaaa-aa")),
447 ("max_pages", serde_json::json!(0)),
448 ("pages_fetched", serde_json::json!(4)),
449 ] {
450 let mut restored = serde_json::to_value(&valid_state).unwrap();
451 restored[field] = value;
452 let restored = serde_json::from_value(restored).unwrap();
453 assert!(
454 matches!(
455 validate_collection_state(&restored),
456 Err(NnsProposalError::InvalidCollectionState { .. })
457 ),
458 "invalid restored {field}"
459 );
460 }
461 for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
462 request.fetched_at = timestamp.to_string();
463 assert!(matches!(
464 NnsProposalCollectionState::new(&request, 2, 3),
465 Err(NnsProposalError::Governance(
466 NnsGovernanceError::InvalidTimestamp {
467 field: "fetched_at",
468 ..
469 }
470 ))
471 ));
472 for field in ["started_at", "updated_at"] {
473 let mut value = serde_json::to_value(&valid_state).expect("serialize state");
474 value[field] = serde_json::json!(timestamp);
475 let state = serde_json::from_value(value).expect("restore caller state");
476 assert!(matches!(
477 validate_collection_state(&state),
478 Err(NnsProposalError::Governance(
479 NnsGovernanceError::InvalidTimestamp { value, .. }
480 )) if value == timestamp
481 ));
482 }
483 }
484 }
485}