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};
16use crate::nns::{
17 MAINNET_GOVERNANCE_CANISTER_ID,
18 governance::{
19 NnsGovernanceRequest, NnsGovernanceSourceProvenance, NnsGovernanceSourceSelection,
20 validate_governance_request, validate_governance_time_interval, validate_source_provenance,
21 },
22};
23#[cfg(feature = "nns-host")]
24use crate::{nns::LiveNnsSource, runtime::block_on_current_thread};
25use serde::{Deserialize, Serialize};
26use std::fmt;
27
28pub const NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
30
31#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum NnsProposalCollectionStatus {
40 Ready,
42 Collecting,
44 Complete,
46 PageLimitReached,
48}
49
50impl NnsProposalCollectionStatus {
51 #[must_use]
53 pub const fn as_str(self) -> &'static str {
54 match self {
55 Self::Ready => "ready",
56 Self::Collecting => "collecting",
57 Self::Complete => "complete",
58 Self::PageLimitReached => "page_limit_reached",
59 }
60 }
61}
62
63impl fmt::Display for NnsProposalCollectionStatus {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 formatter.write_str(self.as_str())
66 }
67}
68
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
76pub struct NnsProposalCollectionState {
77 schema_version: u32,
78 network: String,
79 governance_canister_id: String,
80 requested_source: NnsGovernanceSourceSelection,
81 source: Option<NnsGovernanceSourceProvenance>,
82 page_size: u32,
83 max_pages: u32,
84 pages_fetched: u32,
85 proposals_fetched: u64,
86 next_before_proposal_id: Option<u64>,
87 started_at: String,
88 updated_at: String,
89 status: NnsProposalCollectionStatus,
90}
91
92impl NnsProposalCollectionState {
93 pub fn new(
95 request: &NnsGovernanceRequest,
96 page_size: u32,
97 max_pages: u32,
98 ) -> Result<Self, NnsProposalError> {
99 validate_governance_request(request)?;
100 validate_proposal_page_size(page_size)?;
101 if max_pages == 0 {
102 return Err(NnsProposalError::InvalidCollectionMaxPages);
103 }
104 Ok(Self {
105 schema_version: NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION,
106 network: request.network.clone(),
107 governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
108 requested_source: request.source.clone(),
109 source: None,
110 page_size,
111 max_pages,
112 pages_fetched: 0,
113 proposals_fetched: 0,
114 next_before_proposal_id: None,
115 started_at: request.fetched_at.clone(),
116 updated_at: request.fetched_at.clone(),
117 status: NnsProposalCollectionStatus::Ready,
118 })
119 }
120
121 #[must_use]
123 pub const fn schema_version(&self) -> u32 {
124 self.schema_version
125 }
126
127 #[must_use]
129 pub fn network(&self) -> &str {
130 &self.network
131 }
132
133 #[must_use]
135 pub fn governance_canister_id(&self) -> &str {
136 &self.governance_canister_id
137 }
138
139 #[must_use]
141 pub const fn requested_source(&self) -> &NnsGovernanceSourceSelection {
142 &self.requested_source
143 }
144
145 #[must_use]
147 pub const fn source(&self) -> Option<&NnsGovernanceSourceProvenance> {
148 self.source.as_ref()
149 }
150
151 #[must_use]
153 pub const fn page_size(&self) -> u32 {
154 self.page_size
155 }
156
157 #[must_use]
159 pub const fn max_pages(&self) -> u32 {
160 self.max_pages
161 }
162
163 #[must_use]
165 pub const fn pages_fetched(&self) -> u32 {
166 self.pages_fetched
167 }
168
169 #[must_use]
171 pub const fn proposals_fetched(&self) -> u64 {
172 self.proposals_fetched
173 }
174
175 #[must_use]
177 pub const fn next_before_proposal_id(&self) -> Option<u64> {
178 self.next_before_proposal_id
179 }
180
181 #[must_use]
183 pub fn started_at(&self) -> &str {
184 &self.started_at
185 }
186
187 #[must_use]
189 pub fn updated_at(&self) -> &str {
190 &self.updated_at
191 }
192
193 #[must_use]
195 pub const fn status(&self) -> NnsProposalCollectionStatus {
196 self.status
197 }
198
199 #[must_use]
201 pub const fn is_complete(&self) -> bool {
202 matches!(self.status, NnsProposalCollectionStatus::Complete)
203 }
204}
205
206#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
213pub struct NnsProposalCollectionStep {
214 pub page: NnsProposalListReport,
216 pub state: NnsProposalCollectionState,
218}
219
220#[cfg(feature = "nns-host")]
222pub fn advance_nns_proposal_collection(
223 request: &NnsGovernanceRequest,
224 state: &NnsProposalCollectionState,
225) -> Result<NnsProposalCollectionStep, NnsProposalHostError> {
226 Ok(block_on_current_thread(
227 advance_nns_proposal_collection_with_source(request, state, &LiveNnsSource),
228 )??)
229}
230
231pub async fn advance_nns_proposal_collection_with_source(
233 request: &NnsGovernanceRequest,
234 state: &NnsProposalCollectionState,
235 source: &dyn NnsProposalSource,
236) -> Result<NnsProposalCollectionStep, NnsProposalError> {
237 validate_governance_request(request)?;
238 validate_collection_state(state)?;
239 validate_continuation_request(request, state)?;
240 validate_governance_time_interval(
241 "updated_at",
242 &state.updated_at,
243 "fetched_at",
244 &request.fetched_at,
245 )?;
246 match state.status {
247 NnsProposalCollectionStatus::Complete => {
248 return Err(NnsProposalError::CollectionComplete {
249 pages_fetched: state.pages_fetched,
250 });
251 }
252 NnsProposalCollectionStatus::PageLimitReached => {
253 return Err(NnsProposalError::CollectionPageLimitReached {
254 pages_fetched: state.pages_fetched,
255 max_pages: state.max_pages,
256 });
257 }
258 NnsProposalCollectionStatus::Ready | NnsProposalCollectionStatus::Collecting => {}
259 }
260
261 let mut page_request = NnsProposalListRequest::new(request.clone(), state.page_size);
262 page_request.before_proposal_id = state.next_before_proposal_id;
263 let page = build_nns_proposal_list_report_with_source(&page_request, source).await?;
264 if let Some(expected) = &state.source
265 && *expected != page.context.source
266 {
267 return Err(NnsProposalError::CollectionSourceChanged {
268 expected: expected.clone(),
269 actual: page.context.source,
270 });
271 }
272
273 let page_count = u32::try_from(page.proposal_count)
274 .map_err(|_| NnsProposalError::CollectionAccountingOverflow)?;
275 let page_row_count = u64::try_from(page.proposal_count)
276 .map_err(|_| NnsProposalError::CollectionAccountingOverflow)?;
277 let pages_fetched = state
278 .pages_fetched
279 .checked_add(1)
280 .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
281 let proposals_fetched = state
282 .proposals_fetched
283 .checked_add(page_row_count)
284 .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
285 let next_before_proposal_id = (page_count == state.page_size)
286 .then(|| {
287 page.proposals
288 .iter()
289 .filter_map(|proposal| proposal.proposal_id)
290 .min()
291 .filter(|proposal_id| *proposal_id > 1)
292 })
293 .flatten();
294 let status = if next_before_proposal_id.is_none() {
295 NnsProposalCollectionStatus::Complete
296 } else if pages_fetched == state.max_pages {
297 NnsProposalCollectionStatus::PageLimitReached
298 } else {
299 NnsProposalCollectionStatus::Collecting
300 };
301 let next_state = NnsProposalCollectionState {
302 source: Some(page.context.source.clone()),
303 pages_fetched,
304 proposals_fetched,
305 next_before_proposal_id,
306 updated_at: request.fetched_at.clone(),
307 status,
308 ..state.clone()
309 };
310 validate_collection_state(&next_state)?;
311 Ok(NnsProposalCollectionStep {
312 page,
313 state: next_state,
314 })
315}
316
317fn validate_continuation_request(
318 request: &NnsGovernanceRequest,
319 state: &NnsProposalCollectionState,
320) -> Result<(), NnsProposalError> {
321 if request.network != state.network {
322 return Err(NnsProposalError::CollectionRequestMismatch {
323 field: "network",
324 expected: state.network.clone(),
325 actual: request.network.clone(),
326 });
327 }
328 if request.source != state.requested_source {
329 return Err(NnsProposalError::CollectionRequestMismatch {
330 field: "requested_source",
331 expected: format!("{:?}", state.requested_source),
332 actual: format!("{:?}", request.source),
333 });
334 }
335 Ok(())
336}
337
338pub(super) fn validate_collection_state(
339 state: &NnsProposalCollectionState,
340) -> Result<(), NnsProposalError> {
341 let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
342 if state.schema_version != NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION {
343 return Err(invalid(format!(
344 "schema_version is {}, expected {NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION}",
345 state.schema_version
346 )));
347 }
348 if state.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
349 return Err(invalid(format!(
350 "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
351 state.governance_canister_id
352 )));
353 }
354 let state_request = NnsGovernanceRequest {
355 network: state.network.clone(),
356 fetched_at: state.started_at.clone(),
357 source: state.requested_source.clone(),
358 };
359 validate_governance_request(&state_request)?;
360 validate_governance_time_interval(
361 "started_at",
362 &state.started_at,
363 "updated_at",
364 &state.updated_at,
365 )?;
366 validate_proposal_page_size(state.page_size)?;
367 if state.max_pages == 0 {
368 return Err(invalid("max_pages must be greater than zero".to_string()));
369 }
370 if state.pages_fetched > state.max_pages {
371 return Err(invalid(format!(
372 "pages_fetched {} exceeds max_pages {}",
373 state.pages_fetched, state.max_pages
374 )));
375 }
376 if let Some(source) = &state.source {
377 validate_source_provenance(&state.requested_source, source)?;
378 }
379
380 let page_size = u64::from(state.page_size);
381 let pages_fetched = u64::from(state.pages_fetched);
382 let proposals_fetched = state.proposals_fetched;
383 let maximum_rows = pages_fetched
384 .checked_mul(page_size)
385 .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
386 let minimum_rows = pages_fetched
387 .saturating_sub(1)
388 .checked_mul(page_size)
389 .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
390 if proposals_fetched < minimum_rows || proposals_fetched > maximum_rows {
391 return Err(invalid(format!(
392 "proposals_fetched {} is outside {}..={} for {} pages of size {}",
393 state.proposals_fetched,
394 minimum_rows,
395 maximum_rows,
396 state.pages_fetched,
397 state.page_size
398 )));
399 }
400 if state
401 .next_before_proposal_id
402 .is_some_and(|cursor| cursor <= 1)
403 {
404 return Err(invalid(
405 "next_before_proposal_id must be greater than one".to_string(),
406 ));
407 }
408
409 let valid_lifecycle = match state.status {
410 NnsProposalCollectionStatus::Ready => {
411 state.pages_fetched == 0
412 && state.proposals_fetched == 0
413 && state.next_before_proposal_id.is_none()
414 && state.source.is_none()
415 }
416 NnsProposalCollectionStatus::Collecting => {
417 state.pages_fetched > 0
418 && state.pages_fetched < state.max_pages
419 && proposals_fetched == maximum_rows
420 && state.next_before_proposal_id.is_some()
421 && state.source.is_some()
422 }
423 NnsProposalCollectionStatus::Complete => {
424 state.pages_fetched > 0
425 && state.next_before_proposal_id.is_none()
426 && state.source.is_some()
427 }
428 NnsProposalCollectionStatus::PageLimitReached => {
429 state.pages_fetched == state.max_pages
430 && proposals_fetched == maximum_rows
431 && state.next_before_proposal_id.is_some()
432 && state.source.is_some()
433 }
434 };
435 if !valid_lifecycle {
436 return Err(invalid(format!(
437 "status {} disagrees with cursor, provenance, or counters",
438 state.status
439 )));
440 }
441 Ok(())
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::nns::governance::NnsGovernanceError;
448
449 struct UnexpectedSource;
450 impl NnsProposalSource for UnexpectedSource {
451 fn fetch_proposals<'a>(
452 &'a self,
453 _: &'a NnsGovernanceRequest,
454 _: u32,
455 _: Option<u64>,
456 _: crate::nns::proposals::NnsProposalStatusFilter,
457 _: crate::nns::proposals::NnsProposalRewardStatusFilter,
458 ) -> crate::nns::proposals::NnsProposalSourceFuture<
459 'a,
460 Vec<crate::nns::proposals::NnsProposalRow>,
461 > {
462 panic!("unexpected source call")
463 }
464 fn fetch_proposal<'a>(
465 &'a self,
466 _: &'a NnsGovernanceRequest,
467 _: u64,
468 ) -> crate::nns::proposals::NnsProposalSourceFuture<'a, crate::nns::proposals::NnsProposalRow>
469 {
470 panic!("unexpected source call")
471 }
472 }
473
474 #[test]
475 fn collection_rejects_backward_continuations_and_restored_intervals() {
476 let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
477 let state = NnsProposalCollectionState::new(&start, 2, 3).unwrap();
478 let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
479 let mut future = std::pin::pin!(advance_nns_proposal_collection_with_source(
480 &before,
481 &state,
482 &UnexpectedSource
483 ));
484 let mut context = std::task::Context::from_waker(std::task::Waker::noop());
485 let result = std::future::Future::poll(future.as_mut(), &mut context);
486 assert!(matches!(
487 result,
488 std::task::Poll::Ready(Err(NnsProposalError::Governance(
489 NnsGovernanceError::InvalidTimestampOrder { .. }
490 )))
491 ));
492 let mut value = serde_json::to_value(&state).unwrap();
493 value["updated_at"] = serde_json::json!(before.fetched_at);
494 let restored = serde_json::from_value(value).unwrap();
495 assert!(matches!(
496 validate_collection_state(&restored),
497 Err(NnsProposalError::Governance(
498 NnsGovernanceError::InvalidTimestampOrder { .. }
499 ))
500 ));
501 assert!(
502 validate_governance_time_interval(
503 "updated_at",
504 &state.updated_at,
505 "fetched_at",
506 &start.fetched_at
507 )
508 .is_ok()
509 );
510 }
511
512 #[test]
513 fn collection_validates_request_and_restored_timestamps() {
514 let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
515 "ic",
516 1_700_000_000,
517 );
518 let valid_state = NnsProposalCollectionState::new(&request, 2, 3).expect("valid state");
519 for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
520 request.fetched_at = timestamp.to_string();
521 assert!(matches!(
522 NnsProposalCollectionState::new(&request, 2, 3),
523 Err(NnsProposalError::Governance(
524 NnsGovernanceError::InvalidTimestamp {
525 field: "fetched_at",
526 ..
527 }
528 ))
529 ));
530 for field in ["started_at", "updated_at"] {
531 let mut value = serde_json::to_value(&valid_state).expect("serialize state");
532 value[field] = serde_json::json!(timestamp);
533 let state = serde_json::from_value(value).expect("restore caller state");
534 assert!(matches!(
535 validate_collection_state(&state),
536 Err(NnsProposalError::Governance(
537 NnsGovernanceError::InvalidTimestamp { value, .. }
538 )) if value == timestamp
539 ));
540 }
541 }
542 }
543}