Skip to main content

ic_query/sns/report/model/reports/neurons/
detail.rs

1//! Module: sns::report::model::reports::neurons::detail
2//!
3//! Responsibility: exact SNS neuron detail, permission evidence, and shared evidence validation.
4//! Does not own: live Governance calls, SNS discovery, or text rendering.
5//! Boundary: preserves variable-size native neuron evidence outside fixed-size list caches.
6
7use super::SnsNeuronRow;
8use crate::{hex::is_lowercase_hex, report::ReportDataSource};
9use candid::Principal;
10use serde::{Deserialize as SerdeDeserialize, Serialize};
11use std::collections::HashSet;
12
13///
14/// SnsPolicyObservationStatus
15///
16/// Tri-state result for one observed maturity-conversion policy condition.
17///
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum SnsPolicyObservationStatus {
22    /// Every available value satisfies the observed condition.
23    ObservedSatisfied,
24    /// At least one available value violates the observed condition.
25    Violated,
26    /// Unknown or anomalous evidence prevents a closed-world assessment.
27    Unassessable,
28}
29
30impl SnsPolicyObservationStatus {
31    /// Return the stable report label for this status.
32    #[must_use]
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Self::ObservedSatisfied => "observed_satisfied",
36            Self::Violated => "violated",
37            Self::Unassessable => "unassessable",
38        }
39    }
40
41    /// Combine two observations, preserving a known violation over uncertainty.
42    #[must_use]
43    pub const fn combine(self, other: Self) -> Self {
44        match (self, other) {
45            (Self::Violated, _) | (_, Self::Violated) => Self::Violated,
46            (Self::Unassessable, _) | (_, Self::Unassessable) => Self::Unassessable,
47            (Self::ObservedSatisfied, Self::ObservedSatisfied) => Self::ObservedSatisfied,
48        }
49    }
50}
51
52///
53/// SnsNeuronPermissionValue
54///
55/// Raw SNS Governance permission code with its current native label.
56///
57
58#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
59#[serde(deny_unknown_fields)]
60pub struct SnsNeuronPermissionValue {
61    /// Raw integer permission code returned by Governance.
62    pub code: i32,
63    /// Current native permission label, or `unknown` for an unrecognized code.
64    pub name: String,
65}
66
67impl SnsNeuronPermissionValue {
68    /// Construct one permission value from its raw Governance code.
69    #[must_use]
70    pub fn from_code(code: i32) -> Self {
71        Self {
72            code,
73            name: sns_neuron_permission_name(code).to_string(),
74        }
75    }
76}
77
78///
79/// SnsNeuronPermissionRow
80///
81/// Permissions held by one principal on an SNS neuron.
82///
83
84#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
85#[serde(deny_unknown_fields)]
86pub struct SnsNeuronPermissionRow {
87    /// Canonical principal text when Governance supplied the permission holder.
88    pub principal: Option<String>,
89    /// Raw permission codes and current native labels.
90    pub permission_types: Vec<SnsNeuronPermissionValue>,
91}
92
93///
94/// SnsNeuronAccount
95///
96/// Native optional destination account retained for a pending maturity disbursement.
97///
98
99#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
100#[serde(deny_unknown_fields)]
101pub struct SnsNeuronAccount {
102    /// Canonical destination owner when Governance supplied one.
103    pub owner: Option<String>,
104    /// Native destination subaccount encoded as lowercase hexadecimal when supplied.
105    pub subaccount_hex: Option<String>,
106}
107
108///
109/// SnsMaturityDisbursementRow
110///
111/// Native maturity disbursement that Governance has not yet finalized.
112///
113
114#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
115#[serde(deny_unknown_fields)]
116pub struct SnsMaturityDisbursementRow {
117    /// Unix timestamp at which the maturity disbursement was scheduled.
118    pub timestamp_of_disbursement_seconds: u64,
119    /// Raw scheduled amount in e8s.
120    pub amount_e8s: u64,
121    /// Complete optional destination account returned by Governance.
122    pub account_to_disburse_to: Option<SnsNeuronAccount>,
123    /// Unix timestamp at which Governance expects to finalize the disbursement.
124    pub finalize_disbursement_timestamp_seconds: Option<u64>,
125}
126
127///
128/// SnsNeuronFolloweesRow
129///
130/// Legacy function-based followees retained from one SNS neuron.
131///
132
133#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
134#[serde(deny_unknown_fields)]
135pub struct SnsNeuronFolloweesRow {
136    /// Native nervous-system function identifier.
137    pub function_id: u64,
138    /// Full 32-byte followee neuron identifiers encoded as lowercase hexadecimal.
139    pub followee_neuron_ids: Vec<String>,
140}
141
142///
143/// SnsNeuronFolloweeRow
144///
145/// One native topic-following target and its optional alias.
146///
147
148#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
149#[serde(deny_unknown_fields)]
150pub struct SnsNeuronFolloweeRow {
151    /// Full followee neuron identifier when Governance supplied one.
152    pub neuron_id: Option<String>,
153    /// Native followee alias when Governance supplied one.
154    pub alias: Option<String>,
155}
156
157///
158/// SnsNeuronTopicFolloweesRow
159///
160/// Native topic-following entry retained from one SNS neuron.
161///
162
163#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
164#[serde(deny_unknown_fields)]
165pub struct SnsNeuronTopicFolloweesRow {
166    /// Raw topic code used as the native topic-following map key.
167    pub topic_code: i32,
168    /// Current native topic label when Governance supplied a known topic variant.
169    pub topic: Option<String>,
170    /// Native topic followees in response order.
171    pub followees: Vec<SnsNeuronFolloweeRow>,
172}
173
174///
175/// SnsNeuronDetail
176///
177/// Full native detail evidence for exactly one SNS neuron.
178///
179
180#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
181#[serde(deny_unknown_fields)]
182pub struct SnsNeuronDetail {
183    /// Fixed-size neuron state shared with bounded list and snapshot rows.
184    pub neuron: SnsNeuronRow,
185    /// Every current principal permission entry.
186    pub permissions: Vec<SnsNeuronPermissionRow>,
187    /// Every pending maturity disbursement.
188    pub disburse_maturity_in_progress: Vec<SnsMaturityDisbursementRow>,
189    /// Legacy function-based followees.
190    pub followees: Vec<SnsNeuronFolloweesRow>,
191    /// Topic-based followees when the native optional collection is present.
192    pub topic_followees: Option<Vec<SnsNeuronTopicFolloweesRow>>,
193    /// Observed status for disabling maturity mint conversions through permissions 7 and 8.
194    pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
195    /// Observed status for disabling manual maturity staking through permission 9.
196    pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
197}
198
199impl SnsNeuronDetail {
200    /// Recompute both neuron-local maturity policy observations from raw evidence.
201    #[must_use]
202    pub fn derived_policy_observations(
203        &self,
204    ) -> (SnsPolicyObservationStatus, SnsPolicyObservationStatus) {
205        neuron_policy_observations(
206            &self.permissions,
207            !self.disburse_maturity_in_progress.is_empty(),
208        )
209    }
210}
211
212pub(in crate::sns::report::model) fn neuron_policy_observations(
213    permissions: &[SnsNeuronPermissionRow],
214    has_pending_maturity_disbursement: bool,
215) -> (SnsPolicyObservationStatus, SnsPolicyObservationStatus) {
216    let mut mint = if has_pending_maturity_disbursement {
217        SnsPolicyObservationStatus::Violated
218    } else {
219        SnsPolicyObservationStatus::ObservedSatisfied
220    };
221    let mut staking = if permissions.is_empty() {
222        mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
223        SnsPolicyObservationStatus::Unassessable
224    } else {
225        SnsPolicyObservationStatus::ObservedSatisfied
226    };
227    for permission in permissions {
228        if permission.principal.is_none() || permission.permission_types.is_empty() {
229            mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
230            staking = staking.combine(SnsPolicyObservationStatus::Unassessable);
231        }
232        for code in permission.permission_types.iter().map(|value| value.code) {
233            let (code_mint, code_staking) = permission_code_policy_observations(code);
234            mint = mint.combine(code_mint);
235            staking = staking.combine(code_staking);
236        }
237    }
238    (mint, staking)
239}
240
241pub(in crate::sns::report::model) const fn permission_code_policy_observations(
242    code: i32,
243) -> (SnsPolicyObservationStatus, SnsPolicyObservationStatus) {
244    match code {
245        7 | 8 => (
246            SnsPolicyObservationStatus::Violated,
247            SnsPolicyObservationStatus::ObservedSatisfied,
248        ),
249        9 => (
250            SnsPolicyObservationStatus::ObservedSatisfied,
251            SnsPolicyObservationStatus::Violated,
252        ),
253        0 | 11..=i32::MAX | i32::MIN..=-1 => (
254            SnsPolicyObservationStatus::Unassessable,
255            SnsPolicyObservationStatus::Unassessable,
256        ),
257        1..=6 | 10 => (
258            SnsPolicyObservationStatus::ObservedSatisfied,
259            SnsPolicyObservationStatus::ObservedSatisfied,
260        ),
261    }
262}
263
264///
265/// SnsNeuronDetailReport
266///
267/// Serializable live report for one exact SNS Governance neuron lookup.
268///
269
270#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
271#[serde(deny_unknown_fields)]
272pub struct SnsNeuronDetailReport {
273    /// Report schema version.
274    pub schema_version: u32,
275    /// Requested IC network identity.
276    pub network: String,
277    /// SNS-W canister used for targeted discovery.
278    pub sns_wasm_canister_id: String,
279    /// Collection timestamp in UTC.
280    pub fetched_at: String,
281    /// IC API endpoint used for discovery and Governance calls.
282    pub source_endpoint: String,
283    /// Collector identity recorded in report provenance.
284    pub fetched_by: String,
285    /// Current SNS-W list position retained as display metadata.
286    pub id: usize,
287    /// Current SNS name retained as display metadata.
288    pub name: String,
289    /// Stable SNS Root canister identity.
290    pub root_canister_id: String,
291    /// Stable SNS Governance canister identity.
292    pub governance_canister_id: String,
293    /// Exact requested neuron identifier.
294    pub neuron_id: String,
295    /// Explicit report data source; exact detail reports are live-only.
296    pub data_source: ReportDataSource,
297    /// Full native neuron detail and derived policy observations.
298    pub detail: SnsNeuronDetail,
299}
300
301/// Return the current native label for one raw SNS neuron permission code.
302#[must_use]
303pub const fn sns_neuron_permission_name(code: i32) -> &'static str {
304    match code {
305        0 => "unspecified",
306        1 => "configure_dissolve_state",
307        2 => "manage_principals",
308        3 => "submit_proposal",
309        4 => "vote",
310        5 => "disburse",
311        6 => "split",
312        7 => "merge_maturity",
313        8 => "disburse_maturity",
314        9 => "stake_maturity",
315        10 => "manage_voting_permission",
316        _ => "unknown",
317    }
318}
319
320pub(in crate::sns::report) fn validate_neuron_permissions(
321    permissions: &[SnsNeuronPermissionRow],
322    require_principal: bool,
323) -> Result<(), String> {
324    let mut principals = HashSet::new();
325    for permission in permissions {
326        let principal = match permission.principal.as_deref() {
327            Some(principal) => {
328                validate_canonical_principal(principal, "permission principal")?;
329                if !principals.insert(principal) {
330                    return Err(format!("duplicate permission principal {principal}"));
331                }
332                principal
333            }
334            None if require_principal => {
335                return Err("permission principal is missing".to_string());
336            }
337            None => "missing principal",
338        };
339        let mut codes = HashSet::new();
340        for value in &permission.permission_types {
341            if value.name != sns_neuron_permission_name(value.code) {
342                return Err(format!(
343                    "permission code {} has label {}, expected {}",
344                    value.code,
345                    value.name,
346                    sns_neuron_permission_name(value.code)
347                ));
348            }
349            if !codes.insert(value.code) {
350                return Err(format!(
351                    "{principal} contains duplicate permission code {}",
352                    value.code
353                ));
354            }
355        }
356    }
357    Ok(())
358}
359
360pub(in crate::sns::report) fn validate_maturity_disbursements(
361    disbursements: &[SnsMaturityDisbursementRow],
362) -> Result<(), String> {
363    for disbursement in disbursements {
364        if let Some(account) = disbursement.account_to_disburse_to.as_ref() {
365            if let Some(owner) = account.owner.as_deref() {
366                validate_canonical_principal(owner, "pending disbursement account owner")?;
367            }
368            if let Some(subaccount) = account.subaccount_hex.as_deref()
369                && (subaccount.len() != 64 || !is_lowercase_hex(subaccount))
370            {
371                return Err(
372                    "pending disbursement subaccount is not 32-byte lowercase hexadecimal text"
373                        .to_string(),
374                );
375            }
376        }
377    }
378    Ok(())
379}
380
381pub(super) fn validate_canonical_principal(value: &str, field: &str) -> Result<(), String> {
382    let principal =
383        Principal::from_text(value).map_err(|err| format!("{field} {value} is invalid: {err}"))?;
384    if principal.to_text() == value {
385        Ok(())
386    } else {
387        Err(format!("{field} {value} is not canonical principal text"))
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn permission_names_cover_native_and_unknown_codes() {
397        for (code, expected) in [
398            (0, "unspecified"),
399            (1, "configure_dissolve_state"),
400            (2, "manage_principals"),
401            (3, "submit_proposal"),
402            (4, "vote"),
403            (5, "disburse"),
404            (6, "split"),
405            (7, "merge_maturity"),
406            (8, "disburse_maturity"),
407            (9, "stake_maturity"),
408            (10, "manage_voting_permission"),
409            (11, "unknown"),
410            (-1, "unknown"),
411        ] {
412            assert_eq!(sns_neuron_permission_name(code), expected, "code {code}");
413        }
414    }
415
416    #[test]
417    fn policy_observations_fail_closed_and_prioritize_known_violations() {
418        let mut detail = SnsNeuronDetail {
419            neuron: SnsNeuronRow {
420                neuron_id: "00".repeat(32),
421                cached_neuron_stake_e8s: 0,
422                maturity_e8s_equivalent: 0,
423                staked_maturity_e8s_equivalent: None,
424                created_timestamp_seconds: 0,
425                created_at: "1970-01-01T00:00:00Z".to_string(),
426                source_nns_neuron_id: None,
427                auto_stake_maturity: None,
428                aging_since_timestamp_seconds: 0,
429                dissolve_state: None,
430                voting_power_percentage_multiplier: 100,
431                vesting_period_seconds: None,
432                neuron_fees_e8s: 0,
433            },
434            permissions: vec![SnsNeuronPermissionRow {
435                principal: Some("aaaaa-aa".to_string()),
436                permission_types: vec![SnsNeuronPermissionValue::from_code(11)],
437            }],
438            disburse_maturity_in_progress: Vec::new(),
439            followees: Vec::new(),
440            topic_followees: None,
441            maturity_mint_conversion_observed_disabled:
442                SnsPolicyObservationStatus::ObservedSatisfied,
443            manual_maturity_staking_observed_disabled:
444                SnsPolicyObservationStatus::ObservedSatisfied,
445        };
446
447        assert_eq!(
448            detail.derived_policy_observations(),
449            (
450                SnsPolicyObservationStatus::Unassessable,
451                SnsPolicyObservationStatus::Unassessable,
452            )
453        );
454
455        detail.permissions[0]
456            .permission_types
457            .push(SnsNeuronPermissionValue::from_code(7));
458        assert_eq!(
459            detail.derived_policy_observations(),
460            (
461                SnsPolicyObservationStatus::Violated,
462                SnsPolicyObservationStatus::Unassessable,
463            )
464        );
465    }
466}