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