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