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,
11        permission_code_policy_observations, validate_canonical_principal,
12        validate_maturity_disbursements, validate_neuron_permissions,
13    },
14};
15use crate::{
16    hex::{is_canonical_lowercase_hex, is_lowercase_hex},
17    report::ReportDataSource,
18    sns::report::{
19        MAINNET_SNS_WASM_CANISTER_ID, SNS_REWARD_CHECKPOINT_MAX_NEURONS,
20        SNS_REWARD_CHECKPOINT_PAGE_SIZE, SNS_REWARD_CHECKPOINT_REPORT_SCHEMA_VERSION,
21        model::reports::{SnsGovernanceParameters, SnsRunningVersionResponse},
22    },
23    subnet_catalog::{MAINNET_NETWORK, format_utc_timestamp_secs},
24};
25use candid::{CandidType, Deserialize};
26use serde::Serialize;
27use std::collections::HashSet;
28use thiserror::Error as ThisError;
29
30///
31/// SnsRewardProposalId
32///
33/// Native proposal identifier retained in one Governance reward event.
34///
35
36#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize, Serialize)]
37#[serde(deny_unknown_fields)]
38pub struct SnsRewardProposalId {
39    /// Native SNS Governance proposal identifier.
40    pub id: u64,
41}
42
43///
44/// SnsRewardEvent
45///
46/// Complete native SNS Governance reward-event response.
47///
48
49#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize, Serialize)]
50#[serde(deny_unknown_fields)]
51pub struct SnsRewardEvent {
52    /// Number of reward rounds represented by this event when supplied.
53    pub rounds_since_last_distribution: Option<u64>,
54    /// Native timestamp at which distribution actually ran.
55    pub actual_timestamp_seconds: u64,
56    /// Canonical reward-event position when supplied.
57    pub end_timestamp_seconds: Option<u64>,
58    /// Total maturity available for distribution when supplied.
59    pub total_available_e8s_equivalent: Option<u64>,
60    /// Exact maturity amount distributed by this event.
61    pub distributed_e8s_equivalent: u64,
62    /// Deprecated native reward round retained as continuity evidence.
63    pub round: u64,
64    /// Complete settled-proposal identifiers retained from Governance.
65    pub settled_proposals: Vec<SnsRewardProposalId>,
66}
67
68///
69/// SnsRewardCollectionStatus
70///
71/// Authority level of one completed SNS reward checkpoint collection.
72///
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
75#[serde(rename_all = "snake_case")]
76pub enum SnsRewardCollectionStatus {
77    /// The native neuron API was exhausted with stable bracket responses.
78    ApiExhaustedObserved,
79}
80
81impl SnsRewardCollectionStatus {
82    /// Return the stable report label for this collection status.
83    #[must_use]
84    pub const fn as_str(self) -> &'static str {
85        match self {
86            Self::ApiExhaustedObserved => "api_exhausted_observed",
87        }
88    }
89}
90
91///
92/// SnsRewardCheckpointRow
93///
94/// Variable-size maturity and permission evidence for one observed SNS neuron.
95///
96
97#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
98#[serde(deny_unknown_fields)]
99pub struct SnsRewardCheckpointRow {
100    /// Exact 32-byte neuron identifier as lowercase hexadecimal text.
101    pub neuron_id: String,
102    /// Unix timestamp at which Governance reports the neuron was created.
103    pub created_timestamp_seconds: u64,
104    /// Raw unstaked maturity in e8s-equivalent units.
105    pub maturity_e8s_equivalent: u64,
106    /// Raw staked maturity in e8s-equivalent units when present.
107    pub staked_maturity_e8s_equivalent: Option<u64>,
108    /// Checked sum of unstaked and staked maturity.
109    pub combined_maturity_e8s_equivalent: u64,
110    /// Native automatic maturity-staking state when supplied.
111    pub auto_stake_maturity: Option<bool>,
112    /// Every current principal permission entry returned by Governance.
113    pub permissions: Vec<SnsNeuronPermissionRow>,
114    /// Every pending maturity disbursement returned by Governance.
115    pub disburse_maturity_in_progress: Vec<SnsMaturityDisbursementRow>,
116    /// Neuron-local observation for permissions 7 and 8 plus pending disbursements.
117    pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
118    /// Neuron-local observation for manual maturity-staking permission 9.
119    pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
120}
121
122impl SnsRewardCheckpointRow {
123    /// Recompute combined maturity from the two raw maturity components.
124    #[must_use]
125    pub fn checked_combined_maturity(&self) -> Option<u64> {
126        self.maturity_e8s_equivalent
127            .checked_add(self.staked_maturity_e8s_equivalent.unwrap_or(0))
128    }
129
130    /// Recompute both row-local maturity policy observations from raw evidence.
131    #[must_use]
132    pub fn derived_policy_observations(
133        &self,
134    ) -> (SnsPolicyObservationStatus, SnsPolicyObservationStatus) {
135        neuron_policy_observations(
136            &self.permissions,
137            !self.disburse_maturity_in_progress.is_empty(),
138        )
139    }
140}
141
142///
143/// SnsRewardCheckpointReport
144///
145/// Versioned API-exhausted observed SNS maturity checkpoint.
146///
147
148#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
149#[serde(deny_unknown_fields)]
150pub struct SnsRewardCheckpointReport {
151    /// Report schema version.
152    pub schema_version: u32,
153    /// Requested IC network identity.
154    pub network: String,
155    /// Stable mainnet SNS-W discovery canister identity.
156    pub sns_wasm_canister_id: String,
157    /// Explicit IC API endpoint used for every live call.
158    pub source_endpoint: String,
159    /// Collector identity recorded in report provenance.
160    pub fetched_by: String,
161    /// Current SNS-W list position retained only as display metadata.
162    pub id: usize,
163    /// Current SNS name retained only as display metadata.
164    pub name: String,
165    /// Stable SNS Root canister identity.
166    pub root_canister_id: String,
167    /// Stable SNS Governance canister identity.
168    pub governance_canister_id: String,
169    /// Stable SNS ledger canister identity.
170    pub ledger_canister_id: String,
171    /// Stable SNS decentralization-swap canister identity.
172    pub swap_canister_id: String,
173    /// Stable SNS ledger-index canister identity.
174    pub index_canister_id: String,
175    /// Explicit source classification; checkpoint reports are live-only.
176    pub data_source: ReportDataSource,
177    /// Unix timestamp captured immediately before targeted discovery.
178    pub collection_started_at_unix_secs: u64,
179    /// UTC rendering of `collection_started_at_unix_secs`.
180    pub collection_started_at: String,
181    /// Unix timestamp captured after the final running-version bracket.
182    pub collection_completed_at_unix_secs: u64,
183    /// UTC rendering of `collection_completed_at_unix_secs`.
184    pub collection_completed_at: String,
185    /// Fixed native page size used for every Governance neuron query.
186    pub page_size: u32,
187    /// Number of neuron pages fetched, including the final exhaustion page.
188    pub page_count: u32,
189    /// Number of neuron rows retained.
190    pub row_count: usize,
191    /// Recomputed number of unique full neuron identifiers.
192    pub unique_neuron_id_count: usize,
193    /// Mandatory row ceiling derived from Governance parameters.
194    pub collection_row_ceiling: u64,
195    /// Total targeted-discovery, bracket, and neuron-page client queries.
196    pub client_query_count: u32,
197    /// Explicit authority classification for the completed collection.
198    pub collection_status: SnsRewardCollectionStatus,
199    /// Always false because Governance exposes no point-in-time neuron snapshot version.
200    pub point_in_time_guaranteed: bool,
201    /// Complete nervous-system parameters read before neuron pagination.
202    pub parameters_before: SnsGovernanceParameters,
203    /// Complete nervous-system parameters read after neuron pagination.
204    pub parameters_after: SnsGovernanceParameters,
205    /// Complete reward event read before neuron pagination.
206    pub reward_event_before: SnsRewardEvent,
207    /// Complete reward event read after neuron pagination.
208    pub reward_event_after: SnsRewardEvent,
209    /// Complete running-version response read before neuron pagination.
210    pub running_version_before: SnsRunningVersionResponse,
211    /// Complete running-version response read after neuron pagination.
212    pub running_version_after: SnsRunningVersionResponse,
213    /// Checked aggregate unstaked maturity across every row.
214    pub aggregate_maturity_e8s_equivalent: u64,
215    /// Checked aggregate staked maturity across every row.
216    pub aggregate_staked_maturity_e8s_equivalent: u64,
217    /// Checked aggregate combined maturity across every row.
218    pub aggregate_combined_maturity_e8s_equivalent: u64,
219    /// Number of principal permission entries observed across every row.
220    pub permission_entry_count: usize,
221    /// Number of raw unknown or anomalous permission codes observed globally and per-neuron.
222    pub unassessable_permission_code_count: usize,
223    /// Number of pending maturity disbursements observed across every row.
224    pub pending_maturity_disbursement_count: usize,
225    /// Number of neurons with automatic maturity staking enabled.
226    pub auto_stake_maturity_enabled_count: usize,
227    /// Number of neurons with automatic maturity staking explicitly disabled.
228    pub auto_stake_maturity_disabled_count: usize,
229    /// Number of neurons without an automatic maturity-staking value.
230    pub auto_stake_maturity_unspecified_count: usize,
231    /// Whether permission 2 is currently grantable, or unknown when the list is missing.
232    pub manage_principals_grantable: Option<bool>,
233    /// Global observation for disabling permissions 7 and 8 and pending disbursements.
234    pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
235    /// Global observation for disabling manual maturity-staking permission 9.
236    pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
237    /// Combined observed maturity-conversion policy status.
238    pub maturity_conversion_policy_observed_status: SnsPolicyObservationStatus,
239    /// Strictly increasing full neuron rows retained as raw checkpoint evidence.
240    pub rows: Vec<SnsRewardCheckpointRow>,
241}
242
243///
244/// SnsRewardCheckpointValidationError
245///
246/// Pure validation failure for untrusted serialized or in-memory checkpoint evidence.
247///
248
249#[derive(Debug, Eq, PartialEq, ThisError)]
250#[error("invalid SNS reward checkpoint: {reason}")]
251pub struct SnsRewardCheckpointValidationError {
252    /// Deterministic invariant failure.
253    pub reason: String,
254}
255
256/// Recompute and validate every checkpoint invariant available without live host calls.
257pub fn validate_sns_reward_checkpoint_report(
258    report: &SnsRewardCheckpointReport,
259) -> Result<(), SnsRewardCheckpointValidationError> {
260    validated_reward_checkpoint_summary(report).map(|_| ())
261}
262
263/// Validate untrusted checkpoint evidence and retain the derived summary for projection.
264pub(in crate::sns::report) fn validated_reward_checkpoint_summary(
265    report: &SnsRewardCheckpointReport,
266) -> Result<SnsRewardCheckpointSummary, SnsRewardCheckpointValidationError> {
267    validate_checkpoint_header(report)?;
268    validate_checkpoint_brackets(report)?;
269    validate_checkpoint_rows(&report.rows, report.collection_completed_at_unix_secs)?;
270    let summary = recompute_reward_checkpoint_summary(&report.parameters_before, &report.rows)
271        .map_err(invalid_validation)?;
272    validate_checkpoint_summary(report, &summary)?;
273    Ok(summary)
274}
275
276fn validate_checkpoint_header(
277    report: &SnsRewardCheckpointReport,
278) -> Result<(), SnsRewardCheckpointValidationError> {
279    if report.schema_version != SNS_REWARD_CHECKPOINT_REPORT_SCHEMA_VERSION {
280        return Err(invalid_validation(format!(
281            "schema version {} does not equal {}",
282            report.schema_version, SNS_REWARD_CHECKPOINT_REPORT_SCHEMA_VERSION
283        )));
284    }
285    for (field, principal) in [
286        ("sns_wasm_canister_id", report.sns_wasm_canister_id.as_str()),
287        ("root_canister_id", report.root_canister_id.as_str()),
288        (
289            "governance_canister_id",
290            report.governance_canister_id.as_str(),
291        ),
292        ("ledger_canister_id", report.ledger_canister_id.as_str()),
293        ("swap_canister_id", report.swap_canister_id.as_str()),
294        ("index_canister_id", report.index_canister_id.as_str()),
295    ] {
296        validate_canonical_principal(principal, field).map_err(invalid_validation)?;
297    }
298    let canister_ids = [
299        report.sns_wasm_canister_id.as_str(),
300        report.root_canister_id.as_str(),
301        report.governance_canister_id.as_str(),
302        report.ledger_canister_id.as_str(),
303        report.swap_canister_id.as_str(),
304        report.index_canister_id.as_str(),
305    ];
306    if canister_ids.into_iter().collect::<HashSet<_>>().len() != canister_ids.len() {
307        return Err(invalid_validation(
308            "checkpoint canister roles do not contain unique principals",
309        ));
310    }
311    if report.sns_wasm_canister_id != MAINNET_SNS_WASM_CANISTER_ID {
312        return Err(invalid_validation("unexpected SNS-W canister identity"));
313    }
314    if report.network != MAINNET_NETWORK {
315        return Err(invalid_validation("checkpoint network must be ic"));
316    }
317    if report.source_endpoint.is_empty() || report.fetched_by.is_empty() {
318        return Err(invalid_validation(
319            "checkpoint source_endpoint and fetched_by must be non-empty",
320        ));
321    }
322    if report.id == 0 || report.name.is_empty() {
323        return Err(invalid_validation(
324            "checkpoint SNS list id and display name must be non-empty",
325        ));
326    }
327    if report.data_source != ReportDataSource::Live {
328        return Err(invalid_validation("checkpoint data_source must be live"));
329    }
330    if report.collection_status != SnsRewardCollectionStatus::ApiExhaustedObserved {
331        return Err(invalid_validation(
332            "checkpoint collection is not API-exhausted",
333        ));
334    }
335    if report.point_in_time_guaranteed {
336        return Err(invalid_validation(
337            "checkpoint cannot claim a point-in-time neuron snapshot",
338        ));
339    }
340    validate_checkpoint_collection_metadata(report)
341}
342
343fn validate_checkpoint_collection_metadata(
344    report: &SnsRewardCheckpointReport,
345) -> Result<(), SnsRewardCheckpointValidationError> {
346    if report.collection_completed_at_unix_secs < report.collection_started_at_unix_secs {
347        return Err(invalid_validation(
348            "collection completion precedes collection start",
349        ));
350    }
351    if report.collection_started_at
352        != format_utc_timestamp_secs(report.collection_started_at_unix_secs)
353        || report.collection_completed_at
354            != format_utc_timestamp_secs(report.collection_completed_at_unix_secs)
355    {
356        return Err(invalid_validation(
357            "collection UTC timestamps do not match raw Unix timestamps",
358        ));
359    }
360    if report.page_size != SNS_REWARD_CHECKPOINT_PAGE_SIZE || report.page_count == 0 {
361        return Err(invalid_validation(
362            "checkpoint must contain at least one fixed-size-100 page",
363        ));
364    }
365    let expected_query_count = report
366        .page_count
367        .checked_add(8)
368        .ok_or_else(|| invalid_validation("page_count + 8 exceeds the native u32 contract"))?;
369    if report.client_query_count != expected_query_count {
370        return Err(invalid_validation(
371            "client_query_count does not equal page_count + 8",
372        ));
373    }
374    if report.row_count != report.rows.len() || report.unique_neuron_id_count != report.rows.len() {
375        return Err(invalid_validation(
376            "serialized row counts do not match raw checkpoint rows",
377        ));
378    }
379    let max_neurons = report.parameters_before.max_number_of_neurons;
380    if max_neurons != Some(report.collection_row_ceiling)
381        || !(1..=SNS_REWARD_CHECKPOINT_MAX_NEURONS).contains(&report.collection_row_ceiling)
382    {
383        return Err(invalid_validation(
384            "collection_row_ceiling does not match a valid max_number_of_neurons",
385        ));
386    }
387    if u64::try_from(report.rows.len())
388        .ok()
389        .is_none_or(|rows| rows > report.collection_row_ceiling)
390    {
391        return Err(invalid_validation(
392            "raw row count exceeds the mandatory collection ceiling",
393        ));
394    }
395    Ok(())
396}
397
398fn validate_checkpoint_brackets(
399    report: &SnsRewardCheckpointReport,
400) -> Result<(), SnsRewardCheckpointValidationError> {
401    if report.parameters_before != report.parameters_after {
402        return Err(invalid_validation(
403            "nervous-system parameter brackets differ",
404        ));
405    }
406    if report.reward_event_before != report.reward_event_after {
407        return Err(invalid_validation("reward-event brackets differ"));
408    }
409    if report.running_version_before != report.running_version_after {
410        return Err(invalid_validation("running-version brackets differ"));
411    }
412    validate_sns_reward_event_evidence(&report.reward_event_after).map_err(invalid_validation)?;
413    validate_reward_event_position(report)?;
414    validate_sns_reward_running_version_evidence(&report.running_version_after)
415        .map_err(invalid_validation)?;
416    validate_sns_reward_checkpoint_parameter_evidence(&report.parameters_after)
417        .map_err(invalid_validation)
418}
419
420fn validate_reward_event_position(
421    report: &SnsRewardCheckpointReport,
422) -> Result<(), SnsRewardCheckpointValidationError> {
423    let event = &report.reward_event_after;
424    if event.actual_timestamp_seconds > report.collection_completed_at_unix_secs
425        || event
426            .end_timestamp_seconds
427            .is_none_or(|end| end > report.collection_completed_at_unix_secs)
428    {
429        return Err(invalid_validation(
430            "reward event timestamps exceed checkpoint collection completion",
431        ));
432    }
433    Ok(())
434}
435
436fn validate_checkpoint_rows(
437    rows: &[SnsRewardCheckpointRow],
438    collection_completed_at_unix_secs: u64,
439) -> Result<(), SnsRewardCheckpointValidationError> {
440    let mut previous_id: Option<&str> = None;
441    for row in rows {
442        if row.neuron_id.len() != 64 || !is_canonical_lowercase_hex(&row.neuron_id) {
443            return Err(invalid_validation(format!(
444                "neuron id {} is not 32-byte lowercase hexadecimal text",
445                row.neuron_id
446            )));
447        }
448        if previous_id.is_some_and(|previous| previous >= row.neuron_id.as_str()) {
449            return Err(invalid_validation(
450                "checkpoint neuron ids are not strictly increasing",
451            ));
452        }
453        previous_id = Some(&row.neuron_id);
454        if row.created_timestamp_seconds > collection_completed_at_unix_secs {
455            return Err(invalid_validation(format!(
456                "neuron {} creation timestamp exceeds collection completion",
457                row.neuron_id
458            )));
459        }
460        validate_neuron_permissions(&row.permissions, false)
461            .map_err(|reason| invalid_validation(format!("neuron {}: {reason}", row.neuron_id)))?;
462        validate_maturity_disbursements(&row.disburse_maturity_in_progress)
463            .map_err(invalid_validation)?;
464    }
465    Ok(())
466}
467
468fn validate_checkpoint_summary(
469    report: &SnsRewardCheckpointReport,
470    summary: &SnsRewardCheckpointSummary,
471) -> Result<(), SnsRewardCheckpointValidationError> {
472    let valid = report.aggregate_maturity_e8s_equivalent
473        == summary.aggregate_maturity_e8s_equivalent
474        && report.aggregate_staked_maturity_e8s_equivalent
475            == summary.aggregate_staked_maturity_e8s_equivalent
476        && report.aggregate_combined_maturity_e8s_equivalent
477            == summary.aggregate_combined_maturity_e8s_equivalent
478        && report.permission_entry_count == summary.permission_entry_count
479        && report.unassessable_permission_code_count == summary.unassessable_permission_code_count
480        && report.pending_maturity_disbursement_count
481            == summary.pending_maturity_disbursement_count
482        && report.auto_stake_maturity_enabled_count == summary.auto_stake_maturity_enabled_count
483        && report.auto_stake_maturity_disabled_count == summary.auto_stake_maturity_disabled_count
484        && report.auto_stake_maturity_unspecified_count
485            == summary.auto_stake_maturity_unspecified_count
486        && report.manage_principals_grantable == summary.manage_principals_grantable
487        && report.maturity_mint_conversion_observed_disabled
488            == summary.maturity_mint_conversion_observed_disabled
489        && report.manual_maturity_staking_observed_disabled
490            == summary.manual_maturity_staking_observed_disabled
491        && report.maturity_conversion_policy_observed_status
492            == summary.maturity_conversion_policy_observed_status;
493    if valid {
494        Ok(())
495    } else {
496        Err(invalid_validation(
497            "serialized checkpoint summary does not match raw evidence",
498        ))
499    }
500}
501
502pub(in crate::sns::report) fn validate_sns_reward_running_version_evidence(
503    response: &SnsRunningVersionResponse,
504) -> Result<(), String> {
505    let deployed = response
506        .deployed_version
507        .as_ref()
508        .ok_or_else(|| "running version has no deployed_version".to_string())?;
509    validate_version("deployed_version", deployed)?;
510    if let Some(target) = response
511        .pending_version
512        .as_ref()
513        .and_then(|pending| pending.target_version.as_ref())
514    {
515        validate_version("pending_version.target_version", target)?;
516    }
517    Ok(())
518}
519
520fn validate_version(field: &str, version: &crate::sns::report::SnsVersion) -> Result<(), String> {
521    for (role, hash) in [
522        ("archive", version.archive_wasm_hash_hex.as_str()),
523        ("root", version.root_wasm_hash_hex.as_str()),
524        ("swap", version.swap_wasm_hash_hex.as_str()),
525        ("ledger", version.ledger_wasm_hash_hex.as_str()),
526        ("governance", version.governance_wasm_hash_hex.as_str()),
527        ("index", version.index_wasm_hash_hex.as_str()),
528    ] {
529        if hash.len() != 64 || !is_canonical_lowercase_hex(hash) {
530            return Err(format!(
531                "{field}.{role}_wasm_hash_hex is not a 32-byte lowercase hexadecimal hash"
532            ));
533        }
534    }
535    Ok(())
536}
537
538pub(in crate::sns::report) fn validate_sns_reward_checkpoint_parameter_evidence(
539    parameters: &SnsGovernanceParameters,
540) -> Result<(), String> {
541    validate_default_followees(parameters)?;
542    validate_parameter_permissions(parameters)
543}
544
545fn validate_default_followees(parameters: &SnsGovernanceParameters) -> Result<(), String> {
546    let Some(defaults) = parameters.default_followees.as_ref() else {
547        return Ok(());
548    };
549    let mut function_ids = HashSet::new();
550    for row in &defaults.followees {
551        if !function_ids.insert(row.function_id) {
552            return Err("default followees contain a duplicate function id".to_string());
553        }
554        let mut neuron_ids = HashSet::new();
555        for id in &row.followee_neuron_ids {
556            if id.len() != 64 || !is_lowercase_hex(id) {
557                return Err("default followees contain a non-canonical neuron id".to_string());
558            }
559            if !neuron_ids.insert(id) {
560                return Err("default followees contain a duplicate neuron id".to_string());
561            }
562        }
563    }
564    Ok(())
565}
566
567fn validate_parameter_permissions(parameters: &SnsGovernanceParameters) -> Result<(), String> {
568    for (field, permissions) in [
569        (
570            "neuron_claimer_permissions",
571            parameters.neuron_claimer_permissions.as_ref(),
572        ),
573        (
574            "neuron_grantable_permissions",
575            parameters.neuron_grantable_permissions.as_ref(),
576        ),
577    ] {
578        let Some(permissions) = permissions else {
579            continue;
580        };
581        let mut codes = HashSet::new();
582        if permissions
583            .permissions
584            .iter()
585            .any(|code| !codes.insert(*code))
586        {
587            return Err(format!("{field} contains a duplicate permission code"));
588        }
589    }
590    Ok(())
591}
592
593pub(in crate::sns::report) fn validate_sns_reward_event_evidence(
594    event: &SnsRewardEvent,
595) -> Result<(), String> {
596    if event.end_timestamp_seconds.is_none() {
597        return Err("reward event is missing canonical end_timestamp_seconds".to_string());
598    }
599    let mut proposal_ids = HashSet::new();
600    if event
601        .settled_proposals
602        .iter()
603        .any(|proposal| !proposal_ids.insert(proposal.id))
604    {
605        return Err("reward event contains duplicate settled proposal ids".to_string());
606    }
607    Ok(())
608}
609
610fn invalid_validation(reason: impl Into<String>) -> SnsRewardCheckpointValidationError {
611    SnsRewardCheckpointValidationError {
612        reason: reason.into(),
613    }
614}
615
616///
617/// SnsRewardCheckpointSummary
618///
619/// Pure recomputation result derived from raw checkpoint parameters and rows.
620///
621
622pub(in crate::sns::report) struct SnsRewardCheckpointSummary {
623    pub(in crate::sns::report) aggregate_maturity_e8s_equivalent: u64,
624    pub(in crate::sns::report) aggregate_staked_maturity_e8s_equivalent: u64,
625    pub(in crate::sns::report) aggregate_combined_maturity_e8s_equivalent: u64,
626    pub(in crate::sns::report) permission_entry_count: usize,
627    pub(in crate::sns::report) unassessable_permission_code_count: usize,
628    pub(in crate::sns::report) pending_maturity_disbursement_count: usize,
629    pub(in crate::sns::report) auto_stake_maturity_enabled_count: usize,
630    pub(in crate::sns::report) auto_stake_maturity_disabled_count: usize,
631    pub(in crate::sns::report) auto_stake_maturity_unspecified_count: usize,
632    pub(in crate::sns::report) manage_principals_grantable: Option<bool>,
633    pub(in crate::sns::report) maturity_mint_conversion_observed_disabled:
634        SnsPolicyObservationStatus,
635    pub(in crate::sns::report) manual_maturity_staking_observed_disabled:
636        SnsPolicyObservationStatus,
637    pub(in crate::sns::report) maturity_conversion_policy_observed_status:
638        SnsPolicyObservationStatus,
639}
640
641pub(in crate::sns::report) fn recompute_reward_checkpoint_summary(
642    parameters: &SnsGovernanceParameters,
643    rows: &[SnsRewardCheckpointRow],
644) -> Result<SnsRewardCheckpointSummary, String> {
645    let mut summary = SnsRewardCheckpointSummary {
646        aggregate_maturity_e8s_equivalent: 0,
647        aggregate_staked_maturity_e8s_equivalent: 0,
648        aggregate_combined_maturity_e8s_equivalent: 0,
649        permission_entry_count: 0,
650        unassessable_permission_code_count: 0,
651        pending_maturity_disbursement_count: 0,
652        auto_stake_maturity_enabled_count: 0,
653        auto_stake_maturity_disabled_count: 0,
654        auto_stake_maturity_unspecified_count: 0,
655        manage_principals_grantable: parameters
656            .neuron_grantable_permissions
657            .as_ref()
658            .map(|permissions| permissions.permissions.contains(&2)),
659        maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus::ObservedSatisfied,
660        manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus::ObservedSatisfied,
661        maturity_conversion_policy_observed_status: SnsPolicyObservationStatus::ObservedSatisfied,
662    };
663    let (parameter_mint, parameter_staking, parameter_unknown_count) =
664        parameter_policy_observations(parameters);
665    summary.maturity_mint_conversion_observed_disabled = parameter_mint;
666    summary.manual_maturity_staking_observed_disabled = parameter_staking;
667    summary.unassessable_permission_code_count = parameter_unknown_count;
668
669    for row in rows {
670        accumulate_reward_row(&mut summary, row)?;
671    }
672    summary.maturity_conversion_policy_observed_status = summary
673        .maturity_mint_conversion_observed_disabled
674        .combine(summary.manual_maturity_staking_observed_disabled);
675    Ok(summary)
676}
677
678fn accumulate_reward_row(
679    summary: &mut SnsRewardCheckpointSummary,
680    row: &SnsRewardCheckpointRow,
681) -> Result<(), String> {
682    let combined = row.checked_combined_maturity().ok_or_else(|| {
683        format!(
684            "neuron {} combined maturity exceeds the native u64 contract",
685            row.neuron_id
686        )
687    })?;
688    if row.combined_maturity_e8s_equivalent != combined {
689        return Err(format!(
690            "neuron {} combined maturity does not match raw components",
691            row.neuron_id
692        ));
693    }
694    let (row_mint, row_staking) = row.derived_policy_observations();
695    if row.maturity_mint_conversion_observed_disabled != row_mint
696        || row.manual_maturity_staking_observed_disabled != row_staking
697    {
698        return Err(format!(
699            "neuron {} policy observations do not match raw evidence",
700            row.neuron_id
701        ));
702    }
703    summary.aggregate_maturity_e8s_equivalent = checked_sum(
704        summary.aggregate_maturity_e8s_equivalent,
705        row.maturity_e8s_equivalent,
706        "aggregate maturity",
707    )?;
708    summary.aggregate_staked_maturity_e8s_equivalent = checked_sum(
709        summary.aggregate_staked_maturity_e8s_equivalent,
710        row.staked_maturity_e8s_equivalent.unwrap_or(0),
711        "aggregate staked maturity",
712    )?;
713    summary.aggregate_combined_maturity_e8s_equivalent = checked_sum(
714        summary.aggregate_combined_maturity_e8s_equivalent,
715        combined,
716        "aggregate combined maturity",
717    )?;
718    accumulate_reward_row_counts(summary, row)?;
719    summary.maturity_mint_conversion_observed_disabled = summary
720        .maturity_mint_conversion_observed_disabled
721        .combine(row_mint);
722    summary.manual_maturity_staking_observed_disabled = summary
723        .manual_maturity_staking_observed_disabled
724        .combine(row_staking);
725    Ok(())
726}
727
728fn accumulate_reward_row_counts(
729    summary: &mut SnsRewardCheckpointSummary,
730    row: &SnsRewardCheckpointRow,
731) -> Result<(), String> {
732    summary.permission_entry_count = summary
733        .permission_entry_count
734        .checked_add(row.permissions.len())
735        .ok_or_else(|| "permission entry count overflow".to_string())?;
736    summary.pending_maturity_disbursement_count = summary
737        .pending_maturity_disbursement_count
738        .checked_add(row.disburse_maturity_in_progress.len())
739        .ok_or_else(|| "pending maturity disbursement count overflow".to_string())?;
740    summary.unassessable_permission_code_count = summary
741        .unassessable_permission_code_count
742        .checked_add(
743            row.permissions
744                .iter()
745                .flat_map(|permission| permission.permission_types.iter())
746                .filter(|value| !(1..=10).contains(&value.code))
747                .count(),
748        )
749        .ok_or_else(|| "unassessable permission code count overflow".to_string())?;
750    match row.auto_stake_maturity {
751        Some(true) => increment(&mut summary.auto_stake_maturity_enabled_count)?,
752        Some(false) => increment(&mut summary.auto_stake_maturity_disabled_count)?,
753        None => increment(&mut summary.auto_stake_maturity_unspecified_count)?,
754    }
755    Ok(())
756}
757
758fn parameter_policy_observations(
759    parameters: &SnsGovernanceParameters,
760) -> (
761    SnsPolicyObservationStatus,
762    SnsPolicyObservationStatus,
763    usize,
764) {
765    let mut mint = SnsPolicyObservationStatus::ObservedSatisfied;
766    let mut staking = SnsPolicyObservationStatus::ObservedSatisfied;
767    let mut unknown_count = 0;
768    for permissions in [
769        parameters.neuron_claimer_permissions.as_ref(),
770        parameters.neuron_grantable_permissions.as_ref(),
771    ] {
772        let Some(permissions) = permissions else {
773            mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
774            staking = staking.combine(SnsPolicyObservationStatus::Unassessable);
775            continue;
776        };
777        if permissions.permissions.is_empty() {
778            mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
779            staking = staking.combine(SnsPolicyObservationStatus::Unassessable);
780        }
781        for code in &permissions.permissions {
782            let (code_mint, code_staking) = permission_code_policy_observations(*code);
783            mint = mint.combine(code_mint);
784            staking = staking.combine(code_staking);
785            if !(1..=10).contains(code) {
786                unknown_count += 1;
787            }
788        }
789    }
790    (mint, staking, unknown_count)
791}
792
793fn checked_sum(left: u64, right: u64, field: &str) -> Result<u64, String> {
794    left.checked_add(right)
795        .ok_or_else(|| format!("{field} exceeds the native u64 contract"))
796}
797
798fn increment(value: &mut usize) -> Result<(), String> {
799    *value = value
800        .checked_add(1)
801        .ok_or_else(|| "checkpoint count overflow".to_string())?;
802    Ok(())
803}