1use super::{
8 SnsMaturityDisbursementRow, SnsNeuronPermissionRow,
9 detail::{
10 SnsPolicyObservationStatus, neuron_policy_observations, permission_code_policy_observations,
11 },
12};
13use crate::{
14 hex::{is_canonical_lowercase_hex, is_lowercase_hex},
15 report::ReportDataSource,
16 sns::report::{
17 MAINNET_SNS_WASM_CANISTER_ID, SNS_REWARD_CHECKPOINT_MAX_NEURONS,
18 SNS_REWARD_CHECKPOINT_PAGE_SIZE, SNS_REWARD_CHECKPOINT_REPORT_SCHEMA_VERSION,
19 model::reports::{SnsGovernanceParameters, SnsRunningVersionResponse},
20 },
21 subnet_catalog::{MAINNET_NETWORK, format_utc_timestamp_secs},
22};
23use candid::{CandidType, Deserialize, Principal};
24use serde::Serialize;
25use std::collections::HashSet;
26use thiserror::Error as ThisError;
27
28#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize, Serialize)]
35#[serde(deny_unknown_fields)]
36pub struct SnsRewardProposalId {
37 pub id: u64,
39}
40
41#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct SnsRewardEvent {
50 pub rounds_since_last_distribution: Option<u64>,
52 pub actual_timestamp_seconds: u64,
54 pub end_timestamp_seconds: Option<u64>,
56 pub total_available_e8s_equivalent: Option<u64>,
58 pub distributed_e8s_equivalent: u64,
60 pub round: u64,
62 pub settled_proposals: Vec<SnsRewardProposalId>,
64}
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
73#[serde(rename_all = "snake_case")]
74pub enum SnsRewardCollectionStatus {
75 ApiExhaustedObserved,
77}
78
79impl SnsRewardCollectionStatus {
80 #[must_use]
82 pub const fn as_str(self) -> &'static str {
83 match self {
84 Self::ApiExhaustedObserved => "api_exhausted_observed",
85 }
86 }
87}
88
89#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
96#[serde(deny_unknown_fields)]
97pub struct SnsRewardCheckpointRow {
98 pub neuron_id: String,
100 pub created_timestamp_seconds: u64,
102 pub maturity_e8s_equivalent: u64,
104 pub staked_maturity_e8s_equivalent: Option<u64>,
106 pub combined_maturity_e8s_equivalent: u64,
108 pub auto_stake_maturity: Option<bool>,
110 pub permissions: Vec<SnsNeuronPermissionRow>,
112 pub disburse_maturity_in_progress: Vec<SnsMaturityDisbursementRow>,
114 pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
116 pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
118}
119
120impl SnsRewardCheckpointRow {
121 #[must_use]
123 pub fn checked_combined_maturity(&self) -> Option<u64> {
124 self.maturity_e8s_equivalent
125 .checked_add(self.staked_maturity_e8s_equivalent.unwrap_or(0))
126 }
127
128 #[must_use]
130 pub fn derived_policy_observations(
131 &self,
132 ) -> (SnsPolicyObservationStatus, SnsPolicyObservationStatus) {
133 neuron_policy_observations(
134 &self.permissions,
135 !self.disburse_maturity_in_progress.is_empty(),
136 )
137 }
138}
139
140#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
147#[serde(deny_unknown_fields)]
148pub struct SnsRewardCheckpointReport {
149 pub schema_version: u32,
151 pub network: String,
153 pub sns_wasm_canister_id: String,
155 pub source_endpoint: String,
157 pub fetched_by: String,
159 pub id: usize,
161 pub name: String,
163 pub root_canister_id: String,
165 pub governance_canister_id: String,
167 pub ledger_canister_id: String,
169 pub swap_canister_id: String,
171 pub index_canister_id: String,
173 pub data_source: ReportDataSource,
175 pub collection_started_at_unix_secs: u64,
177 pub collection_started_at: String,
179 pub collection_completed_at_unix_secs: u64,
181 pub collection_completed_at: String,
183 pub page_size: u32,
185 pub page_count: u32,
187 pub row_count: usize,
189 pub unique_neuron_id_count: usize,
191 pub collection_row_ceiling: u64,
193 pub client_query_count: u32,
195 pub collection_status: SnsRewardCollectionStatus,
197 pub point_in_time_guaranteed: bool,
199 pub parameters_before: SnsGovernanceParameters,
201 pub parameters_after: SnsGovernanceParameters,
203 pub reward_event_before: SnsRewardEvent,
205 pub reward_event_after: SnsRewardEvent,
207 pub running_version_before: SnsRunningVersionResponse,
209 pub running_version_after: SnsRunningVersionResponse,
211 pub aggregate_maturity_e8s_equivalent: u64,
213 pub aggregate_staked_maturity_e8s_equivalent: u64,
215 pub aggregate_combined_maturity_e8s_equivalent: u64,
217 pub permission_entry_count: usize,
219 pub unassessable_permission_code_count: usize,
221 pub pending_maturity_disbursement_count: usize,
223 pub auto_stake_maturity_enabled_count: usize,
225 pub auto_stake_maturity_disabled_count: usize,
227 pub auto_stake_maturity_unspecified_count: usize,
229 pub manage_principals_grantable: Option<bool>,
231 pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
233 pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
235 pub maturity_conversion_policy_observed_status: SnsPolicyObservationStatus,
237 pub rows: Vec<SnsRewardCheckpointRow>,
239}
240
241#[derive(Debug, Eq, PartialEq, ThisError)]
248#[error("invalid SNS reward checkpoint: {reason}")]
249pub struct SnsRewardCheckpointValidationError {
250 pub reason: String,
252}
253
254pub fn validate_sns_reward_checkpoint_report(
256 report: &SnsRewardCheckpointReport,
257) -> Result<(), SnsRewardCheckpointValidationError> {
258 validate_checkpoint_header(report)?;
259 validate_checkpoint_brackets(report)?;
260 validate_checkpoint_rows(&report.rows, report.collection_completed_at_unix_secs)?;
261 let summary = recompute_reward_checkpoint_summary(&report.parameters_before, &report.rows)
262 .map_err(invalid_validation)?;
263 validate_checkpoint_summary(report, &summary)
264}
265
266fn validate_checkpoint_header(
267 report: &SnsRewardCheckpointReport,
268) -> Result<(), SnsRewardCheckpointValidationError> {
269 if report.schema_version != SNS_REWARD_CHECKPOINT_REPORT_SCHEMA_VERSION {
270 return Err(invalid_validation(format!(
271 "schema version {} does not equal {}",
272 report.schema_version, SNS_REWARD_CHECKPOINT_REPORT_SCHEMA_VERSION
273 )));
274 }
275 for (field, principal) in [
276 ("sns_wasm_canister_id", report.sns_wasm_canister_id.as_str()),
277 ("root_canister_id", report.root_canister_id.as_str()),
278 (
279 "governance_canister_id",
280 report.governance_canister_id.as_str(),
281 ),
282 ("ledger_canister_id", report.ledger_canister_id.as_str()),
283 ("swap_canister_id", report.swap_canister_id.as_str()),
284 ("index_canister_id", report.index_canister_id.as_str()),
285 ] {
286 validate_principal(field, principal)?;
287 }
288 let canister_ids = [
289 report.sns_wasm_canister_id.as_str(),
290 report.root_canister_id.as_str(),
291 report.governance_canister_id.as_str(),
292 report.ledger_canister_id.as_str(),
293 report.swap_canister_id.as_str(),
294 report.index_canister_id.as_str(),
295 ];
296 if canister_ids.into_iter().collect::<HashSet<_>>().len() != canister_ids.len() {
297 return Err(invalid_validation(
298 "checkpoint canister roles do not contain unique principals",
299 ));
300 }
301 if report.sns_wasm_canister_id != MAINNET_SNS_WASM_CANISTER_ID {
302 return Err(invalid_validation("unexpected SNS-W canister identity"));
303 }
304 if report.network != MAINNET_NETWORK {
305 return Err(invalid_validation("checkpoint network must be ic"));
306 }
307 if report.source_endpoint.is_empty() || report.fetched_by.is_empty() {
308 return Err(invalid_validation(
309 "checkpoint source_endpoint and fetched_by must be non-empty",
310 ));
311 }
312 if report.id == 0 || report.name.is_empty() {
313 return Err(invalid_validation(
314 "checkpoint SNS list id and display name must be non-empty",
315 ));
316 }
317 if report.data_source != ReportDataSource::Live {
318 return Err(invalid_validation("checkpoint data_source must be live"));
319 }
320 if report.collection_status != SnsRewardCollectionStatus::ApiExhaustedObserved {
321 return Err(invalid_validation(
322 "checkpoint collection is not API-exhausted",
323 ));
324 }
325 if report.point_in_time_guaranteed {
326 return Err(invalid_validation(
327 "checkpoint cannot claim a point-in-time neuron snapshot",
328 ));
329 }
330 validate_checkpoint_collection_metadata(report)
331}
332
333fn validate_checkpoint_collection_metadata(
334 report: &SnsRewardCheckpointReport,
335) -> Result<(), SnsRewardCheckpointValidationError> {
336 if report.collection_completed_at_unix_secs < report.collection_started_at_unix_secs {
337 return Err(invalid_validation(
338 "collection completion precedes collection start",
339 ));
340 }
341 if report.collection_started_at
342 != format_utc_timestamp_secs(report.collection_started_at_unix_secs)
343 || report.collection_completed_at
344 != format_utc_timestamp_secs(report.collection_completed_at_unix_secs)
345 {
346 return Err(invalid_validation(
347 "collection UTC timestamps do not match raw Unix timestamps",
348 ));
349 }
350 if report.page_size != SNS_REWARD_CHECKPOINT_PAGE_SIZE || report.page_count == 0 {
351 return Err(invalid_validation(
352 "checkpoint must contain at least one fixed-size-100 page",
353 ));
354 }
355 let expected_query_count = report
356 .page_count
357 .checked_add(8)
358 .ok_or_else(|| invalid_validation("page_count + 8 exceeds the native u32 contract"))?;
359 if report.client_query_count != expected_query_count {
360 return Err(invalid_validation(
361 "client_query_count does not equal page_count + 8",
362 ));
363 }
364 if report.row_count != report.rows.len() || report.unique_neuron_id_count != report.rows.len() {
365 return Err(invalid_validation(
366 "serialized row counts do not match raw checkpoint rows",
367 ));
368 }
369 let max_neurons = report.parameters_before.max_number_of_neurons;
370 if max_neurons != Some(report.collection_row_ceiling)
371 || !(1..=SNS_REWARD_CHECKPOINT_MAX_NEURONS).contains(&report.collection_row_ceiling)
372 {
373 return Err(invalid_validation(
374 "collection_row_ceiling does not match a valid max_number_of_neurons",
375 ));
376 }
377 if u64::try_from(report.rows.len())
378 .ok()
379 .is_none_or(|rows| rows > report.collection_row_ceiling)
380 {
381 return Err(invalid_validation(
382 "raw row count exceeds the mandatory collection ceiling",
383 ));
384 }
385 Ok(())
386}
387
388fn validate_checkpoint_brackets(
389 report: &SnsRewardCheckpointReport,
390) -> Result<(), SnsRewardCheckpointValidationError> {
391 if report.parameters_before != report.parameters_after {
392 return Err(invalid_validation(
393 "nervous-system parameter brackets differ",
394 ));
395 }
396 if report.reward_event_before != report.reward_event_after {
397 return Err(invalid_validation("reward-event brackets differ"));
398 }
399 if report.running_version_before != report.running_version_after {
400 return Err(invalid_validation("running-version brackets differ"));
401 }
402 validate_sns_reward_event_evidence(&report.reward_event_after).map_err(invalid_validation)?;
403 validate_reward_event_position(report)?;
404 validate_sns_reward_running_version_evidence(&report.running_version_after)
405 .map_err(invalid_validation)?;
406 validate_sns_reward_checkpoint_parameter_evidence(&report.parameters_after)
407 .map_err(invalid_validation)
408}
409
410fn validate_reward_event_position(
411 report: &SnsRewardCheckpointReport,
412) -> Result<(), SnsRewardCheckpointValidationError> {
413 let event = &report.reward_event_after;
414 if event.actual_timestamp_seconds > report.collection_completed_at_unix_secs
415 || event
416 .end_timestamp_seconds
417 .is_none_or(|end| end > report.collection_completed_at_unix_secs)
418 {
419 return Err(invalid_validation(
420 "reward event timestamps exceed checkpoint collection completion",
421 ));
422 }
423 Ok(())
424}
425
426fn validate_checkpoint_rows(
427 rows: &[SnsRewardCheckpointRow],
428 collection_completed_at_unix_secs: u64,
429) -> Result<(), SnsRewardCheckpointValidationError> {
430 let mut previous_id: Option<&str> = None;
431 for row in rows {
432 if row.neuron_id.len() != 64 || !is_canonical_lowercase_hex(&row.neuron_id) {
433 return Err(invalid_validation(format!(
434 "neuron id {} is not 32-byte lowercase hexadecimal text",
435 row.neuron_id
436 )));
437 }
438 if previous_id.is_some_and(|previous| previous >= row.neuron_id.as_str()) {
439 return Err(invalid_validation(
440 "checkpoint neuron ids are not strictly increasing",
441 ));
442 }
443 previous_id = Some(&row.neuron_id);
444 if row.created_timestamp_seconds > collection_completed_at_unix_secs {
445 return Err(invalid_validation(format!(
446 "neuron {} creation timestamp exceeds collection completion",
447 row.neuron_id
448 )));
449 }
450 validate_checkpoint_row_evidence(row)?;
451 }
452 Ok(())
453}
454
455fn validate_checkpoint_row_evidence(
456 row: &SnsRewardCheckpointRow,
457) -> Result<(), SnsRewardCheckpointValidationError> {
458 let mut principals = HashSet::new();
459 for permission in &row.permissions {
460 if let Some(principal) = permission.principal.as_deref() {
461 validate_principal("permission principal", principal)?;
462 if !principals.insert(principal) {
463 return Err(invalid_validation(format!(
464 "neuron {} contains duplicate permission principal {principal}",
465 row.neuron_id
466 )));
467 }
468 }
469 let mut codes = HashSet::new();
470 for value in &permission.permission_types {
471 if value.name != super::sns_neuron_permission_name(value.code)
472 || !codes.insert(value.code)
473 {
474 return Err(invalid_validation(format!(
475 "neuron {} contains invalid or duplicate permission code {}",
476 row.neuron_id, value.code
477 )));
478 }
479 }
480 }
481 for disbursement in &row.disburse_maturity_in_progress {
482 if let Some(account) = disbursement.account_to_disburse_to.as_ref() {
483 if let Some(owner) = account.owner.as_deref() {
484 validate_principal("pending disbursement owner", owner)?;
485 }
486 if let Some(subaccount) = account.subaccount_hex.as_deref()
487 && (subaccount.len() != 64 || !is_lowercase_hex(subaccount))
488 {
489 return Err(invalid_validation(
490 "pending disbursement subaccount is not 32-byte lowercase hexadecimal text",
491 ));
492 }
493 }
494 }
495 Ok(())
496}
497
498fn validate_checkpoint_summary(
499 report: &SnsRewardCheckpointReport,
500 summary: &SnsRewardCheckpointSummary,
501) -> Result<(), SnsRewardCheckpointValidationError> {
502 let valid = report.aggregate_maturity_e8s_equivalent
503 == summary.aggregate_maturity_e8s_equivalent
504 && report.aggregate_staked_maturity_e8s_equivalent
505 == summary.aggregate_staked_maturity_e8s_equivalent
506 && report.aggregate_combined_maturity_e8s_equivalent
507 == summary.aggregate_combined_maturity_e8s_equivalent
508 && report.permission_entry_count == summary.permission_entry_count
509 && report.unassessable_permission_code_count == summary.unassessable_permission_code_count
510 && report.pending_maturity_disbursement_count
511 == summary.pending_maturity_disbursement_count
512 && report.auto_stake_maturity_enabled_count == summary.auto_stake_maturity_enabled_count
513 && report.auto_stake_maturity_disabled_count == summary.auto_stake_maturity_disabled_count
514 && report.auto_stake_maturity_unspecified_count
515 == summary.auto_stake_maturity_unspecified_count
516 && report.manage_principals_grantable == summary.manage_principals_grantable
517 && report.maturity_mint_conversion_observed_disabled
518 == summary.maturity_mint_conversion_observed_disabled
519 && report.manual_maturity_staking_observed_disabled
520 == summary.manual_maturity_staking_observed_disabled
521 && report.maturity_conversion_policy_observed_status
522 == summary.maturity_conversion_policy_observed_status;
523 if valid {
524 Ok(())
525 } else {
526 Err(invalid_validation(
527 "serialized checkpoint summary does not match raw evidence",
528 ))
529 }
530}
531
532pub(in crate::sns::report) fn validate_sns_reward_running_version_evidence(
533 response: &SnsRunningVersionResponse,
534) -> Result<(), String> {
535 let deployed = response
536 .deployed_version
537 .as_ref()
538 .ok_or_else(|| "running version has no deployed_version".to_string())?;
539 validate_version("deployed_version", deployed)?;
540 if let Some(target) = response
541 .pending_version
542 .as_ref()
543 .and_then(|pending| pending.target_version.as_ref())
544 {
545 validate_version("pending_version.target_version", target)?;
546 }
547 Ok(())
548}
549
550fn validate_version(field: &str, version: &crate::sns::report::SnsVersion) -> Result<(), String> {
551 for (role, hash) in [
552 ("archive", version.archive_wasm_hash_hex.as_str()),
553 ("root", version.root_wasm_hash_hex.as_str()),
554 ("swap", version.swap_wasm_hash_hex.as_str()),
555 ("ledger", version.ledger_wasm_hash_hex.as_str()),
556 ("governance", version.governance_wasm_hash_hex.as_str()),
557 ("index", version.index_wasm_hash_hex.as_str()),
558 ] {
559 if hash.len() != 64 || !is_canonical_lowercase_hex(hash) {
560 return Err(format!(
561 "{field}.{role}_wasm_hash_hex is not a 32-byte lowercase hexadecimal hash"
562 ));
563 }
564 }
565 Ok(())
566}
567
568pub(in crate::sns::report) fn validate_sns_reward_checkpoint_parameter_evidence(
569 parameters: &SnsGovernanceParameters,
570) -> Result<(), String> {
571 validate_default_followees(parameters)?;
572 validate_parameter_permissions(parameters)
573}
574
575fn validate_default_followees(parameters: &SnsGovernanceParameters) -> Result<(), String> {
576 let Some(defaults) = parameters.default_followees.as_ref() else {
577 return Ok(());
578 };
579 let mut function_ids = HashSet::new();
580 for row in &defaults.followees {
581 if !function_ids.insert(row.function_id) {
582 return Err("default followees contain a duplicate function id".to_string());
583 }
584 let mut neuron_ids = HashSet::new();
585 for id in &row.followee_neuron_ids {
586 if id.len() != 64 || !is_lowercase_hex(id) {
587 return Err("default followees contain a non-canonical neuron id".to_string());
588 }
589 if !neuron_ids.insert(id) {
590 return Err("default followees contain a duplicate neuron id".to_string());
591 }
592 }
593 }
594 Ok(())
595}
596
597fn validate_parameter_permissions(parameters: &SnsGovernanceParameters) -> Result<(), String> {
598 for (field, permissions) in [
599 (
600 "neuron_claimer_permissions",
601 parameters.neuron_claimer_permissions.as_ref(),
602 ),
603 (
604 "neuron_grantable_permissions",
605 parameters.neuron_grantable_permissions.as_ref(),
606 ),
607 ] {
608 let Some(permissions) = permissions else {
609 continue;
610 };
611 let mut codes = HashSet::new();
612 if permissions
613 .permissions
614 .iter()
615 .any(|code| !codes.insert(*code))
616 {
617 return Err(format!("{field} contains a duplicate permission code"));
618 }
619 }
620 Ok(())
621}
622
623pub(in crate::sns::report) fn validate_sns_reward_event_evidence(
624 event: &SnsRewardEvent,
625) -> Result<(), String> {
626 if event.end_timestamp_seconds.is_none() {
627 return Err("reward event is missing canonical end_timestamp_seconds".to_string());
628 }
629 let mut proposal_ids = HashSet::new();
630 if event
631 .settled_proposals
632 .iter()
633 .any(|proposal| !proposal_ids.insert(proposal.id))
634 {
635 return Err("reward event contains duplicate settled proposal ids".to_string());
636 }
637 Ok(())
638}
639
640fn validate_principal(field: &str, value: &str) -> Result<(), SnsRewardCheckpointValidationError> {
641 let principal = Principal::from_text(value)
642 .map_err(|error| invalid_validation(format!("{field} {value} is invalid: {error}")))?;
643 if principal.to_text() == value {
644 Ok(())
645 } else {
646 Err(invalid_validation(format!(
647 "{field} {value} is not canonical principal text"
648 )))
649 }
650}
651
652fn invalid_validation(reason: impl Into<String>) -> SnsRewardCheckpointValidationError {
653 SnsRewardCheckpointValidationError {
654 reason: reason.into(),
655 }
656}
657
658pub(in crate::sns::report) struct SnsRewardCheckpointSummary {
665 pub(in crate::sns::report) aggregate_maturity_e8s_equivalent: u64,
666 pub(in crate::sns::report) aggregate_staked_maturity_e8s_equivalent: u64,
667 pub(in crate::sns::report) aggregate_combined_maturity_e8s_equivalent: u64,
668 pub(in crate::sns::report) permission_entry_count: usize,
669 pub(in crate::sns::report) unassessable_permission_code_count: usize,
670 pub(in crate::sns::report) pending_maturity_disbursement_count: usize,
671 pub(in crate::sns::report) auto_stake_maturity_enabled_count: usize,
672 pub(in crate::sns::report) auto_stake_maturity_disabled_count: usize,
673 pub(in crate::sns::report) auto_stake_maturity_unspecified_count: usize,
674 pub(in crate::sns::report) manage_principals_grantable: Option<bool>,
675 pub(in crate::sns::report) maturity_mint_conversion_observed_disabled:
676 SnsPolicyObservationStatus,
677 pub(in crate::sns::report) manual_maturity_staking_observed_disabled:
678 SnsPolicyObservationStatus,
679 pub(in crate::sns::report) maturity_conversion_policy_observed_status:
680 SnsPolicyObservationStatus,
681}
682
683pub(in crate::sns::report) fn recompute_reward_checkpoint_summary(
684 parameters: &SnsGovernanceParameters,
685 rows: &[SnsRewardCheckpointRow],
686) -> Result<SnsRewardCheckpointSummary, String> {
687 let mut summary = SnsRewardCheckpointSummary {
688 aggregate_maturity_e8s_equivalent: 0,
689 aggregate_staked_maturity_e8s_equivalent: 0,
690 aggregate_combined_maturity_e8s_equivalent: 0,
691 permission_entry_count: 0,
692 unassessable_permission_code_count: 0,
693 pending_maturity_disbursement_count: 0,
694 auto_stake_maturity_enabled_count: 0,
695 auto_stake_maturity_disabled_count: 0,
696 auto_stake_maturity_unspecified_count: 0,
697 manage_principals_grantable: parameters
698 .neuron_grantable_permissions
699 .as_ref()
700 .map(|permissions| permissions.permissions.contains(&2)),
701 maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus::ObservedSatisfied,
702 manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus::ObservedSatisfied,
703 maturity_conversion_policy_observed_status: SnsPolicyObservationStatus::ObservedSatisfied,
704 };
705 let (parameter_mint, parameter_staking, parameter_unknown_count) =
706 parameter_policy_observations(parameters);
707 summary.maturity_mint_conversion_observed_disabled = parameter_mint;
708 summary.manual_maturity_staking_observed_disabled = parameter_staking;
709 summary.unassessable_permission_code_count = parameter_unknown_count;
710
711 for row in rows {
712 accumulate_reward_row(&mut summary, row)?;
713 }
714 summary.maturity_conversion_policy_observed_status = summary
715 .maturity_mint_conversion_observed_disabled
716 .combine(summary.manual_maturity_staking_observed_disabled);
717 Ok(summary)
718}
719
720fn accumulate_reward_row(
721 summary: &mut SnsRewardCheckpointSummary,
722 row: &SnsRewardCheckpointRow,
723) -> Result<(), String> {
724 let combined = row.checked_combined_maturity().ok_or_else(|| {
725 format!(
726 "neuron {} combined maturity exceeds the native u64 contract",
727 row.neuron_id
728 )
729 })?;
730 if row.combined_maturity_e8s_equivalent != combined {
731 return Err(format!(
732 "neuron {} combined maturity does not match raw components",
733 row.neuron_id
734 ));
735 }
736 let (row_mint, row_staking) = row.derived_policy_observations();
737 if row.maturity_mint_conversion_observed_disabled != row_mint
738 || row.manual_maturity_staking_observed_disabled != row_staking
739 {
740 return Err(format!(
741 "neuron {} policy observations do not match raw evidence",
742 row.neuron_id
743 ));
744 }
745 summary.aggregate_maturity_e8s_equivalent = checked_sum(
746 summary.aggregate_maturity_e8s_equivalent,
747 row.maturity_e8s_equivalent,
748 "aggregate maturity",
749 )?;
750 summary.aggregate_staked_maturity_e8s_equivalent = checked_sum(
751 summary.aggregate_staked_maturity_e8s_equivalent,
752 row.staked_maturity_e8s_equivalent.unwrap_or(0),
753 "aggregate staked maturity",
754 )?;
755 summary.aggregate_combined_maturity_e8s_equivalent = checked_sum(
756 summary.aggregate_combined_maturity_e8s_equivalent,
757 combined,
758 "aggregate combined maturity",
759 )?;
760 accumulate_reward_row_counts(summary, row)?;
761 summary.maturity_mint_conversion_observed_disabled = summary
762 .maturity_mint_conversion_observed_disabled
763 .combine(row_mint);
764 summary.manual_maturity_staking_observed_disabled = summary
765 .manual_maturity_staking_observed_disabled
766 .combine(row_staking);
767 Ok(())
768}
769
770fn accumulate_reward_row_counts(
771 summary: &mut SnsRewardCheckpointSummary,
772 row: &SnsRewardCheckpointRow,
773) -> Result<(), String> {
774 summary.permission_entry_count = summary
775 .permission_entry_count
776 .checked_add(row.permissions.len())
777 .ok_or_else(|| "permission entry count overflow".to_string())?;
778 summary.pending_maturity_disbursement_count = summary
779 .pending_maturity_disbursement_count
780 .checked_add(row.disburse_maturity_in_progress.len())
781 .ok_or_else(|| "pending maturity disbursement count overflow".to_string())?;
782 summary.unassessable_permission_code_count = summary
783 .unassessable_permission_code_count
784 .checked_add(
785 row.permissions
786 .iter()
787 .flat_map(|permission| permission.permission_types.iter())
788 .filter(|value| !(1..=10).contains(&value.code))
789 .count(),
790 )
791 .ok_or_else(|| "unassessable permission code count overflow".to_string())?;
792 match row.auto_stake_maturity {
793 Some(true) => increment(&mut summary.auto_stake_maturity_enabled_count)?,
794 Some(false) => increment(&mut summary.auto_stake_maturity_disabled_count)?,
795 None => increment(&mut summary.auto_stake_maturity_unspecified_count)?,
796 }
797 Ok(())
798}
799
800fn parameter_policy_observations(
801 parameters: &SnsGovernanceParameters,
802) -> (
803 SnsPolicyObservationStatus,
804 SnsPolicyObservationStatus,
805 usize,
806) {
807 let mut mint = SnsPolicyObservationStatus::ObservedSatisfied;
808 let mut staking = SnsPolicyObservationStatus::ObservedSatisfied;
809 let mut unknown_count = 0;
810 for permissions in [
811 parameters.neuron_claimer_permissions.as_ref(),
812 parameters.neuron_grantable_permissions.as_ref(),
813 ] {
814 let Some(permissions) = permissions else {
815 mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
816 staking = staking.combine(SnsPolicyObservationStatus::Unassessable);
817 continue;
818 };
819 if permissions.permissions.is_empty() {
820 mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
821 staking = staking.combine(SnsPolicyObservationStatus::Unassessable);
822 }
823 for code in &permissions.permissions {
824 let (code_mint, code_staking) = permission_code_policy_observations(*code);
825 mint = mint.combine(code_mint);
826 staking = staking.combine(code_staking);
827 if !(1..=10).contains(code) {
828 unknown_count += 1;
829 }
830 }
831 }
832 (mint, staking, unknown_count)
833}
834
835fn checked_sum(left: u64, right: u64, field: &str) -> Result<u64, String> {
836 left.checked_add(right)
837 .ok_or_else(|| format!("{field} exceeds the native u64 contract"))
838}
839
840fn increment(value: &mut usize) -> Result<(), String> {
841 *value = value
842 .checked_add(1)
843 .ok_or_else(|| "checkpoint count overflow".to_string())?;
844 Ok(())
845}