1use super::SnsNeuronRow;
8use crate::{hex::is_lowercase_hex_32_bytes, report::ReportDataSource};
9use serde::{Deserialize as SerdeDeserialize, Serialize};
10use std::collections::HashSet;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum SnsPolicyObservationStatus {
21 ObservedSatisfied,
23 Violated,
25 Unassessable,
27}
28
29impl SnsPolicyObservationStatus {
30 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct SnsNeuronPermissionValue {
60 pub code: i32,
62 pub name: String,
64}
65
66impl SnsNeuronPermissionValue {
67 #[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#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
84#[serde(deny_unknown_fields)]
85pub struct SnsNeuronPermissionRow {
86 pub principal: Option<String>,
88 pub permission_types: Vec<SnsNeuronPermissionValue>,
90}
91
92#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
99#[serde(deny_unknown_fields)]
100pub struct SnsNeuronAccount {
101 pub owner: Option<String>,
103 pub subaccount_hex: Option<String>,
105}
106
107#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
114#[serde(deny_unknown_fields)]
115pub struct SnsMaturityDisbursementRow {
116 pub timestamp_of_disbursement_seconds: u64,
118 pub amount_e8s: u64,
120 pub account_to_disburse_to: Option<SnsNeuronAccount>,
122 pub finalize_disbursement_timestamp_seconds: Option<u64>,
124}
125
126#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
133#[serde(deny_unknown_fields)]
134pub struct SnsNeuronFolloweesRow {
135 pub function_id: u64,
137 pub followee_neuron_ids: Vec<String>,
139}
140
141#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
148#[serde(deny_unknown_fields)]
149pub struct SnsNeuronFolloweeRow {
150 pub neuron_id: Option<String>,
152 pub alias: Option<String>,
154}
155
156#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
163#[serde(deny_unknown_fields)]
164pub struct SnsNeuronTopicFolloweesRow {
165 pub topic_code: i32,
167 pub topic: Option<String>,
169 pub followees: Vec<SnsNeuronFolloweeRow>,
171}
172
173#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
180#[serde(deny_unknown_fields)]
181pub struct SnsNeuronDetail {
182 pub neuron: SnsNeuronRow,
184 pub permissions: Vec<SnsNeuronPermissionRow>,
186 pub disburse_maturity_in_progress: Vec<SnsMaturityDisbursementRow>,
188 pub followees: Vec<SnsNeuronFolloweesRow>,
190 pub topic_followees: Option<Vec<SnsNeuronTopicFolloweesRow>>,
192 pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
194 pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
196}
197
198impl SnsNeuronDetail {
199 #[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#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
274#[serde(deny_unknown_fields)]
275pub struct SnsNeuronDetailReport {
276 pub schema_version: u32,
278 pub network: String,
280 pub sns_wasm_canister_id: String,
282 pub fetched_at: String,
284 pub source_endpoint: String,
286 pub fetched_by: String,
288 pub id: usize,
290 pub name: String,
292 pub root_canister_id: String,
294 pub governance_canister_id: String,
296 pub neuron_id: String,
298 pub data_source: ReportDataSource,
300 pub detail: SnsNeuronDetail,
302}
303
304#[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}