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