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