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 pages_fetched = state
238 .pages_fetched
239 .checked_add(1)
240 .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
241 let proposals_fetched = state
242 .proposals_fetched
243 .checked_add(u64::from(page_count))
244 .ok_or(NnsProposalError::CollectionAccountingOverflow)?;
245 let next_before_proposal_id = (page_count == state.page_size)
246 .then(|| {
247 page.proposals
248 .iter()
249 .filter_map(|proposal| proposal.proposal_id)
250 .min()
251 .filter(|proposal_id| *proposal_id > 1)
252 })
253 .flatten();
254 let status = if next_before_proposal_id.is_none() {
255 NnsGovernanceCollectionStatus::Complete
256 } else if pages_fetched == state.max_pages {
257 NnsGovernanceCollectionStatus::PageLimitReached
258 } else {
259 NnsGovernanceCollectionStatus::Collecting
260 };
261 let next_state = NnsProposalCollectionState {
262 source: Some(page.context.source.clone()),
263 pages_fetched,
264 proposals_fetched,
265 next_before_proposal_id,
266 updated_at: request.fetched_at.clone(),
267 status,
268 ..state.clone()
269 };
270 Ok(NnsProposalCollectionStep {
271 page,
272 state: next_state,
273 })
274}
275
276fn validate_continuation_request(
277 request: &NnsGovernanceRequest,
278 state: &NnsProposalCollectionState,
279) -> Result<(), NnsProposalError> {
280 if request.network != state.network {
281 return Err(NnsProposalError::CollectionRequestMismatch {
282 field: "network",
283 expected: state.network.clone(),
284 actual: request.network.clone(),
285 });
286 }
287 if request.source != state.requested_source {
288 return Err(NnsProposalError::CollectionRequestMismatch {
289 field: "requested_source",
290 expected: format!("{:?}", state.requested_source),
291 actual: format!("{:?}", request.source),
292 });
293 }
294 Ok(())
295}
296
297pub(super) fn validate_collection_state(
298 state: &NnsProposalCollectionState,
299) -> Result<(), NnsProposalError> {
300 let invalid = |reason| NnsProposalError::InvalidCollectionState { reason };
301 if state.schema_version != NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION {
302 return Err(invalid(format!(
303 "schema_version is {}, expected {NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION}",
304 state.schema_version
305 )));
306 }
307 if state.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
308 return Err(invalid(format!(
309 "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
310 state.governance_canister_id
311 )));
312 }
313 let state_request = NnsGovernanceRequest {
314 network: state.network.clone(),
315 fetched_at: state.started_at.clone(),
316 source: state.requested_source.clone(),
317 };
318 validate_governance_request(&state_request)?;
319 validate_governance_time_interval(
320 "started_at",
321 &state.started_at,
322 "updated_at",
323 &state.updated_at,
324 )?;
325 validate_proposal_page_size(state.page_size)?;
326 if state.max_pages == 0 {
327 return Err(invalid("max_pages must be greater than zero".to_string()));
328 }
329 if state.pages_fetched > state.max_pages {
330 return Err(invalid(format!(
331 "pages_fetched {} exceeds max_pages {}",
332 state.pages_fetched, state.max_pages
333 )));
334 }
335 if let Some(source) = &state.source {
336 validate_source_provenance(&state.requested_source, source)?;
337 }
338
339 let page_size = u64::from(state.page_size);
340 let pages_fetched = u64::from(state.pages_fetched);
341 let proposals_fetched = state.proposals_fetched;
342 let maximum_rows = pages_fetched * page_size;
344 let minimum_rows = pages_fetched.saturating_sub(1) * page_size;
345 if proposals_fetched < minimum_rows || proposals_fetched > maximum_rows {
346 return Err(invalid(format!(
347 "proposals_fetched {} is outside {}..={} for {} pages of size {}",
348 state.proposals_fetched,
349 minimum_rows,
350 maximum_rows,
351 state.pages_fetched,
352 state.page_size
353 )));
354 }
355 if state
356 .next_before_proposal_id
357 .is_some_and(|cursor| cursor <= 1)
358 {
359 return Err(invalid(
360 "next_before_proposal_id must be greater than one".to_string(),
361 ));
362 }
363
364 let valid_lifecycle = match state.status {
365 NnsGovernanceCollectionStatus::Ready => {
366 state.pages_fetched == 0
367 && state.proposals_fetched == 0
368 && state.next_before_proposal_id.is_none()
369 && state.source.is_none()
370 }
371 NnsGovernanceCollectionStatus::Collecting => {
372 state.pages_fetched > 0
373 && state.pages_fetched < state.max_pages
374 && proposals_fetched == maximum_rows
375 && state.next_before_proposal_id.is_some()
376 && state.source.is_some()
377 }
378 NnsGovernanceCollectionStatus::Complete => {
379 state.pages_fetched > 0
380 && state.next_before_proposal_id.is_none()
381 && state.source.is_some()
382 }
383 NnsGovernanceCollectionStatus::PageLimitReached => {
384 state.pages_fetched == state.max_pages
385 && proposals_fetched == maximum_rows
386 && state.next_before_proposal_id.is_some()
387 && state.source.is_some()
388 }
389 };
390 if !valid_lifecycle {
391 return Err(invalid(format!(
392 "status {} disagrees with cursor, provenance, or counters",
393 state.status
394 )));
395 }
396 Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::nns::governance::NnsGovernanceError;
403
404 struct UnexpectedSource;
405 impl NnsProposalSource for UnexpectedSource {
406 fn fetch_proposals<'a>(
407 &'a self,
408 _: &'a NnsGovernanceRequest,
409 _: u32,
410 _: Option<u64>,
411 _: crate::nns::proposals::NnsProposalStatusFilter,
412 _: crate::nns::proposals::NnsProposalRewardStatusFilter,
413 ) -> crate::nns::proposals::NnsProposalSourceFuture<
414 'a,
415 Vec<crate::nns::proposals::NnsProposalRow>,
416 > {
417 panic!("unexpected source call")
418 }
419 fn fetch_proposal<'a>(
420 &'a self,
421 _: &'a NnsGovernanceRequest,
422 _: u64,
423 ) -> crate::nns::proposals::NnsProposalSourceFuture<'a, crate::nns::proposals::NnsProposalRow>
424 {
425 panic!("unexpected source call")
426 }
427 }
428
429 #[test]
430 fn collection_rejects_backward_continuations_and_restored_intervals() {
431 let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
432 let state = NnsProposalCollectionState::new(&start, 2, 3).unwrap();
433 let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
434 let mut future = std::pin::pin!(advance_nns_proposal_collection_with_source(
435 &before,
436 &state,
437 &UnexpectedSource
438 ));
439 let mut context = std::task::Context::from_waker(std::task::Waker::noop());
440 let result = std::future::Future::poll(future.as_mut(), &mut context);
441 assert!(matches!(
442 result,
443 std::task::Poll::Ready(Err(NnsProposalError::Governance(
444 NnsGovernanceError::InvalidTimestampOrder { .. }
445 )))
446 ));
447 let mut value = serde_json::to_value(&state).unwrap();
448 value["updated_at"] = serde_json::json!(before.fetched_at);
449 let restored = serde_json::from_value(value).unwrap();
450 assert!(matches!(
451 validate_collection_state(&restored),
452 Err(NnsProposalError::Governance(
453 NnsGovernanceError::InvalidTimestampOrder { .. }
454 ))
455 ));
456 assert!(
457 validate_governance_time_interval(
458 "updated_at",
459 &state.updated_at,
460 "fetched_at",
461 &start.fetched_at
462 )
463 .is_ok()
464 );
465 }
466
467 #[test]
468 fn collection_validates_request_and_restored_timestamps() {
469 let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
470 "ic",
471 1_700_000_000,
472 );
473 let valid_state = NnsProposalCollectionState::new(&request, 2, 3).expect("valid state");
474 for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
475 request.fetched_at = timestamp.to_string();
476 assert!(matches!(
477 NnsProposalCollectionState::new(&request, 2, 3),
478 Err(NnsProposalError::Governance(
479 NnsGovernanceError::InvalidTimestamp {
480 field: "fetched_at",
481 ..
482 }
483 ))
484 ));
485 for field in ["started_at", "updated_at"] {
486 let mut value = serde_json::to_value(&valid_state).expect("serialize state");
487 value[field] = serde_json::json!(timestamp);
488 let state = serde_json::from_value(value).expect("restore caller state");
489 assert!(matches!(
490 validate_collection_state(&state),
491 Err(NnsProposalError::Governance(
492 NnsGovernanceError::InvalidTimestamp { value, .. }
493 )) if value == timestamp
494 ));
495 }
496 }
497 }
498}