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