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