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