Skip to main content

ic_query/sns/report/model/reports/neurons/
reward.rs

1//! Module: sns::report::model::reports::neurons::reward
2//!
3//! Responsibility: SNS reward-event and API-exhausted maturity checkpoint DTOs.
4//! Does not own: live Governance calls, strict pagination, or filesystem loading.
5//! Boundary: retains raw bracketing evidence, variable neuron evidence, and recomputed summaries.
6
7use 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///
29/// SnsRewardProposalId
30///
31/// Native proposal identifier retained in one Governance reward event.
32///
33
34#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize, Serialize)]
35#[serde(deny_unknown_fields)]
36pub struct SnsRewardProposalId {
37    /// Native SNS Governance proposal identifier.
38    pub id: u64,
39}
40
41///
42/// SnsRewardEvent
43///
44/// Complete native SNS Governance reward-event response.
45///
46
47#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct SnsRewardEvent {
50    /// Number of reward rounds represented by this event when supplied.
51    pub rounds_since_last_distribution: Option<u64>,
52    /// Native timestamp at which distribution actually ran.
53    pub actual_timestamp_seconds: u64,
54    /// Canonical reward-event position when supplied.
55    pub end_timestamp_seconds: Option<u64>,
56    /// Total maturity available for distribution when supplied.
57    pub total_available_e8s_equivalent: Option<u64>,
58    /// Exact maturity amount distributed by this event.
59    pub distributed_e8s_equivalent: u64,
60    /// Deprecated native reward round retained as continuity evidence.
61    pub round: u64,
62    /// Complete settled-proposal identifiers retained from Governance.
63    pub settled_proposals: Vec<SnsRewardProposalId>,
64}
65
66///
67/// SnsRewardCollectionStatus
68///
69/// Authority level of one completed SNS reward checkpoint collection.
70///
71
72#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
73#[serde(rename_all = "snake_case")]
74pub enum SnsRewardCollectionStatus {
75    /// The native neuron API was exhausted with stable bracket responses.
76    ApiExhaustedObserved,
77}
78
79impl SnsRewardCollectionStatus {
80    /// Return the stable report label for this collection status.
81    #[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///
90/// SnsRewardCheckpointRow
91///
92/// Variable-size maturity and permission evidence for one observed SNS neuron.
93///
94
95#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
96#[serde(deny_unknown_fields)]
97pub struct SnsRewardCheckpointRow {
98    /// Exact 32-byte neuron identifier as lowercase hexadecimal text.
99    pub neuron_id: String,
100    /// Unix timestamp at which Governance reports the neuron was created.
101    pub created_timestamp_seconds: u64,
102    /// Raw unstaked maturity in e8s-equivalent units.
103    pub maturity_e8s_equivalent: u64,
104    /// Raw staked maturity in e8s-equivalent units when present.
105    pub staked_maturity_e8s_equivalent: Option<u64>,
106    /// Checked sum of unstaked and staked maturity.
107    pub combined_maturity_e8s_equivalent: u64,
108    /// Native automatic maturity-staking state when supplied.
109    pub auto_stake_maturity: Option<bool>,
110    /// Every current principal permission entry returned by Governance.
111    pub permissions: Vec<SnsNeuronPermissionRow>,
112    /// Every pending maturity disbursement returned by Governance.
113    pub disburse_maturity_in_progress: Vec<SnsMaturityDisbursementRow>,
114    /// Neuron-local observation for permissions 7 and 8 plus pending disbursements.
115    pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
116    /// Neuron-local observation for manual maturity-staking permission 9.
117    pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
118}
119
120impl SnsRewardCheckpointRow {
121    /// Recompute combined maturity from the two raw maturity components.
122    #[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    /// Recompute both row-local maturity policy observations from raw evidence.
129    #[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///
141/// SnsRewardCheckpointReport
142///
143/// Versioned API-exhausted observed SNS maturity checkpoint.
144///
145
146#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
147#[serde(deny_unknown_fields)]
148pub struct SnsRewardCheckpointReport {
149    /// Report schema version.
150    pub schema_version: u32,
151    /// Requested IC network identity.
152    pub network: String,
153    /// Stable mainnet SNS-W discovery canister identity.
154    pub sns_wasm_canister_id: String,
155    /// Explicit IC API endpoint used for every live call.
156    pub source_endpoint: String,
157    /// Collector identity recorded in report provenance.
158    pub fetched_by: String,
159    /// Current SNS-W list position retained only as display metadata.
160    pub id: usize,
161    /// Current SNS name retained only as display metadata.
162    pub name: String,
163    /// Stable SNS Root canister identity.
164    pub root_canister_id: String,
165    /// Stable SNS Governance canister identity.
166    pub governance_canister_id: String,
167    /// Stable SNS ledger canister identity.
168    pub ledger_canister_id: String,
169    /// Stable SNS decentralization-swap canister identity.
170    pub swap_canister_id: String,
171    /// Stable SNS ledger-index canister identity.
172    pub index_canister_id: String,
173    /// Explicit source classification; checkpoint reports are live-only.
174    pub data_source: ReportDataSource,
175    /// Unix timestamp captured immediately before targeted discovery.
176    pub collection_started_at_unix_secs: u64,
177    /// UTC rendering of `collection_started_at_unix_secs`.
178    pub collection_started_at: String,
179    /// Unix timestamp captured after the final running-version bracket.
180    pub collection_completed_at_unix_secs: u64,
181    /// UTC rendering of `collection_completed_at_unix_secs`.
182    pub collection_completed_at: String,
183    /// Fixed native page size used for every Governance neuron query.
184    pub page_size: u32,
185    /// Number of neuron pages fetched, including the final exhaustion page.
186    pub page_count: u32,
187    /// Number of neuron rows retained.
188    pub row_count: usize,
189    /// Recomputed number of unique full neuron identifiers.
190    pub unique_neuron_id_count: usize,
191    /// Mandatory row ceiling derived from Governance parameters.
192    pub collection_row_ceiling: u64,
193    /// Total targeted-discovery, bracket, and neuron-page client queries.
194    pub client_query_count: u32,
195    /// Explicit authority classification for the completed collection.
196    pub collection_status: SnsRewardCollectionStatus,
197    /// Always false because Governance exposes no point-in-time neuron snapshot version.
198    pub point_in_time_guaranteed: bool,
199    /// Complete nervous-system parameters read before neuron pagination.
200    pub parameters_before: SnsGovernanceParameters,
201    /// Complete nervous-system parameters read after neuron pagination.
202    pub parameters_after: SnsGovernanceParameters,
203    /// Complete reward event read before neuron pagination.
204    pub reward_event_before: SnsRewardEvent,
205    /// Complete reward event read after neuron pagination.
206    pub reward_event_after: SnsRewardEvent,
207    /// Complete running-version response read before neuron pagination.
208    pub running_version_before: SnsRunningVersionResponse,
209    /// Complete running-version response read after neuron pagination.
210    pub running_version_after: SnsRunningVersionResponse,
211    /// Checked aggregate unstaked maturity across every row.
212    pub aggregate_maturity_e8s_equivalent: u64,
213    /// Checked aggregate staked maturity across every row.
214    pub aggregate_staked_maturity_e8s_equivalent: u64,
215    /// Checked aggregate combined maturity across every row.
216    pub aggregate_combined_maturity_e8s_equivalent: u64,
217    /// Number of principal permission entries observed across every row.
218    pub permission_entry_count: usize,
219    /// Number of raw unknown or anomalous permission codes observed globally and per-neuron.
220    pub unassessable_permission_code_count: usize,
221    /// Number of pending maturity disbursements observed across every row.
222    pub pending_maturity_disbursement_count: usize,
223    /// Number of neurons with automatic maturity staking enabled.
224    pub auto_stake_maturity_enabled_count: usize,
225    /// Number of neurons with automatic maturity staking explicitly disabled.
226    pub auto_stake_maturity_disabled_count: usize,
227    /// Number of neurons without an automatic maturity-staking value.
228    pub auto_stake_maturity_unspecified_count: usize,
229    /// Whether permission 2 is currently grantable, or unknown when the list is missing.
230    pub manage_principals_grantable: Option<bool>,
231    /// Global observation for disabling permissions 7 and 8 and pending disbursements.
232    pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
233    /// Global observation for disabling manual maturity-staking permission 9.
234    pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
235    /// Combined observed maturity-conversion policy status.
236    pub maturity_conversion_policy_observed_status: SnsPolicyObservationStatus,
237    /// Strictly increasing full neuron rows retained as raw checkpoint evidence.
238    pub rows: Vec<SnsRewardCheckpointRow>,
239}
240
241///
242/// SnsRewardCheckpointValidationError
243///
244/// Pure validation failure for untrusted serialized or in-memory checkpoint evidence.
245///
246
247#[derive(Debug, Eq, PartialEq, ThisError)]
248#[error("invalid SNS reward checkpoint: {reason}")]
249pub struct SnsRewardCheckpointValidationError {
250    /// Deterministic invariant failure.
251    pub reason: String,
252}
253
254/// Recompute and validate every checkpoint invariant available without live host calls.
255pub 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
658///
659/// SnsRewardCheckpointSummary
660///
661/// Pure recomputation result derived from raw checkpoint parameters and rows.
662///
663
664pub(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}