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