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