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 total_reported_value: u64,
501}
502
503impl OptionalValueAccumulator {
504 fn observe(
505 &mut self,
506 value: Option<u64>,
507 reported_field: &'static str,
508 total_field: &'static str,
509 ) -> Result<(), NnsNeuronDistributionError> {
510 if let Some(value) = value {
511 increment(&mut self.reported_neuron_count, reported_field)?;
512 add(&mut self.total_reported_value, value, total_field)
513 } else {
514 Ok(())
515 }
516 }
517}
518
519#[derive(Default)]
520struct DistributionAccumulator {
521 states: BTreeMap<i32, DimensionAccumulator>,
522 visibilities: BTreeMap<Option<i32>, DimensionAccumulator>,
523 neuron_types: BTreeMap<Option<i32>, DimensionAccumulator>,
524 total_effective_stake_e8s: u64,
525 staked_maturity: OptionalValueAccumulator,
526 deciding_voting_power: OptionalValueAccumulator,
527 potential_voting_power: OptionalValueAccumulator,
528 known_neuron_metadata_count: u64,
529 neurons_fund_join_timestamp_present_count: u64,
530 retrieved_timestamp_bounds: Option<(u64, u64)>,
531}
532
533impl DistributionAccumulator {
534 fn observe(&mut self, neuron: &NnsNeuronRow) -> Result<(), NnsNeuronDistributionError> {
535 add(
536 &mut self.total_effective_stake_e8s,
537 neuron.stake_e8s,
538 "total_effective_stake_e8s",
539 )?;
540 self.states.entry(neuron.state).or_default().observe(
541 neuron.stake_e8s,
542 "state_neuron_count",
543 "state_effective_stake_e8s",
544 )?;
545 self.visibilities
546 .entry(neuron.visibility)
547 .or_default()
548 .observe(
549 neuron.stake_e8s,
550 "visibility_neuron_count",
551 "visibility_effective_stake_e8s",
552 )?;
553 self.neuron_types
554 .entry(neuron.neuron_type)
555 .or_default()
556 .observe(
557 neuron.stake_e8s,
558 "neuron_type_neuron_count",
559 "neuron_type_effective_stake_e8s",
560 )?;
561 self.staked_maturity.observe(
562 neuron.staked_maturity_e8s_equivalent,
563 "reported_staked_maturity_neuron_count",
564 "total_reported_staked_maturity_e8s_equivalent",
565 )?;
566 self.deciding_voting_power.observe(
567 neuron.deciding_voting_power,
568 "reported_deciding_voting_power_neuron_count",
569 "total_reported_deciding_voting_power",
570 )?;
571 self.potential_voting_power.observe(
572 neuron.potential_voting_power,
573 "reported_potential_voting_power_neuron_count",
574 "total_reported_potential_voting_power",
575 )?;
576 if neuron.known_neuron_data.is_some() {
577 increment(
578 &mut self.known_neuron_metadata_count,
579 "known_neuron_metadata_count",
580 )?;
581 }
582 if neuron.joined_community_fund_timestamp_seconds.is_some() {
583 increment(
584 &mut self.neurons_fund_join_timestamp_present_count,
585 "neurons_fund_join_timestamp_present_count",
586 )?;
587 }
588 let retrieved_at = neuron.retrieved_at_timestamp_seconds;
589 self.retrieved_timestamp_bounds = Some(match self.retrieved_timestamp_bounds {
590 Some((earliest, latest)) => (earliest.min(retrieved_at), latest.max(retrieved_at)),
591 None => (retrieved_at, retrieved_at),
592 });
593 Ok(())
594 }
595
596 fn into_report(
597 self,
598 collection: &NnsNeuronCollectionState,
599 collected_neuron_count: u64,
600 source: NnsGovernanceSourceProvenance,
601 ) -> NnsNeuronDistributionReport {
602 NnsNeuronDistributionReport {
603 schema_version: NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION,
604 network: collection.network().to_string(),
605 governance_canister_id: collection.governance_canister_id().to_string(),
606 source,
607 collection_started_at: collection.started_at().to_string(),
608 collection_updated_at: collection.updated_at().to_string(),
609 collection_page_count: collection.pages_fetched(),
610 collected_neuron_count,
611 point_in_time_guaranteed: false,
612 earliest_retrieved_at_timestamp_seconds: self
613 .retrieved_timestamp_bounds
614 .map(|bounds| bounds.0),
615 latest_retrieved_at_timestamp_seconds: self
616 .retrieved_timestamp_bounds
617 .map(|bounds| bounds.1),
618 total_effective_stake_e8s: self.total_effective_stake_e8s,
619 reported_staked_maturity_neuron_count: self.staked_maturity.reported_neuron_count,
620 unreported_staked_maturity_neuron_count: collected_neuron_count
621 - self.staked_maturity.reported_neuron_count,
622 total_reported_staked_maturity_e8s_equivalent: self
623 .staked_maturity
624 .total_reported_value,
625 reported_deciding_voting_power_neuron_count: self
626 .deciding_voting_power
627 .reported_neuron_count,
628 unreported_deciding_voting_power_neuron_count: collected_neuron_count
629 - self.deciding_voting_power.reported_neuron_count,
630 total_reported_deciding_voting_power: self.deciding_voting_power.total_reported_value,
631 reported_potential_voting_power_neuron_count: self
632 .potential_voting_power
633 .reported_neuron_count,
634 unreported_potential_voting_power_neuron_count: collected_neuron_count
635 - self.potential_voting_power.reported_neuron_count,
636 total_reported_potential_voting_power: self.potential_voting_power.total_reported_value,
637 known_neuron_metadata_count: self.known_neuron_metadata_count,
638 neurons_fund_join_timestamp_present_count: self
639 .neurons_fund_join_timestamp_present_count,
640 state_distribution: self
641 .states
642 .into_iter()
643 .map(|(state, distribution)| NnsNeuronStateDistribution {
644 state,
645
646 neuron_count: distribution.neuron_count,
647 effective_stake_e8s: distribution.effective_stake_e8s,
648 })
649 .collect(),
650 visibility_distribution: self
651 .visibilities
652 .into_iter()
653 .map(
654 |(visibility, distribution)| NnsNeuronVisibilityDistribution {
655 visibility,
656
657 neuron_count: distribution.neuron_count,
658 effective_stake_e8s: distribution.effective_stake_e8s,
659 },
660 )
661 .collect(),
662 neuron_type_distribution: self
663 .neuron_types
664 .into_iter()
665 .map(|(neuron_type, distribution)| NnsNeuronTypeDistribution {
666 neuron_type,
667
668 neuron_count: distribution.neuron_count,
669 effective_stake_e8s: distribution.effective_stake_e8s,
670 })
671 .collect(),
672 }
673 }
674}
675
676fn increment(value: &mut u64, field: &'static str) -> Result<(), NnsNeuronDistributionError> {
677 *value = value
678 .checked_add(1)
679 .ok_or(NnsNeuronDistributionError::AccountingOverflow { field })?;
680 Ok(())
681}
682
683fn add(total: &mut u64, value: u64, field: &'static str) -> Result<(), NnsNeuronDistributionError> {
684 *total = total
685 .checked_add(value)
686 .ok_or(NnsNeuronDistributionError::AccountingOverflow { field })?;
687 Ok(())
688}
689
690#[cfg(test)]
691mod tests;