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