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