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