1use super::{
8 NnsNeuronCollectionState, collection::validate_collection_state, model::NnsNeuronRow,
9 source::validate_neuron_rows,
10};
11use crate::{
12 nns::{
13 MAINNET_GOVERNANCE_CANISTER_ID,
14 governance::{
15 NnsGovernanceCollectionStatus, NnsGovernanceSourceProvenance,
16 validate_governance_report_source, validate_governance_time_interval,
17 },
18 },
19 subnet_catalog::MAINNET_NETWORK,
20};
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23use thiserror::Error as ThisError;
24
25pub const NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION: u32 = 1;
27
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35#[serde(deny_unknown_fields)]
36pub struct NnsNeuronStateDistribution {
37 pub state: i32,
39 pub neuron_count: u64,
41 pub effective_stake_e8s: u64,
43}
44
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(deny_unknown_fields)]
53pub struct NnsNeuronVisibilityDistribution {
54 pub visibility: Option<i32>,
56 pub neuron_count: u64,
58 pub effective_stake_e8s: u64,
60}
61
62#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
69#[serde(deny_unknown_fields)]
70pub struct NnsNeuronTypeDistribution {
71 pub neuron_type: Option<i32>,
73 pub neuron_count: u64,
75 pub effective_stake_e8s: u64,
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
86pub struct NnsNeuronDistributionReport {
87 pub schema_version: u32,
89 pub network: String,
91 pub governance_canister_id: String,
93 pub source: NnsGovernanceSourceProvenance,
95 pub collection_started_at: String,
97 pub collection_updated_at: String,
99 pub collection_page_count: u32,
101 pub collected_neuron_count: u64,
103 pub point_in_time_guaranteed: bool,
105 pub earliest_retrieved_at_timestamp_seconds: Option<u64>,
107 pub latest_retrieved_at_timestamp_seconds: Option<u64>,
109 pub total_effective_stake_e8s: u64,
111 pub reported_staked_maturity_neuron_count: u64,
113 pub unreported_staked_maturity_neuron_count: u64,
115 pub total_reported_staked_maturity_e8s_equivalent: u64,
117 pub reported_deciding_voting_power_neuron_count: u64,
119 pub unreported_deciding_voting_power_neuron_count: u64,
121 pub total_reported_deciding_voting_power: u64,
123 pub reported_potential_voting_power_neuron_count: u64,
125 pub unreported_potential_voting_power_neuron_count: u64,
127 pub total_reported_potential_voting_power: u64,
129 pub known_neuron_metadata_count: u64,
131 pub neurons_fund_join_timestamp_present_count: u64,
133 pub state_distribution: Vec<NnsNeuronStateDistribution>,
135 pub visibility_distribution: Vec<NnsNeuronVisibilityDistribution>,
137 pub neuron_type_distribution: Vec<NnsNeuronTypeDistribution>,
139}
140
141#[derive(Debug, Eq, PartialEq, ThisError)]
148#[error("invalid NNS neuron distribution report: {reason}")]
149pub struct NnsNeuronDistributionValidationError {
150 pub reason: String,
152}
153
154#[derive(Debug, ThisError)]
161pub enum NnsNeuronDistributionError {
162 #[error("invalid NNS neuron collection state for distribution projection: {reason}")]
164 InvalidCollectionState {
165 reason: String,
167 },
168
169 #[error("NNS neuron distribution requires a complete collection; state is {status}")]
171 CollectionNotComplete {
172 status: NnsGovernanceCollectionStatus,
174 },
175
176 #[error(
178 "NNS neuron distribution received {actual} rows; complete collection accounts for {expected}"
179 )]
180 NeuronCountMismatch {
181 expected: u64,
183 actual: u64,
185 },
186
187 #[error("invalid NNS neuron rows for distribution projection: {reason}")]
189 InvalidNeuronRows {
190 reason: String,
192 },
193
194 #[error("NNS neuron distribution accounting overflow while updating {field}")]
196 AccountingOverflow {
197 field: &'static str,
199 },
200}
201
202pub fn build_nns_neuron_distribution_report(
204 collection: &NnsNeuronCollectionState,
205 neurons: &[NnsNeuronRow],
206) -> Result<NnsNeuronDistributionReport, NnsNeuronDistributionError> {
207 validate_collection_state(collection).map_err(|error| {
208 NnsNeuronDistributionError::InvalidCollectionState {
209 reason: error.to_string(),
210 }
211 })?;
212 if !collection.is_complete() {
213 return Err(NnsNeuronDistributionError::CollectionNotComplete {
214 status: collection.status(),
215 });
216 }
217 let source = collection.source().cloned().ok_or_else(|| {
218 NnsNeuronDistributionError::InvalidCollectionState {
219 reason: "complete collection has no concrete source provenance".to_string(),
220 }
221 })?;
222
223 let expected = collection.neurons_fetched();
224 let actual = u64::try_from(neurons.len()).map_err(|_| {
225 NnsNeuronDistributionError::AccountingOverflow {
226 field: "supplied_neuron_count",
227 }
228 })?;
229 if actual != expected {
230 return Err(NnsNeuronDistributionError::NeuronCountMismatch { expected, actual });
231 }
232 validate_neuron_rows(neurons).map_err(|error| {
233 NnsNeuronDistributionError::InvalidNeuronRows {
234 reason: error.to_string(),
235 }
236 })?;
237
238 let mut distribution = DistributionAccumulator::default();
239 for neuron in neurons {
240 distribution.observe(neuron)?;
241 }
242 Ok(distribution.into_report(collection, expected, source))
243}
244
245pub fn validate_nns_neuron_distribution_report(
247 report: &NnsNeuronDistributionReport,
248) -> Result<(), NnsNeuronDistributionValidationError> {
249 validate_distribution_header(report)?;
250 validate_distribution_summary(report)?;
251 validate_distribution(
252 report,
253 "state",
254 "raw-code",
255 report
256 .state_distribution
257 .iter()
258 .map(|row| (row.state, row.neuron_count, row.effective_stake_e8s)),
259 )?;
260 validate_distribution(
261 report,
262 "visibility",
263 "optional-raw-code",
264 report
265 .visibility_distribution
266 .iter()
267 .map(|row| (row.visibility, row.neuron_count, row.effective_stake_e8s)),
268 )?;
269 validate_distribution(
270 report,
271 "neuron-type",
272 "optional-raw-code",
273 report
274 .neuron_type_distribution
275 .iter()
276 .map(|row| (row.neuron_type, row.neuron_count, row.effective_stake_e8s)),
277 )
278}
279
280fn validate_distribution_header(
281 report: &NnsNeuronDistributionReport,
282) -> Result<(), NnsNeuronDistributionValidationError> {
283 if report.schema_version != NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION {
284 return Err(invalid_validation(format!(
285 "schema version {} does not equal {}",
286 report.schema_version, NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION
287 )));
288 }
289 if report.network != MAINNET_NETWORK {
290 return Err(invalid_validation(format!(
291 "network is {}, expected {MAINNET_NETWORK}",
292 report.network
293 )));
294 }
295 if report.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
296 return Err(invalid_validation(format!(
297 "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
298 report.governance_canister_id
299 )));
300 }
301 if report.collection_page_count == 0 {
302 return Err(invalid_validation(
303 "complete distribution report must retain at least one collection page",
304 ));
305 }
306 let minimum_neuron_count = u64::from(report.collection_page_count - 1);
307 if report.collected_neuron_count < minimum_neuron_count {
308 return Err(invalid_validation(format!(
309 "collection_page_count {} requires at least {minimum_neuron_count} collected neurons, found {}",
310 report.collection_page_count, report.collected_neuron_count
311 )));
312 }
313 validate_governance_time_interval(
314 "collection_started_at",
315 &report.collection_started_at,
316 "collection_updated_at",
317 &report.collection_updated_at,
318 )
319 .map_err(|error| invalid_validation(error.to_string()))?;
320 if report.point_in_time_guaranteed {
321 return Err(invalid_validation(
322 "sequential public-neuron collection cannot claim a point-in-time snapshot",
323 ));
324 }
325 validate_governance_report_source(&report.network, &report.source).map_err(|error| {
326 let context = match &report.source {
327 NnsGovernanceSourceProvenance::ReplicaQuery { .. } => "source",
328 NnsGovernanceSourceProvenance::ReplicatedInterCanisterCall { .. } => "provenance",
329 };
330 invalid_validation(format!("invalid collection {context}: {error}"))
331 })?;
332 validate_retrieval_range(report)
333}
334
335fn validate_retrieval_range(
336 report: &NnsNeuronDistributionReport,
337) -> Result<(), NnsNeuronDistributionValidationError> {
338 match (
339 report.earliest_retrieved_at_timestamp_seconds,
340 report.latest_retrieved_at_timestamp_seconds,
341 ) {
342 (None, None) if report.collected_neuron_count == 0 => Ok(()),
343 (Some(earliest), Some(latest))
344 if report.collected_neuron_count > 0 && earliest <= latest =>
345 {
346 Ok(())
347 }
348 _ => Err(invalid_validation(
349 "retrieval timestamp range disagrees with collected_neuron_count or is reversed",
350 )),
351 }
352}
353
354fn validate_distribution_summary(
355 report: &NnsNeuronDistributionReport,
356) -> Result<(), NnsNeuronDistributionValidationError> {
357 validate_optional_summary(
358 report.reported_staked_maturity_neuron_count,
359 report.unreported_staked_maturity_neuron_count,
360 report.total_reported_staked_maturity_e8s_equivalent,
361 report.collected_neuron_count,
362 "staked maturity",
363 )?;
364 validate_optional_summary(
365 report.reported_deciding_voting_power_neuron_count,
366 report.unreported_deciding_voting_power_neuron_count,
367 report.total_reported_deciding_voting_power,
368 report.collected_neuron_count,
369 "deciding voting power",
370 )?;
371 validate_optional_summary(
372 report.reported_potential_voting_power_neuron_count,
373 report.unreported_potential_voting_power_neuron_count,
374 report.total_reported_potential_voting_power,
375 report.collected_neuron_count,
376 "potential voting power",
377 )?;
378 for (field, count) in [
379 (
380 "known_neuron_metadata_count",
381 report.known_neuron_metadata_count,
382 ),
383 (
384 "neurons_fund_join_timestamp_present_count",
385 report.neurons_fund_join_timestamp_present_count,
386 ),
387 ] {
388 if count > report.collected_neuron_count {
389 return Err(invalid_validation(format!(
390 "{field} {count} exceeds collected_neuron_count {}",
391 report.collected_neuron_count
392 )));
393 }
394 }
395 Ok(())
396}
397
398fn validate_optional_summary(
399 reported: u64,
400 unreported: u64,
401 total: u64,
402 collected: u64,
403 field: &'static str,
404) -> Result<(), NnsNeuronDistributionValidationError> {
405 let accounted = reported
406 .checked_add(unreported)
407 .ok_or_else(|| invalid_validation(format!("{field} coverage count overflow")))?;
408 if accounted != collected {
409 return Err(invalid_validation(format!(
410 "{field} coverage accounts for {accounted} neurons, expected {collected}"
411 )));
412 }
413 if reported == 0 && total != 0 {
414 return Err(invalid_validation(format!(
415 "{field} total must be zero when no rows report the field"
416 )));
417 }
418 Ok(())
419}
420
421fn validate_distribution<Key: Copy + Ord + std::fmt::Debug>(
422 report: &NnsNeuronDistributionReport,
423 dimension: &'static str,
424 order: &'static str,
425 rows: impl Iterator<Item = (Key, u64, u64)>,
426) -> Result<(), NnsNeuronDistributionValidationError> {
427 let mut previous = None;
428 let mut neuron_count = 0_u64;
429 let mut stake = 0_u64;
430 for (key, count, effective_stake) in rows {
431 if previous.is_some_and(|previous| previous >= key) {
432 return Err(invalid_validation(format!(
433 "{dimension} distribution is not strictly {order} ordered"
434 )));
435 }
436 neuron_count = add_distribution_count(neuron_count, count, dimension)?;
437 stake = stake
438 .checked_add(effective_stake)
439 .ok_or_else(|| invalid_validation(format!("{dimension} stake total overflow")))?;
440 previous = Some(key);
441 }
442 validate_distribution_totals(report, neuron_count, stake, dimension)
443}
444
445fn add_distribution_count(
446 total: u64,
447 count: u64,
448 dimension: &'static str,
449) -> Result<u64, NnsNeuronDistributionValidationError> {
450 if count == 0 {
451 return Err(invalid_validation(format!(
452 "{dimension} distribution row must contain at least one neuron"
453 )));
454 }
455 add_validation_total(total, count, dimension)
456}
457
458fn add_validation_total(
459 total: u64,
460 value: u64,
461 field: &'static str,
462) -> Result<u64, NnsNeuronDistributionValidationError> {
463 total
464 .checked_add(value)
465 .ok_or_else(|| invalid_validation(format!("{field} total overflow")))
466}
467
468fn validate_distribution_totals(
469 report: &NnsNeuronDistributionReport,
470 neuron_count: u64,
471 stake: u64,
472 dimension: &'static str,
473) -> Result<(), NnsNeuronDistributionValidationError> {
474 if neuron_count != report.collected_neuron_count {
475 return Err(invalid_validation(format!(
476 "{dimension} neuron counts sum to {neuron_count}, expected {}",
477 report.collected_neuron_count
478 )));
479 }
480 if stake != report.total_effective_stake_e8s {
481 return Err(invalid_validation(format!(
482 "{dimension} effective stake sums to {stake}, expected {}",
483 report.total_effective_stake_e8s
484 )));
485 }
486 Ok(())
487}
488
489fn invalid_validation(reason: impl Into<String>) -> NnsNeuronDistributionValidationError {
490 NnsNeuronDistributionValidationError {
491 reason: reason.into(),
492 }
493}
494
495#[derive(Clone, Copy, Default)]
496struct DimensionAccumulator {
497 neuron_count: u64,
498 effective_stake_e8s: u64,
499}
500
501impl DimensionAccumulator {
502 fn observe(
503 &mut self,
504 effective_stake_e8s: u64,
505 count_field: &'static str,
506 stake_field: &'static str,
507 ) -> Result<(), NnsNeuronDistributionError> {
508 increment(&mut self.neuron_count, count_field)?;
509 add(
510 &mut self.effective_stake_e8s,
511 effective_stake_e8s,
512 stake_field,
513 )
514 }
515}
516
517#[derive(Default)]
518struct OptionalValueAccumulator {
519 reported_neuron_count: u64,
520 unreported_neuron_count: u64,
521 total_reported_value: u64,
522}
523
524impl OptionalValueAccumulator {
525 fn observe(
526 &mut self,
527 value: Option<u64>,
528 reported_field: &'static str,
529 unreported_field: &'static str,
530 total_field: &'static str,
531 ) -> Result<(), NnsNeuronDistributionError> {
532 if let Some(value) = value {
533 increment(&mut self.reported_neuron_count, reported_field)?;
534 add(&mut self.total_reported_value, value, total_field)
535 } else {
536 increment(&mut self.unreported_neuron_count, unreported_field)
537 }
538 }
539}
540
541#[derive(Default)]
542struct DistributionAccumulator {
543 states: BTreeMap<i32, DimensionAccumulator>,
544 visibilities: BTreeMap<Option<i32>, DimensionAccumulator>,
545 neuron_types: BTreeMap<Option<i32>, DimensionAccumulator>,
546 total_effective_stake_e8s: u64,
547 staked_maturity: OptionalValueAccumulator,
548 deciding_voting_power: OptionalValueAccumulator,
549 potential_voting_power: OptionalValueAccumulator,
550 known_neuron_metadata_count: u64,
551 neurons_fund_join_timestamp_present_count: u64,
552 earliest_retrieved_at_timestamp_seconds: Option<u64>,
553 latest_retrieved_at_timestamp_seconds: Option<u64>,
554}
555
556impl DistributionAccumulator {
557 fn observe(&mut self, neuron: &NnsNeuronRow) -> Result<(), NnsNeuronDistributionError> {
558 add(
559 &mut self.total_effective_stake_e8s,
560 neuron.stake_e8s,
561 "total_effective_stake_e8s",
562 )?;
563 self.states.entry(neuron.state).or_default().observe(
564 neuron.stake_e8s,
565 "state_neuron_count",
566 "state_effective_stake_e8s",
567 )?;
568 self.visibilities
569 .entry(neuron.visibility)
570 .or_default()
571 .observe(
572 neuron.stake_e8s,
573 "visibility_neuron_count",
574 "visibility_effective_stake_e8s",
575 )?;
576 self.neuron_types
577 .entry(neuron.neuron_type)
578 .or_default()
579 .observe(
580 neuron.stake_e8s,
581 "neuron_type_neuron_count",
582 "neuron_type_effective_stake_e8s",
583 )?;
584 self.staked_maturity.observe(
585 neuron.staked_maturity_e8s_equivalent,
586 "reported_staked_maturity_neuron_count",
587 "unreported_staked_maturity_neuron_count",
588 "total_reported_staked_maturity_e8s_equivalent",
589 )?;
590 self.deciding_voting_power.observe(
591 neuron.deciding_voting_power,
592 "reported_deciding_voting_power_neuron_count",
593 "unreported_deciding_voting_power_neuron_count",
594 "total_reported_deciding_voting_power",
595 )?;
596 self.potential_voting_power.observe(
597 neuron.potential_voting_power,
598 "reported_potential_voting_power_neuron_count",
599 "unreported_potential_voting_power_neuron_count",
600 "total_reported_potential_voting_power",
601 )?;
602 if neuron.known_neuron_data.is_some() {
603 increment(
604 &mut self.known_neuron_metadata_count,
605 "known_neuron_metadata_count",
606 )?;
607 }
608 if neuron.joined_community_fund_timestamp_seconds.is_some() {
609 increment(
610 &mut self.neurons_fund_join_timestamp_present_count,
611 "neurons_fund_join_timestamp_present_count",
612 )?;
613 }
614 let retrieved_at = neuron.retrieved_at_timestamp_seconds;
615 self.earliest_retrieved_at_timestamp_seconds = Some(
616 self.earliest_retrieved_at_timestamp_seconds
617 .map_or(retrieved_at, |earliest| earliest.min(retrieved_at)),
618 );
619 self.latest_retrieved_at_timestamp_seconds = Some(
620 self.latest_retrieved_at_timestamp_seconds
621 .map_or(retrieved_at, |latest| latest.max(retrieved_at)),
622 );
623 Ok(())
624 }
625
626 fn into_report(
627 self,
628 collection: &NnsNeuronCollectionState,
629 collected_neuron_count: u64,
630 source: NnsGovernanceSourceProvenance,
631 ) -> NnsNeuronDistributionReport {
632 NnsNeuronDistributionReport {
633 schema_version: NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION,
634 network: collection.network().to_string(),
635 governance_canister_id: collection.governance_canister_id().to_string(),
636 source,
637 collection_started_at: collection.started_at().to_string(),
638 collection_updated_at: collection.updated_at().to_string(),
639 collection_page_count: collection.pages_fetched(),
640 collected_neuron_count,
641 point_in_time_guaranteed: false,
642 earliest_retrieved_at_timestamp_seconds: self.earliest_retrieved_at_timestamp_seconds,
643 latest_retrieved_at_timestamp_seconds: self.latest_retrieved_at_timestamp_seconds,
644 total_effective_stake_e8s: self.total_effective_stake_e8s,
645 reported_staked_maturity_neuron_count: self.staked_maturity.reported_neuron_count,
646 unreported_staked_maturity_neuron_count: self.staked_maturity.unreported_neuron_count,
647 total_reported_staked_maturity_e8s_equivalent: self
648 .staked_maturity
649 .total_reported_value,
650 reported_deciding_voting_power_neuron_count: self
651 .deciding_voting_power
652 .reported_neuron_count,
653 unreported_deciding_voting_power_neuron_count: self
654 .deciding_voting_power
655 .unreported_neuron_count,
656 total_reported_deciding_voting_power: self.deciding_voting_power.total_reported_value,
657 reported_potential_voting_power_neuron_count: self
658 .potential_voting_power
659 .reported_neuron_count,
660 unreported_potential_voting_power_neuron_count: self
661 .potential_voting_power
662 .unreported_neuron_count,
663 total_reported_potential_voting_power: self.potential_voting_power.total_reported_value,
664 known_neuron_metadata_count: self.known_neuron_metadata_count,
665 neurons_fund_join_timestamp_present_count: self
666 .neurons_fund_join_timestamp_present_count,
667 state_distribution: self
668 .states
669 .into_iter()
670 .map(|(state, distribution)| NnsNeuronStateDistribution {
671 state,
672
673 neuron_count: distribution.neuron_count,
674 effective_stake_e8s: distribution.effective_stake_e8s,
675 })
676 .collect(),
677 visibility_distribution: self
678 .visibilities
679 .into_iter()
680 .map(
681 |(visibility, distribution)| NnsNeuronVisibilityDistribution {
682 visibility,
683
684 neuron_count: distribution.neuron_count,
685 effective_stake_e8s: distribution.effective_stake_e8s,
686 },
687 )
688 .collect(),
689 neuron_type_distribution: self
690 .neuron_types
691 .into_iter()
692 .map(|(neuron_type, distribution)| NnsNeuronTypeDistribution {
693 neuron_type,
694
695 neuron_count: distribution.neuron_count,
696 effective_stake_e8s: distribution.effective_stake_e8s,
697 })
698 .collect(),
699 }
700 }
701}
702
703fn increment(value: &mut u64, field: &'static str) -> Result<(), NnsNeuronDistributionError> {
704 *value = value
705 .checked_add(1)
706 .ok_or(NnsNeuronDistributionError::AccountingOverflow { field })?;
707 Ok(())
708}
709
710fn add(total: &mut u64, value: u64, field: &'static str) -> Result<(), NnsNeuronDistributionError> {
711 *total = total
712 .checked_add(value)
713 .ok_or(NnsNeuronDistributionError::AccountingOverflow { field })?;
714 Ok(())
715}
716
717#[cfg(test)]
718mod tests;