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