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