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