1use super::{
8 NnsNeuronCollectionState,
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::{
18 NnsGovernanceCollectionStatus, NnsGovernanceSourceProvenance,
19 validate_governance_report_source, validate_governance_time_interval,
20 },
21 },
22 subnet_catalog::MAINNET_NETWORK,
23};
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26use thiserror::Error as ThisError;
27
28pub const NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION: u32 = 1;
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
38pub struct NnsNeuronStateDistribution {
39 pub state: i32,
41 pub state_text: NnsNeuronState,
43 pub neuron_count: u64,
45 pub effective_stake_e8s: u64,
47}
48
49#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56pub struct NnsNeuronVisibilityDistribution {
57 pub visibility: Option<i32>,
59 pub visibility_text: NnsNeuronVisibility,
61 pub neuron_count: u64,
63 pub effective_stake_e8s: u64,
65}
66
67#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct NnsNeuronTypeDistribution {
75 pub neuron_type: Option<i32>,
77 pub neuron_type_text: NnsNeuronType,
79 pub neuron_count: u64,
81 pub effective_stake_e8s: u64,
83}
84
85#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92pub struct NnsNeuronDistributionReport {
93 pub schema_version: u32,
95 pub network: String,
97 pub governance_canister_id: String,
99 pub source: NnsGovernanceSourceProvenance,
101 pub collection_started_at: String,
103 pub collection_updated_at: String,
105 pub collection_page_count: u32,
107 pub collected_neuron_count: u64,
109 pub point_in_time_guaranteed: bool,
111 pub earliest_retrieved_at_timestamp_seconds: Option<u64>,
113 pub latest_retrieved_at_timestamp_seconds: Option<u64>,
115 pub total_effective_stake_e8s: u64,
117 pub reported_staked_maturity_neuron_count: u64,
119 pub unreported_staked_maturity_neuron_count: u64,
121 pub total_reported_staked_maturity_e8s_equivalent: u64,
123 pub reported_deciding_voting_power_neuron_count: u64,
125 pub unreported_deciding_voting_power_neuron_count: u64,
127 pub total_reported_deciding_voting_power: u64,
129 pub reported_potential_voting_power_neuron_count: u64,
131 pub unreported_potential_voting_power_neuron_count: u64,
133 pub total_reported_potential_voting_power: u64,
135 pub known_neuron_metadata_count: u64,
137 pub neurons_fund_join_timestamp_present_count: u64,
139 pub state_distribution: Vec<NnsNeuronStateDistribution>,
141 pub visibility_distribution: Vec<NnsNeuronVisibilityDistribution>,
143 pub neuron_type_distribution: Vec<NnsNeuronTypeDistribution>,
145}
146
147#[derive(Debug, Eq, PartialEq, ThisError)]
154#[error("invalid NNS neuron distribution report: {reason}")]
155pub struct NnsNeuronDistributionValidationError {
156 pub reason: String,
158}
159
160#[derive(Debug, ThisError)]
167pub enum NnsNeuronDistributionError {
168 #[error("invalid NNS neuron collection state for distribution projection: {reason}")]
170 InvalidCollectionState {
171 reason: String,
173 },
174
175 #[error("NNS neuron distribution requires a complete collection; state is {status}")]
177 CollectionNotComplete {
178 status: NnsGovernanceCollectionStatus,
180 },
181
182 #[error(
184 "NNS neuron distribution received {actual} rows; complete collection accounts for {expected}"
185 )]
186 NeuronCountMismatch {
187 expected: u64,
189 actual: u64,
191 },
192
193 #[error("invalid NNS neuron rows for distribution projection: {reason}")]
195 InvalidNeuronRows {
196 reason: String,
198 },
199
200 #[error("NNS neuron distribution accounting overflow while updating {field}")]
202 AccountingOverflow {
203 field: &'static str,
205 },
206}
207
208pub fn build_nns_neuron_distribution_report(
210 collection: &NnsNeuronCollectionState,
211 neurons: &[NnsNeuronRow],
212) -> Result<NnsNeuronDistributionReport, NnsNeuronDistributionError> {
213 validate_collection_state(collection).map_err(|error| {
214 NnsNeuronDistributionError::InvalidCollectionState {
215 reason: error.to_string(),
216 }
217 })?;
218 if !collection.is_complete() {
219 return Err(NnsNeuronDistributionError::CollectionNotComplete {
220 status: collection.status(),
221 });
222 }
223 let source = collection.source().cloned().ok_or_else(|| {
224 NnsNeuronDistributionError::InvalidCollectionState {
225 reason: "complete collection has no concrete source provenance".to_string(),
226 }
227 })?;
228
229 let expected = collection.neurons_fetched();
230 let actual = u64::try_from(neurons.len()).map_err(|_| {
231 NnsNeuronDistributionError::AccountingOverflow {
232 field: "supplied_neuron_count",
233 }
234 })?;
235 if actual != expected {
236 return Err(NnsNeuronDistributionError::NeuronCountMismatch { expected, actual });
237 }
238 validate_neuron_rows(neurons).map_err(|error| {
239 NnsNeuronDistributionError::InvalidNeuronRows {
240 reason: error.to_string(),
241 }
242 })?;
243
244 let mut distribution = DistributionAccumulator::default();
245 for neuron in neurons {
246 distribution.observe(neuron)?;
247 }
248 Ok(distribution.into_report(collection, expected, source))
249}
250
251pub fn validate_nns_neuron_distribution_report(
253 report: &NnsNeuronDistributionReport,
254) -> Result<(), NnsNeuronDistributionValidationError> {
255 validate_distribution_header(report)?;
256 validate_distribution_summary(report)?;
257 validate_state_distribution(report)?;
258 validate_visibility_distribution(report)?;
259 validate_neuron_type_distribution(report)
260}
261
262fn validate_distribution_header(
263 report: &NnsNeuronDistributionReport,
264) -> Result<(), NnsNeuronDistributionValidationError> {
265 if report.schema_version != NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION {
266 return Err(invalid_validation(format!(
267 "schema version {} does not equal {}",
268 report.schema_version, NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION
269 )));
270 }
271 if report.network != MAINNET_NETWORK {
272 return Err(invalid_validation(format!(
273 "network is {}, expected {MAINNET_NETWORK}",
274 report.network
275 )));
276 }
277 if report.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
278 return Err(invalid_validation(format!(
279 "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
280 report.governance_canister_id
281 )));
282 }
283 if report.collection_page_count == 0 {
284 return Err(invalid_validation(
285 "complete distribution report must retain at least one collection page",
286 ));
287 }
288 let minimum_neuron_count = u64::from(report.collection_page_count - 1);
289 if report.collected_neuron_count < minimum_neuron_count {
290 return Err(invalid_validation(format!(
291 "collection_page_count {} requires at least {minimum_neuron_count} collected neurons, found {}",
292 report.collection_page_count, report.collected_neuron_count
293 )));
294 }
295 validate_governance_time_interval(
296 "collection_started_at",
297 &report.collection_started_at,
298 "collection_updated_at",
299 &report.collection_updated_at,
300 )
301 .map_err(|error| invalid_validation(error.to_string()))?;
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;