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