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