1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum SnsPolicyObservationStatus {
25 ObservedSatisfied,
27 Violated,
29 Unassessable,
31}
32
33impl SnsPolicyObservationStatus {
34 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
62#[serde(deny_unknown_fields)]
63pub struct SnsNeuronPermissionRow {
64 pub principal: Option<String>,
66 pub permission_types: Vec<i32>,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
77#[serde(deny_unknown_fields)]
78pub struct SnsNeuronAccount {
79 pub owner: Option<String>,
81 pub subaccount_hex: Option<String>,
83}
84
85#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
92#[serde(deny_unknown_fields)]
93pub struct SnsMaturityDisbursementRow {
94 pub timestamp_of_disbursement_seconds: u64,
96 pub amount_e8s: u64,
98 pub account_to_disburse_to: Option<SnsNeuronAccount>,
100 pub finalize_disbursement_timestamp_seconds: Option<u64>,
102}
103
104#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
111#[serde(deny_unknown_fields)]
112pub struct SnsNeuronFolloweesRow {
113 pub function_id: u64,
115 pub followee_neuron_ids: Vec<String>,
117}
118
119#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
126#[serde(deny_unknown_fields)]
127pub struct SnsNeuronFolloweeRow {
128 pub neuron_id: Option<String>,
130 pub alias: Option<String>,
132}
133
134#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
141#[serde(deny_unknown_fields)]
142pub struct SnsNeuronTopicFolloweesRow {
143 pub topic_code: i32,
145 pub topic: Option<String>,
147 pub followees: Vec<SnsNeuronFolloweeRow>,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
158#[serde(deny_unknown_fields)]
159pub struct SnsNeuronDetail {
160 pub neuron: SnsNeuronRow,
162 pub permissions: Vec<SnsNeuronPermissionRow>,
164 pub disburse_maturity_in_progress: Vec<SnsMaturityDisbursementRow>,
166 pub followees: Vec<SnsNeuronFolloweesRow>,
168 pub topic_followees: Option<Vec<SnsNeuronTopicFolloweesRow>>,
170}
171
172impl SnsNeuronDetail {
173 #[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#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
248#[serde(deny_unknown_fields)]
249pub struct SnsNeuronDetailReport {
250 pub schema_version: u32,
252 #[serde(flatten)]
254 pub context: SnsReportContext,
255 pub neuron_id: String,
257 pub data_source: ReportDataSource,
259 pub detail: SnsNeuronDetail,
261}
262
263#[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}