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