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