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