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};
14#[cfg(feature = "nns-host")]
15use crate::nns::LiveNnsSource;
16use crate::nns::{
17 MAINNET_GOVERNANCE_CANISTER_ID,
18 governance::{
19 NnsGovernanceCollectionStatus, NnsGovernanceRequest, NnsGovernanceSourceProvenance,
20 NnsGovernanceSourceSelection, validate_governance_collection_identity,
21 validate_governance_collection_page_bounds, validate_governance_continuation_request,
22 validate_governance_request, validate_governance_time_interval, validate_source_provenance,
23 },
24};
25use serde::{Deserialize, Serialize};
26
27pub const NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION: u32 = 1;
29
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
37pub struct NnsNeuronCollectionState {
38 schema_version: u32,
39 network: String,
40 governance_canister_id: String,
41 requested_source: NnsGovernanceSourceSelection,
42 source: Option<NnsGovernanceSourceProvenance>,
43 page_size: u32,
44 max_pages: u32,
45 pages_fetched: u32,
46 neurons_fetched: u64,
47 next_start_neuron_id: Option<u64>,
48 started_at: String,
49 updated_at: String,
50 status: NnsGovernanceCollectionStatus,
51}
52
53impl NnsNeuronCollectionState {
54 pub fn new(
56 request: &NnsGovernanceRequest,
57 page_size: u32,
58 max_pages: u32,
59 ) -> Result<Self, NnsNeuronError> {
60 validate_governance_request(request)?;
61 validate_page_size(page_size)?;
62 if max_pages == 0 {
63 return Err(NnsNeuronError::InvalidCollectionMaxPages);
64 }
65 Ok(Self {
66 schema_version: NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION,
67 network: request.network.clone(),
68 governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
69 requested_source: request.source.clone(),
70 source: None,
71 page_size,
72 max_pages,
73 pages_fetched: 0,
74 neurons_fetched: 0,
75 next_start_neuron_id: None,
76 started_at: request.fetched_at.clone(),
77 updated_at: request.fetched_at.clone(),
78 status: NnsGovernanceCollectionStatus::Ready,
79 })
80 }
81
82 #[must_use]
84 pub const fn schema_version(&self) -> u32 {
85 self.schema_version
86 }
87
88 #[must_use]
90 pub fn network(&self) -> &str {
91 &self.network
92 }
93
94 #[must_use]
96 pub fn governance_canister_id(&self) -> &str {
97 &self.governance_canister_id
98 }
99
100 #[must_use]
102 pub const fn requested_source(&self) -> &NnsGovernanceSourceSelection {
103 &self.requested_source
104 }
105
106 #[must_use]
108 pub const fn source(&self) -> Option<&NnsGovernanceSourceProvenance> {
109 self.source.as_ref()
110 }
111
112 #[must_use]
114 pub const fn page_size(&self) -> u32 {
115 self.page_size
116 }
117
118 #[must_use]
120 pub const fn max_pages(&self) -> u32 {
121 self.max_pages
122 }
123
124 #[must_use]
126 pub const fn pages_fetched(&self) -> u32 {
127 self.pages_fetched
128 }
129
130 #[must_use]
132 pub const fn neurons_fetched(&self) -> u64 {
133 self.neurons_fetched
134 }
135
136 #[must_use]
138 pub const fn next_start_neuron_id(&self) -> Option<u64> {
139 self.next_start_neuron_id
140 }
141
142 #[must_use]
144 pub fn started_at(&self) -> &str {
145 &self.started_at
146 }
147
148 #[must_use]
150 pub fn updated_at(&self) -> &str {
151 &self.updated_at
152 }
153
154 #[must_use]
156 pub const fn status(&self) -> NnsGovernanceCollectionStatus {
157 self.status
158 }
159
160 #[must_use]
162 pub const fn is_complete(&self) -> bool {
163 matches!(self.status, NnsGovernanceCollectionStatus::Complete)
164 }
165}
166
167#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
174pub struct NnsNeuronCollectionStep {
175 pub page: NnsNeuronListReport,
177 pub state: NnsNeuronCollectionState,
179}
180
181#[cfg(feature = "nns-host")]
183pub async fn advance_nns_neuron_collection(
184 request: &NnsGovernanceRequest,
185 state: &NnsNeuronCollectionState,
186) -> Result<NnsNeuronCollectionStep, NnsNeuronHostError> {
187 Ok(
188 advance_nns_neuron_collection_with_source(request, state, &LiveNnsSource::default())
189 .await?,
190 )
191}
192
193pub async fn advance_nns_neuron_collection_with_source(
195 request: &NnsGovernanceRequest,
196 state: &NnsNeuronCollectionState,
197 source: &dyn NnsNeuronSource,
198) -> Result<NnsNeuronCollectionStep, NnsNeuronError> {
199 validate_governance_request(request)?;
200 validate_collection_state(state)?;
201 validate_governance_continuation_request(
202 request,
203 &state.network,
204 &state.requested_source,
205 |field, expected, actual| NnsNeuronError::CollectionRequestMismatch {
206 field,
207 expected,
208 actual,
209 },
210 )?;
211 validate_governance_time_interval(
212 "updated_at",
213 &state.updated_at,
214 "fetched_at",
215 &request.fetched_at,
216 )?;
217 match state.status {
218 NnsGovernanceCollectionStatus::Complete => {
219 return Err(NnsNeuronError::CollectionComplete {
220 pages_fetched: state.pages_fetched,
221 });
222 }
223 NnsGovernanceCollectionStatus::PageLimitReached => {
224 return Err(NnsNeuronError::CollectionPageLimitReached {
225 pages_fetched: state.pages_fetched,
226 max_pages: state.max_pages,
227 });
228 }
229 NnsGovernanceCollectionStatus::Ready | NnsGovernanceCollectionStatus::Collecting => {}
230 }
231
232 let mut page_request = NnsNeuronListRequest::new(request.clone(), state.page_size);
233 page_request.exclusive_start_neuron_id = state.next_start_neuron_id;
234 let page = build_nns_neuron_list_report_with_source(&page_request, source).await?;
235 if let Some(expected) = &state.source
236 && *expected != page.context.source
237 {
238 return Err(NnsNeuronError::CollectionSourceChanged {
239 expected: expected.clone(),
240 actual: page.context.source,
241 });
242 }
243
244 let pages_fetched = state
245 .pages_fetched
246 .checked_add(1)
247 .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
248 let page_row_count = u64::try_from(page.returned_neuron_count)
249 .map_err(|_| NnsNeuronError::CollectionAccountingOverflow)?;
250 let neurons_fetched = state
251 .neurons_fetched
252 .checked_add(page_row_count)
253 .ok_or(NnsNeuronError::CollectionAccountingOverflow)?;
254 let next_start_neuron_id = page.next_start_neuron_id;
255 let status = if next_start_neuron_id.is_none() {
256 NnsGovernanceCollectionStatus::Complete
257 } else if pages_fetched == state.max_pages {
258 NnsGovernanceCollectionStatus::PageLimitReached
259 } else {
260 NnsGovernanceCollectionStatus::Collecting
261 };
262 let next_state = NnsNeuronCollectionState {
263 source: Some(page.context.source.clone()),
264 pages_fetched,
265 neurons_fetched,
266 next_start_neuron_id,
267 updated_at: request.fetched_at.clone(),
268 status,
269 ..state.clone()
270 };
271 Ok(NnsNeuronCollectionStep {
272 page,
273 state: next_state,
274 })
275}
276
277pub(super) fn validate_collection_state(
278 state: &NnsNeuronCollectionState,
279) -> Result<(), NnsNeuronError> {
280 let invalid = |reason| NnsNeuronError::InvalidCollectionState { reason };
281 if state.schema_version != NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION {
282 return Err(invalid(format!(
283 "schema_version is {}, expected {NNS_NEURON_COLLECTION_STATE_SCHEMA_VERSION}",
284 state.schema_version
285 )));
286 }
287 validate_governance_collection_identity(
288 &state.network,
289 &state.governance_canister_id,
290 &state.requested_source,
291 (&state.started_at, &state.updated_at),
292 invalid,
293 )?;
294 validate_page_size(state.page_size)?;
295 validate_governance_collection_page_bounds(state.max_pages, state.pages_fetched, invalid)?;
296 if let Some(source) = &state.source {
297 validate_source_provenance(&state.requested_source, source)?;
298 }
299
300 let page_size = u64::from(state.page_size);
301 let pages_fetched = u64::from(state.pages_fetched);
302 let neurons_fetched = state.neurons_fetched;
303 let maximum_rows = pages_fetched * page_size;
305 let minimum_rows = pages_fetched.saturating_sub(1) * page_size;
306 if neurons_fetched < minimum_rows || neurons_fetched > maximum_rows {
307 return Err(invalid(format!(
308 "neurons_fetched {} is outside {}..={} for {} pages of size {}",
309 state.neurons_fetched, minimum_rows, maximum_rows, state.pages_fetched, state.page_size
310 )));
311 }
312
313 let valid_lifecycle = match state.status {
314 NnsGovernanceCollectionStatus::Ready => {
315 state.pages_fetched == 0
316 && state.neurons_fetched == 0
317 && state.next_start_neuron_id.is_none()
318 && state.source.is_none()
319 }
320 NnsGovernanceCollectionStatus::Collecting => {
321 state.pages_fetched > 0
322 && state.pages_fetched < state.max_pages
323 && neurons_fetched == maximum_rows
324 && state.next_start_neuron_id.is_some()
325 && state.source.is_some()
326 }
327 NnsGovernanceCollectionStatus::Complete => {
328 state.pages_fetched > 0
329 && neurons_fetched < maximum_rows
330 && state.next_start_neuron_id.is_none()
331 && state.source.is_some()
332 }
333 NnsGovernanceCollectionStatus::PageLimitReached => {
334 state.pages_fetched == state.max_pages
335 && neurons_fetched == maximum_rows
336 && state.next_start_neuron_id.is_some()
337 && state.source.is_some()
338 }
339 };
340 if !valid_lifecycle {
341 return Err(invalid(format!(
342 "status {} disagrees with cursor, provenance, or counters",
343 state.status
344 )));
345 }
346 Ok(())
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::nns::governance::NnsGovernanceError;
353
354 struct UnexpectedSource;
355 impl NnsNeuronSource for UnexpectedSource {
356 fn fetch_neuron_page<'a>(
357 &'a self,
358 _: &'a NnsGovernanceRequest,
359 _: Option<u64>,
360 _: u32,
361 ) -> crate::nns::neuron::NnsNeuronSourceFuture<'a, crate::nns::neuron::NnsNeuronPage>
362 {
363 panic!("unexpected source call")
364 }
365 fn fetch_neuron<'a>(
366 &'a self,
367 _: &'a NnsGovernanceRequest,
368 _: u64,
369 ) -> crate::nns::neuron::NnsNeuronSourceFuture<'a, crate::nns::neuron::NnsNeuronRow>
370 {
371 panic!("unexpected source call")
372 }
373 }
374
375 #[tokio::test(flavor = "current_thread")]
376 async fn collection_rejects_backward_continuations_and_restored_intervals() {
377 let start = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 100);
378 let state = NnsNeuronCollectionState::new(&start, 2, 3).unwrap();
379 let before = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 99);
380 let mut future = std::pin::pin!(advance_nns_neuron_collection_with_source(
381 &before,
382 &state,
383 &UnexpectedSource
384 ));
385 let mut context = std::task::Context::from_waker(std::task::Waker::noop());
386 let result = std::future::Future::poll(future.as_mut(), &mut context);
387 assert!(matches!(
388 result,
389 std::task::Poll::Ready(Err(NnsNeuronError::Governance(
390 NnsGovernanceError::InvalidTimestampOrder { .. }
391 )))
392 ));
393 let mut value = serde_json::to_value(&state).unwrap();
394 value["updated_at"] = serde_json::json!(before.fetched_at);
395 let restored = serde_json::from_value(value).unwrap();
396 assert!(matches!(
397 validate_collection_state(&restored),
398 Err(NnsNeuronError::Governance(
399 NnsGovernanceError::InvalidTimestampOrder { .. }
400 ))
401 ));
402 assert!(
403 validate_governance_time_interval(
404 "updated_at",
405 &state.updated_at,
406 "fetched_at",
407 &start.fetched_at
408 )
409 .is_ok()
410 );
411 }
412
413 #[test]
414 fn collection_validates_request_and_restored_timestamps() {
415 let mut request = NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs(
416 "ic",
417 1_700_000_000,
418 );
419 let valid_state = NnsNeuronCollectionState::new(&request, 2, 3).expect("valid state");
420 for (field, value) in [
421 ("governance_canister_id", serde_json::json!("aaaaa-aa")),
422 ("max_pages", serde_json::json!(0)),
423 ("pages_fetched", serde_json::json!(4)),
424 ] {
425 let mut restored = serde_json::to_value(&valid_state).unwrap();
426 restored[field] = value;
427 let restored = serde_json::from_value(restored).unwrap();
428 assert!(
429 matches!(
430 validate_collection_state(&restored),
431 Err(NnsNeuronError::InvalidCollectionState { .. })
432 ),
433 "invalid restored {field}"
434 );
435 }
436 for timestamp in ["not a UTC timestamp", "+2023-11-14T22:13:20Z"] {
437 request.fetched_at = timestamp.to_string();
438 assert!(matches!(
439 NnsNeuronCollectionState::new(&request, 2, 3),
440 Err(NnsNeuronError::Governance(
441 NnsGovernanceError::InvalidTimestamp {
442 field: "fetched_at",
443 ..
444 }
445 ))
446 ));
447 for field in ["started_at", "updated_at"] {
448 let mut value = serde_json::to_value(&valid_state).expect("serialize state");
449 value[field] = serde_json::json!(timestamp);
450 let state = serde_json::from_value(value).expect("restore caller state");
451 assert!(matches!(
452 validate_collection_state(&state),
453 Err(NnsNeuronError::Governance(
454 NnsGovernanceError::InvalidTimestamp { value, .. }
455 )) if value == timestamp
456 ));
457 }
458 }
459 }
460}