1use super::{
8 SnsMaturityDisbursementRow, SnsNeuronPermissionRow,
9 detail::{
10 SnsPolicyObservationStatus, neuron_policy_observations, permission_code_is_assessable,
11 permission_code_policy_observations, validate_canonical_principal,
12 validate_maturity_disbursements, validate_neuron_permissions,
13 },
14};
15use crate::{
16 hex::is_lowercase_hex_32_bytes,
17 report::ReportDataSource,
18 sns::report::{
19 MAINNET_SNS_WASM_CANISTER_ID, SNS_REWARD_CHECKPOINT_MAX_NEURONS,
20 SNS_REWARD_CHECKPOINT_PAGE_SIZE, SNS_REWARD_CHECKPOINT_REPORT_SCHEMA_VERSION,
21 model::reports::{SnsGovernanceParameters, SnsRunningVersionResponse},
22 },
23 subnet_catalog::MAINNET_NETWORK,
24};
25use ic_host_text::timestamp::format_utc_timestamp_secs;
26use ic_platform::candid::contract::{CandidType, serde::Deserialize};
27use serde::Serialize;
28use std::collections::HashSet;
29use thiserror::Error as ThisError;
30
31#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize, Serialize)]
38#[candid_path("ic_platform::candid::contract")]
39#[serde(deny_unknown_fields)]
40pub struct SnsRewardProposalId {
41 pub id: u64,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize, Serialize)]
52#[candid_path("ic_platform::candid::contract")]
53#[serde(deny_unknown_fields)]
54pub struct SnsRewardEvent {
55 pub rounds_since_last_distribution: Option<u64>,
57 pub actual_timestamp_seconds: u64,
59 pub end_timestamp_seconds: Option<u64>,
61 pub total_available_e8s_equivalent: Option<u64>,
63 pub distributed_e8s_equivalent: u64,
65 pub round: u64,
67 pub settled_proposals: Vec<SnsRewardProposalId>,
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
78#[serde(rename_all = "snake_case")]
79pub enum SnsRewardCollectionStatus {
80 ApiExhaustedObserved,
82}
83
84impl SnsRewardCollectionStatus {
85 #[must_use]
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Self::ApiExhaustedObserved => "api_exhausted_observed",
90 }
91 }
92}
93
94#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
101#[serde(deny_unknown_fields)]
102pub struct SnsRewardCheckpointRow {
103 pub neuron_id: String,
105 pub created_timestamp_seconds: u64,
107 pub maturity_e8s_equivalent: u64,
109 pub staked_maturity_e8s_equivalent: Option<u64>,
111 pub combined_maturity_e8s_equivalent: u64,
113 pub auto_stake_maturity: Option<bool>,
115 pub permissions: Vec<SnsNeuronPermissionRow>,
117 pub disburse_maturity_in_progress: Vec<SnsMaturityDisbursementRow>,
119 pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
121 pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
123}
124
125impl SnsRewardCheckpointRow {
126 #[must_use]
128 pub fn checked_combined_maturity(&self) -> Option<u64> {
129 self.maturity_e8s_equivalent
130 .checked_add(self.staked_maturity_e8s_equivalent.unwrap_or(0))
131 }
132
133 #[must_use]
135 pub fn derived_policy_observations(
136 &self,
137 ) -> (SnsPolicyObservationStatus, SnsPolicyObservationStatus) {
138 neuron_policy_observations(
139 &self.permissions,
140 !self.disburse_maturity_in_progress.is_empty(),
141 )
142 }
143}
144
145#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
152#[serde(deny_unknown_fields)]
153pub struct SnsRewardCheckpointReport {
154 pub schema_version: u32,
156 pub network: String,
158 pub sns_wasm_canister_id: String,
160 pub source_endpoint: String,
162 pub fetched_by: String,
164 pub id: usize,
166 pub name: String,
168 pub root_canister_id: String,
170 pub governance_canister_id: String,
172 pub ledger_canister_id: String,
174 pub swap_canister_id: String,
176 pub index_canister_id: String,
178 pub data_source: ReportDataSource,
180 pub collection_started_at_unix_secs: u64,
182 pub collection_started_at: String,
184 pub collection_completed_at_unix_secs: u64,
186 pub collection_completed_at: String,
188 pub page_size: u32,
190 pub page_count: u32,
192 pub row_count: usize,
194 pub unique_neuron_id_count: usize,
196 pub collection_row_ceiling: u64,
198 pub client_query_count: u32,
200 pub collection_status: SnsRewardCollectionStatus,
202 pub point_in_time_guaranteed: bool,
204 pub parameters_before: SnsGovernanceParameters,
206 pub parameters_after: SnsGovernanceParameters,
208 pub reward_event_before: SnsRewardEvent,
210 pub reward_event_after: SnsRewardEvent,
212 pub running_version_before: SnsRunningVersionResponse,
214 pub running_version_after: SnsRunningVersionResponse,
216 pub aggregate_maturity_e8s_equivalent: u64,
218 pub aggregate_staked_maturity_e8s_equivalent: u64,
220 pub aggregate_combined_maturity_e8s_equivalent: u64,
222 pub permission_entry_count: usize,
224 pub unassessable_permission_code_count: usize,
226 pub pending_maturity_disbursement_count: usize,
228 pub auto_stake_maturity_enabled_count: usize,
230 pub auto_stake_maturity_disabled_count: usize,
232 pub auto_stake_maturity_unspecified_count: usize,
234 pub manage_principals_grantable: Option<bool>,
236 pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
238 pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
240 pub maturity_conversion_policy_observed_status: SnsPolicyObservationStatus,
242 pub rows: Vec<SnsRewardCheckpointRow>,
244}
245
246#[derive(Debug, Eq, PartialEq, ThisError)]
253#[error("invalid SNS reward checkpoint: {reason}")]
254pub struct SnsRewardCheckpointValidationError {
255 pub reason: String,
257}
258
259pub fn validate_sns_reward_checkpoint_report(
261 report: &SnsRewardCheckpointReport,
262) -> Result<(), SnsRewardCheckpointValidationError> {
263 validate_checkpoint_evidence(report)?;
264 let summary = recompute_reward_checkpoint_summary(&report.parameters_before, &report.rows)
265 .map_err(invalid_validation)?;
266 validate_checkpoint_summary(report, &summary)
267}
268
269pub(in crate::sns::report) enum RewardCheckpointAssessment {
276 Valid(SnsRewardCheckpointSummary),
277 Invalid {
278 error: SnsRewardCheckpointValidationError,
279 summary: Option<SnsRewardCheckpointSummary>,
280 },
281}
282
283impl RewardCheckpointAssessment {
284 pub(in crate::sns::report) const fn summary(&self) -> Option<&SnsRewardCheckpointSummary> {
285 match self {
286 Self::Valid(summary) => Some(summary),
287 Self::Invalid { summary, .. } => summary.as_ref(),
288 }
289 }
290}
291
292pub(in crate::sns::report) fn assess_reward_checkpoint(
294 report: &SnsRewardCheckpointReport,
295) -> RewardCheckpointAssessment {
296 let evidence = validate_checkpoint_evidence(report);
297 match recompute_reward_checkpoint_summary(&report.parameters_before, &report.rows) {
298 Ok(summary) => {
299 match evidence.and_then(|()| validate_checkpoint_summary(report, &summary)) {
300 Ok(()) => RewardCheckpointAssessment::Valid(summary),
301 Err(error) => RewardCheckpointAssessment::Invalid {
302 error,
303 summary: Some(summary),
304 },
305 }
306 }
307 Err(reason) => RewardCheckpointAssessment::Invalid {
308 error: evidence.err().unwrap_or_else(|| invalid_validation(reason)),
309 summary: None,
310 },
311 }
312}
313
314impl SnsRewardCheckpointReport {
315 #[cfg(feature = "sns-host")]
317 pub(in crate::sns::report) fn validate_collected_summary(
318 &self,
319 summary: &SnsRewardCheckpointSummary,
320 ) -> Result<(), SnsRewardCheckpointValidationError> {
321 validate_checkpoint_evidence(self)?;
322 validate_checkpoint_summary(self, summary)
323 }
324}
325
326fn validate_checkpoint_evidence(
327 report: &SnsRewardCheckpointReport,
328) -> Result<(), SnsRewardCheckpointValidationError> {
329 validate_checkpoint_header(report)?;
330 validate_checkpoint_brackets(report)?;
331 validate_checkpoint_rows(&report.rows, report.collection_completed_at_unix_secs)
332}
333
334fn validate_checkpoint_header(
335 report: &SnsRewardCheckpointReport,
336) -> Result<(), SnsRewardCheckpointValidationError> {
337 if report.schema_version != SNS_REWARD_CHECKPOINT_REPORT_SCHEMA_VERSION {
338 return Err(invalid_validation(format!(
339 "schema version {} does not equal {}",
340 report.schema_version, SNS_REWARD_CHECKPOINT_REPORT_SCHEMA_VERSION
341 )));
342 }
343 for (field, principal) in [
344 ("sns_wasm_canister_id", report.sns_wasm_canister_id.as_str()),
345 ("root_canister_id", report.root_canister_id.as_str()),
346 (
347 "governance_canister_id",
348 report.governance_canister_id.as_str(),
349 ),
350 ("ledger_canister_id", report.ledger_canister_id.as_str()),
351 ("swap_canister_id", report.swap_canister_id.as_str()),
352 ("index_canister_id", report.index_canister_id.as_str()),
353 ] {
354 validate_canonical_principal(principal, field).map_err(invalid_validation)?;
355 }
356 let canister_ids = [
357 report.sns_wasm_canister_id.as_str(),
358 report.root_canister_id.as_str(),
359 report.governance_canister_id.as_str(),
360 report.ledger_canister_id.as_str(),
361 report.swap_canister_id.as_str(),
362 report.index_canister_id.as_str(),
363 ];
364 if canister_ids.into_iter().collect::<HashSet<_>>().len() != canister_ids.len() {
365 return Err(invalid_validation(
366 "checkpoint canister roles do not contain unique principals",
367 ));
368 }
369 if report.sns_wasm_canister_id != MAINNET_SNS_WASM_CANISTER_ID {
370 return Err(invalid_validation("unexpected SNS-W canister identity"));
371 }
372 if report.network != MAINNET_NETWORK {
373 return Err(invalid_validation("checkpoint network must be ic"));
374 }
375 if report.source_endpoint.is_empty() || report.fetched_by.is_empty() {
376 return Err(invalid_validation(
377 "checkpoint source_endpoint and fetched_by must be non-empty",
378 ));
379 }
380 if report.id == 0 || report.name.is_empty() {
381 return Err(invalid_validation(
382 "checkpoint SNS list id and display name must be non-empty",
383 ));
384 }
385 if report.data_source != ReportDataSource::Live {
386 return Err(invalid_validation("checkpoint data_source must be live"));
387 }
388 if report.collection_status != SnsRewardCollectionStatus::ApiExhaustedObserved {
389 return Err(invalid_validation(
390 "checkpoint collection is not API-exhausted",
391 ));
392 }
393 if report.point_in_time_guaranteed {
394 return Err(invalid_validation(
395 "checkpoint cannot claim a point-in-time neuron snapshot",
396 ));
397 }
398 validate_checkpoint_collection_metadata(report)
399}
400
401fn validate_checkpoint_collection_metadata(
402 report: &SnsRewardCheckpointReport,
403) -> Result<(), SnsRewardCheckpointValidationError> {
404 if report.collection_completed_at_unix_secs < report.collection_started_at_unix_secs {
405 return Err(invalid_validation(
406 "collection completion precedes collection start",
407 ));
408 }
409 if report.collection_started_at
410 != format_utc_timestamp_secs(report.collection_started_at_unix_secs)
411 || report.collection_completed_at
412 != format_utc_timestamp_secs(report.collection_completed_at_unix_secs)
413 {
414 return Err(invalid_validation(
415 "collection UTC timestamps do not match raw Unix timestamps",
416 ));
417 }
418 if report.page_size != SNS_REWARD_CHECKPOINT_PAGE_SIZE || report.page_count == 0 {
419 return Err(invalid_validation(
420 "checkpoint must contain at least one fixed-size-100 page",
421 ));
422 }
423 let expected_query_count = report
424 .page_count
425 .checked_add(8)
426 .ok_or_else(|| invalid_validation("page_count + 8 exceeds the native u32 contract"))?;
427 if report.client_query_count != expected_query_count {
428 return Err(invalid_validation(
429 "client_query_count does not equal page_count + 8",
430 ));
431 }
432 if report.row_count != report.rows.len() || report.unique_neuron_id_count != report.rows.len() {
433 return Err(invalid_validation(
434 "serialized row counts do not match raw checkpoint rows",
435 ));
436 }
437 if reward_checkpoint_neuron_ceiling(&report.parameters_before)
438 != Some(report.collection_row_ceiling)
439 {
440 return Err(invalid_validation(
441 "collection_row_ceiling does not match a valid max_number_of_neurons",
442 ));
443 }
444 if u64::try_from(report.rows.len())
445 .ok()
446 .is_none_or(|rows| rows > report.collection_row_ceiling)
447 {
448 return Err(invalid_validation(
449 "raw row count exceeds the mandatory collection ceiling",
450 ));
451 }
452 Ok(())
453}
454
455pub(in crate::sns::report) fn reward_checkpoint_neuron_ceiling(
456 parameters: &SnsGovernanceParameters,
457) -> Option<u64> {
458 parameters
459 .max_number_of_neurons
460 .filter(|value| (1..=SNS_REWARD_CHECKPOINT_MAX_NEURONS).contains(value))
461}
462
463pub(in crate::sns::report) fn reward_checkpoint_bracket_mismatch(
465 parameters: (&SnsGovernanceParameters, &SnsGovernanceParameters),
466 events: (&SnsRewardEvent, &SnsRewardEvent),
467 versions: (&SnsRunningVersionResponse, &SnsRunningVersionResponse),
468) -> Option<(&'static str, &'static str)> {
469 [
470 (
471 "nervous-system parameters",
472 "nervous-system parameter brackets differ",
473 parameters.0 == parameters.1,
474 ),
475 (
476 "reward event",
477 "reward-event brackets differ",
478 events.0 == events.1,
479 ),
480 (
481 "running SNS version",
482 "running-version brackets differ",
483 versions.0 == versions.1,
484 ),
485 ]
486 .into_iter()
487 .find_map(|(component, reason, stable)| (!stable).then_some((component, reason)))
488}
489
490fn validate_checkpoint_brackets(
491 report: &SnsRewardCheckpointReport,
492) -> Result<(), SnsRewardCheckpointValidationError> {
493 if let Some((_, reason)) = reward_checkpoint_bracket_mismatch(
494 (&report.parameters_before, &report.parameters_after),
495 (&report.reward_event_before, &report.reward_event_after),
496 (
497 &report.running_version_before,
498 &report.running_version_after,
499 ),
500 ) {
501 return Err(invalid_validation(reason));
502 }
503 validate_sns_reward_event_evidence(&report.reward_event_after).map_err(invalid_validation)?;
504 validate_reward_event_position(report)?;
505 validate_sns_reward_running_version_evidence(&report.running_version_after)
506 .map_err(invalid_validation)?;
507 validate_sns_reward_checkpoint_parameter_evidence(&report.parameters_after)
508 .map_err(invalid_validation)
509}
510
511fn validate_reward_event_position(
512 report: &SnsRewardCheckpointReport,
513) -> Result<(), SnsRewardCheckpointValidationError> {
514 let event = &report.reward_event_after;
515 if event.actual_timestamp_seconds > report.collection_completed_at_unix_secs
516 || event
517 .end_timestamp_seconds
518 .is_none_or(|end| end > report.collection_completed_at_unix_secs)
519 {
520 return Err(invalid_validation(
521 "reward event timestamps exceed checkpoint collection completion",
522 ));
523 }
524 Ok(())
525}
526
527fn validate_checkpoint_rows(
528 rows: &[SnsRewardCheckpointRow],
529 collection_completed_at_unix_secs: u64,
530) -> Result<(), SnsRewardCheckpointValidationError> {
531 let mut previous_id: Option<&str> = None;
532 for row in rows {
533 if !is_lowercase_hex_32_bytes(&row.neuron_id) {
534 return Err(invalid_validation(format!(
535 "neuron id {} is not 32-byte lowercase hexadecimal text",
536 row.neuron_id
537 )));
538 }
539 if previous_id.is_some_and(|previous| previous >= row.neuron_id.as_str()) {
540 return Err(invalid_validation(
541 "checkpoint neuron ids are not strictly increasing",
542 ));
543 }
544 previous_id = Some(&row.neuron_id);
545 if row.created_timestamp_seconds > collection_completed_at_unix_secs {
546 return Err(invalid_validation(format!(
547 "neuron {} creation timestamp exceeds collection completion",
548 row.neuron_id
549 )));
550 }
551 validate_neuron_permissions(&row.permissions, false)
552 .map_err(|reason| invalid_validation(format!("neuron {}: {reason}", row.neuron_id)))?;
553 validate_maturity_disbursements(&row.disburse_maturity_in_progress)
554 .map_err(invalid_validation)?;
555 }
556 Ok(())
557}
558
559fn validate_checkpoint_summary(
560 report: &SnsRewardCheckpointReport,
561 summary: &SnsRewardCheckpointSummary,
562) -> Result<(), SnsRewardCheckpointValidationError> {
563 let valid = report.aggregate_maturity_e8s_equivalent
564 == summary.aggregate_maturity_e8s_equivalent
565 && report.aggregate_staked_maturity_e8s_equivalent
566 == summary.aggregate_staked_maturity_e8s_equivalent
567 && report.aggregate_combined_maturity_e8s_equivalent
568 == summary.aggregate_combined_maturity_e8s_equivalent
569 && report.permission_entry_count == summary.permission_entry_count
570 && report.unassessable_permission_code_count == summary.unassessable_permission_code_count
571 && report.pending_maturity_disbursement_count
572 == summary.pending_maturity_disbursement_count
573 && report.auto_stake_maturity_enabled_count == summary.auto_stake_maturity_enabled_count
574 && report.auto_stake_maturity_disabled_count == summary.auto_stake_maturity_disabled_count
575 && report.auto_stake_maturity_unspecified_count
576 == summary.auto_stake_maturity_unspecified_count
577 && report.manage_principals_grantable == summary.manage_principals_grantable
578 && report.maturity_mint_conversion_observed_disabled
579 == summary.maturity_mint_conversion_observed_disabled
580 && report.manual_maturity_staking_observed_disabled
581 == summary.manual_maturity_staking_observed_disabled
582 && report.maturity_conversion_policy_observed_status
583 == summary.maturity_conversion_policy_observed_status;
584 if valid {
585 Ok(())
586 } else {
587 Err(invalid_validation(
588 "serialized checkpoint summary does not match raw evidence",
589 ))
590 }
591}
592
593pub(in crate::sns::report) fn validate_sns_reward_running_version_evidence(
594 response: &SnsRunningVersionResponse,
595) -> Result<(), String> {
596 let deployed = response
597 .deployed_version
598 .as_ref()
599 .ok_or_else(|| "running version has no deployed_version".to_string())?;
600 deployed.validate_hashes("deployed_version", Some(32))?;
601 if let Some(target) = response
602 .pending_version
603 .as_ref()
604 .and_then(|pending| pending.target_version.as_ref())
605 {
606 target.validate_hashes("pending_version.target_version", Some(32))?;
607 }
608 Ok(())
609}
610
611pub(in crate::sns::report) fn validate_sns_reward_checkpoint_parameter_evidence(
612 parameters: &SnsGovernanceParameters,
613) -> Result<(), String> {
614 validate_default_followees(parameters)?;
615 validate_parameter_permissions(parameters)
616}
617
618fn validate_default_followees(parameters: &SnsGovernanceParameters) -> Result<(), String> {
619 let Some(defaults) = parameters.default_followees.as_ref() else {
620 return Ok(());
621 };
622 let mut function_ids = HashSet::new();
623 for row in &defaults.followees {
624 if !function_ids.insert(row.function_id) {
625 return Err("default followees contain a duplicate function id".to_string());
626 }
627 let mut neuron_ids = HashSet::new();
628 for id in &row.followee_neuron_ids {
629 if !is_lowercase_hex_32_bytes(id) {
630 return Err("default followees contain a non-canonical neuron id".to_string());
631 }
632 if !neuron_ids.insert(id) {
633 return Err("default followees contain a duplicate neuron id".to_string());
634 }
635 }
636 }
637 Ok(())
638}
639
640fn validate_parameter_permissions(parameters: &SnsGovernanceParameters) -> Result<(), String> {
641 for (field, permissions) in [
642 (
643 "neuron_claimer_permissions",
644 parameters.neuron_claimer_permissions.as_ref(),
645 ),
646 (
647 "neuron_grantable_permissions",
648 parameters.neuron_grantable_permissions.as_ref(),
649 ),
650 ] {
651 let Some(permissions) = permissions else {
652 continue;
653 };
654 let mut codes = HashSet::new();
655 if permissions
656 .permissions
657 .iter()
658 .any(|code| !codes.insert(*code))
659 {
660 return Err(format!("{field} contains a duplicate permission code"));
661 }
662 }
663 Ok(())
664}
665
666pub(in crate::sns::report) fn validate_sns_reward_event_evidence(
667 event: &SnsRewardEvent,
668) -> Result<(), String> {
669 if event.end_timestamp_seconds.is_none() {
670 return Err("reward event is missing canonical end_timestamp_seconds".to_string());
671 }
672 let mut proposal_ids = HashSet::new();
673 if event
674 .settled_proposals
675 .iter()
676 .any(|proposal| !proposal_ids.insert(proposal.id))
677 {
678 return Err("reward event contains duplicate settled proposal ids".to_string());
679 }
680 Ok(())
681}
682
683fn invalid_validation(reason: impl Into<String>) -> SnsRewardCheckpointValidationError {
684 SnsRewardCheckpointValidationError {
685 reason: reason.into(),
686 }
687}
688
689#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
696#[serde(deny_unknown_fields)]
697pub struct SnsRewardCheckpointSummary {
698 pub aggregate_maturity_e8s_equivalent: u64,
700 pub aggregate_staked_maturity_e8s_equivalent: u64,
702 pub aggregate_combined_maturity_e8s_equivalent: u64,
704 pub permission_entry_count: usize,
706 pub unassessable_permission_code_count: usize,
708 pub pending_maturity_disbursement_count: usize,
710 pub auto_stake_maturity_enabled_count: usize,
712 pub auto_stake_maturity_disabled_count: usize,
714 pub auto_stake_maturity_unspecified_count: usize,
716 pub manage_principals_grantable: Option<bool>,
718 pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
720 pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
722 pub maturity_conversion_policy_observed_status: SnsPolicyObservationStatus,
724}
725
726pub(in crate::sns::report) fn recompute_reward_checkpoint_summary(
727 parameters: &SnsGovernanceParameters,
728 rows: &[SnsRewardCheckpointRow],
729) -> Result<SnsRewardCheckpointSummary, String> {
730 let mut summary = SnsRewardCheckpointSummary {
731 aggregate_maturity_e8s_equivalent: 0,
732 aggregate_staked_maturity_e8s_equivalent: 0,
733 aggregate_combined_maturity_e8s_equivalent: 0,
734 permission_entry_count: 0,
735 unassessable_permission_code_count: 0,
736 pending_maturity_disbursement_count: 0,
737 auto_stake_maturity_enabled_count: 0,
738 auto_stake_maturity_disabled_count: 0,
739 auto_stake_maturity_unspecified_count: 0,
740 manage_principals_grantable: parameters
741 .neuron_grantable_permissions
742 .as_ref()
743 .map(|permissions| permissions.permissions.contains(&2)),
744 maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus::ObservedSatisfied,
745 manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus::ObservedSatisfied,
746 maturity_conversion_policy_observed_status: SnsPolicyObservationStatus::ObservedSatisfied,
747 };
748 let (parameter_mint, parameter_staking, parameter_unknown_count) =
749 parameter_policy_observations(parameters);
750 summary.maturity_mint_conversion_observed_disabled = parameter_mint;
751 summary.manual_maturity_staking_observed_disabled = parameter_staking;
752 summary.unassessable_permission_code_count = parameter_unknown_count;
753
754 for row in rows {
755 accumulate_reward_row(&mut summary, row)?;
756 }
757 summary.maturity_conversion_policy_observed_status = summary
758 .maturity_mint_conversion_observed_disabled
759 .combine(summary.manual_maturity_staking_observed_disabled);
760 Ok(summary)
761}
762
763fn accumulate_reward_row(
764 summary: &mut SnsRewardCheckpointSummary,
765 row: &SnsRewardCheckpointRow,
766) -> Result<(), String> {
767 let combined = row.checked_combined_maturity().ok_or_else(|| {
768 format!(
769 "neuron {} combined maturity exceeds the native u64 contract",
770 row.neuron_id
771 )
772 })?;
773 if row.combined_maturity_e8s_equivalent != combined {
774 return Err(format!(
775 "neuron {} combined maturity does not match raw components",
776 row.neuron_id
777 ));
778 }
779 let (row_mint, row_staking) = row.derived_policy_observations();
780 if row.maturity_mint_conversion_observed_disabled != row_mint
781 || row.manual_maturity_staking_observed_disabled != row_staking
782 {
783 return Err(format!(
784 "neuron {} policy observations do not match raw evidence",
785 row.neuron_id
786 ));
787 }
788 summary.aggregate_maturity_e8s_equivalent = checked_sum(
789 summary.aggregate_maturity_e8s_equivalent,
790 row.maturity_e8s_equivalent,
791 "aggregate maturity",
792 )?;
793 summary.aggregate_staked_maturity_e8s_equivalent = checked_sum(
794 summary.aggregate_staked_maturity_e8s_equivalent,
795 row.staked_maturity_e8s_equivalent.unwrap_or(0),
796 "aggregate staked maturity",
797 )?;
798 summary.aggregate_combined_maturity_e8s_equivalent = checked_sum(
799 summary.aggregate_combined_maturity_e8s_equivalent,
800 combined,
801 "aggregate combined maturity",
802 )?;
803 accumulate_reward_row_counts(summary, row)?;
804 summary.maturity_mint_conversion_observed_disabled = summary
805 .maturity_mint_conversion_observed_disabled
806 .combine(row_mint);
807 summary.manual_maturity_staking_observed_disabled = summary
808 .manual_maturity_staking_observed_disabled
809 .combine(row_staking);
810 Ok(())
811}
812
813fn accumulate_reward_row_counts(
814 summary: &mut SnsRewardCheckpointSummary,
815 row: &SnsRewardCheckpointRow,
816) -> Result<(), String> {
817 summary.permission_entry_count = summary
818 .permission_entry_count
819 .checked_add(row.permissions.len())
820 .ok_or_else(|| "permission entry count overflow".to_string())?;
821 summary.pending_maturity_disbursement_count = summary
822 .pending_maturity_disbursement_count
823 .checked_add(row.disburse_maturity_in_progress.len())
824 .ok_or_else(|| "pending maturity disbursement count overflow".to_string())?;
825 summary.unassessable_permission_code_count = summary
826 .unassessable_permission_code_count
827 .checked_add(
828 row.permissions
829 .iter()
830 .flat_map(|permission| permission.permission_types.iter())
831 .filter(|value| !permission_code_is_assessable(value.code))
832 .count(),
833 )
834 .ok_or_else(|| "unassessable permission code count overflow".to_string())?;
835 match row.auto_stake_maturity {
836 Some(true) => increment(&mut summary.auto_stake_maturity_enabled_count)?,
837 Some(false) => increment(&mut summary.auto_stake_maturity_disabled_count)?,
838 None => increment(&mut summary.auto_stake_maturity_unspecified_count)?,
839 }
840 Ok(())
841}
842
843fn parameter_policy_observations(
844 parameters: &SnsGovernanceParameters,
845) -> (
846 SnsPolicyObservationStatus,
847 SnsPolicyObservationStatus,
848 usize,
849) {
850 let mut mint = SnsPolicyObservationStatus::ObservedSatisfied;
851 let mut staking = SnsPolicyObservationStatus::ObservedSatisfied;
852 let mut unknown_count = 0;
853 for permissions in [
854 parameters.neuron_claimer_permissions.as_ref(),
855 parameters.neuron_grantable_permissions.as_ref(),
856 ] {
857 let Some(permissions) = permissions else {
858 mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
859 staking = staking.combine(SnsPolicyObservationStatus::Unassessable);
860 continue;
861 };
862 if permissions.permissions.is_empty() {
863 mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
864 staking = staking.combine(SnsPolicyObservationStatus::Unassessable);
865 }
866 for code in &permissions.permissions {
867 let (code_mint, code_staking) = permission_code_policy_observations(*code);
868 mint = mint.combine(code_mint);
869 staking = staking.combine(code_staking);
870 if !permission_code_is_assessable(*code) {
871 unknown_count += 1;
872 }
873 }
874 }
875 (mint, staking, unknown_count)
876}
877
878fn checked_sum(left: u64, right: u64, field: &str) -> Result<u64, String> {
879 left.checked_add(right)
880 .ok_or_else(|| format!("{field} exceeds the native u64 contract"))
881}
882
883fn increment(value: &mut usize) -> Result<(), String> {
884 *value = value
885 .checked_add(1)
886 .ok_or_else(|| "checkpoint count overflow".to_string())?;
887 Ok(())
888}