1use super::SnsNeuronRow;
8use crate::{hex::is_lowercase_hex, report::ReportDataSource};
9use candid::Principal;
10use serde::{Deserialize as SerdeDeserialize, Serialize};
11use std::collections::HashSet;
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum SnsPolicyObservationStatus {
22 ObservedSatisfied,
24 Violated,
26 Unassessable,
28}
29
30impl SnsPolicyObservationStatus {
31 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
59#[serde(deny_unknown_fields)]
60pub struct SnsNeuronPermissionValue {
61 pub code: i32,
63 pub name: String,
65}
66
67impl SnsNeuronPermissionValue {
68 #[must_use]
70 pub fn from_code(code: i32) -> Self {
71 Self {
72 code,
73 name: sns_neuron_permission_name(code).to_string(),
74 }
75 }
76}
77
78#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
85#[serde(deny_unknown_fields)]
86pub struct SnsNeuronPermissionRow {
87 pub principal: Option<String>,
89 pub permission_types: Vec<SnsNeuronPermissionValue>,
91}
92
93#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
100#[serde(deny_unknown_fields)]
101pub struct SnsNeuronAccount {
102 pub owner: Option<String>,
104 pub subaccount_hex: Option<String>,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
115#[serde(deny_unknown_fields)]
116pub struct SnsMaturityDisbursementRow {
117 pub timestamp_of_disbursement_seconds: u64,
119 pub amount_e8s: u64,
121 pub account_to_disburse_to: Option<SnsNeuronAccount>,
123 pub finalize_disbursement_timestamp_seconds: Option<u64>,
125}
126
127#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
134#[serde(deny_unknown_fields)]
135pub struct SnsNeuronFolloweesRow {
136 pub function_id: u64,
138 pub followee_neuron_ids: Vec<String>,
140}
141
142#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
149#[serde(deny_unknown_fields)]
150pub struct SnsNeuronFolloweeRow {
151 pub neuron_id: Option<String>,
153 pub alias: Option<String>,
155}
156
157#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
164#[serde(deny_unknown_fields)]
165pub struct SnsNeuronTopicFolloweesRow {
166 pub topic_code: i32,
168 pub topic: Option<String>,
170 pub followees: Vec<SnsNeuronFolloweeRow>,
172}
173
174#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
181#[serde(deny_unknown_fields)]
182pub struct SnsNeuronDetail {
183 pub neuron: SnsNeuronRow,
185 pub permissions: Vec<SnsNeuronPermissionRow>,
187 pub disburse_maturity_in_progress: Vec<SnsMaturityDisbursementRow>,
189 pub followees: Vec<SnsNeuronFolloweesRow>,
191 pub topic_followees: Option<Vec<SnsNeuronTopicFolloweesRow>>,
193 pub maturity_mint_conversion_observed_disabled: SnsPolicyObservationStatus,
195 pub manual_maturity_staking_observed_disabled: SnsPolicyObservationStatus,
197}
198
199impl SnsNeuronDetail {
200 #[must_use]
202 pub fn derived_policy_observations(
203 &self,
204 ) -> (SnsPolicyObservationStatus, SnsPolicyObservationStatus) {
205 neuron_policy_observations(
206 &self.permissions,
207 !self.disburse_maturity_in_progress.is_empty(),
208 )
209 }
210}
211
212pub(in crate::sns::report::model) fn neuron_policy_observations(
213 permissions: &[SnsNeuronPermissionRow],
214 has_pending_maturity_disbursement: bool,
215) -> (SnsPolicyObservationStatus, SnsPolicyObservationStatus) {
216 let mut mint = if has_pending_maturity_disbursement {
217 SnsPolicyObservationStatus::Violated
218 } else {
219 SnsPolicyObservationStatus::ObservedSatisfied
220 };
221 let mut staking = if permissions.is_empty() {
222 mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
223 SnsPolicyObservationStatus::Unassessable
224 } else {
225 SnsPolicyObservationStatus::ObservedSatisfied
226 };
227 for permission in permissions {
228 if permission.principal.is_none() || permission.permission_types.is_empty() {
229 mint = mint.combine(SnsPolicyObservationStatus::Unassessable);
230 staking = staking.combine(SnsPolicyObservationStatus::Unassessable);
231 }
232 for code in permission.permission_types.iter().map(|value| value.code) {
233 let (code_mint, code_staking) = permission_code_policy_observations(code);
234 mint = mint.combine(code_mint);
235 staking = staking.combine(code_staking);
236 }
237 }
238 (mint, staking)
239}
240
241pub(in crate::sns::report::model) const fn permission_code_policy_observations(
242 code: i32,
243) -> (SnsPolicyObservationStatus, SnsPolicyObservationStatus) {
244 match code {
245 7 | 8 => (
246 SnsPolicyObservationStatus::Violated,
247 SnsPolicyObservationStatus::ObservedSatisfied,
248 ),
249 9 => (
250 SnsPolicyObservationStatus::ObservedSatisfied,
251 SnsPolicyObservationStatus::Violated,
252 ),
253 0 | 11..=i32::MAX | i32::MIN..=-1 => (
254 SnsPolicyObservationStatus::Unassessable,
255 SnsPolicyObservationStatus::Unassessable,
256 ),
257 1..=6 | 10 => (
258 SnsPolicyObservationStatus::ObservedSatisfied,
259 SnsPolicyObservationStatus::ObservedSatisfied,
260 ),
261 }
262}
263
264#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
271#[serde(deny_unknown_fields)]
272pub struct SnsNeuronDetailReport {
273 pub schema_version: u32,
275 pub network: String,
277 pub sns_wasm_canister_id: String,
279 pub fetched_at: String,
281 pub source_endpoint: String,
283 pub fetched_by: String,
285 pub id: usize,
287 pub name: String,
289 pub root_canister_id: String,
291 pub governance_canister_id: String,
293 pub neuron_id: String,
295 pub data_source: ReportDataSource,
297 pub detail: SnsNeuronDetail,
299}
300
301#[must_use]
303pub const fn sns_neuron_permission_name(code: i32) -> &'static str {
304 match code {
305 0 => "unspecified",
306 1 => "configure_dissolve_state",
307 2 => "manage_principals",
308 3 => "submit_proposal",
309 4 => "vote",
310 5 => "disburse",
311 6 => "split",
312 7 => "merge_maturity",
313 8 => "disburse_maturity",
314 9 => "stake_maturity",
315 10 => "manage_voting_permission",
316 _ => "unknown",
317 }
318}
319
320pub(in crate::sns::report) fn validate_neuron_permissions(
321 permissions: &[SnsNeuronPermissionRow],
322 require_principal: bool,
323) -> Result<(), String> {
324 let mut principals = HashSet::new();
325 for permission in permissions {
326 let principal = match permission.principal.as_deref() {
327 Some(principal) => {
328 validate_canonical_principal(principal, "permission principal")?;
329 if !principals.insert(principal) {
330 return Err(format!("duplicate permission principal {principal}"));
331 }
332 principal
333 }
334 None if require_principal => {
335 return Err("permission principal is missing".to_string());
336 }
337 None => "missing principal",
338 };
339 let mut codes = HashSet::new();
340 for value in &permission.permission_types {
341 if value.name != sns_neuron_permission_name(value.code) {
342 return Err(format!(
343 "permission code {} has label {}, expected {}",
344 value.code,
345 value.name,
346 sns_neuron_permission_name(value.code)
347 ));
348 }
349 if !codes.insert(value.code) {
350 return Err(format!(
351 "{principal} contains duplicate permission code {}",
352 value.code
353 ));
354 }
355 }
356 }
357 Ok(())
358}
359
360pub(in crate::sns::report) fn validate_maturity_disbursements(
361 disbursements: &[SnsMaturityDisbursementRow],
362) -> Result<(), String> {
363 for disbursement in disbursements {
364 if let Some(account) = disbursement.account_to_disburse_to.as_ref() {
365 if let Some(owner) = account.owner.as_deref() {
366 validate_canonical_principal(owner, "pending disbursement account owner")?;
367 }
368 if let Some(subaccount) = account.subaccount_hex.as_deref()
369 && (subaccount.len() != 64 || !is_lowercase_hex(subaccount))
370 {
371 return Err(
372 "pending disbursement subaccount is not 32-byte lowercase hexadecimal text"
373 .to_string(),
374 );
375 }
376 }
377 }
378 Ok(())
379}
380
381pub(super) fn validate_canonical_principal(value: &str, field: &str) -> Result<(), String> {
382 let principal =
383 Principal::from_text(value).map_err(|err| format!("{field} {value} is invalid: {err}"))?;
384 if principal.to_text() == value {
385 Ok(())
386 } else {
387 Err(format!("{field} {value} is not canonical principal text"))
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn permission_names_cover_native_and_unknown_codes() {
397 for (code, expected) in [
398 (0, "unspecified"),
399 (1, "configure_dissolve_state"),
400 (2, "manage_principals"),
401 (3, "submit_proposal"),
402 (4, "vote"),
403 (5, "disburse"),
404 (6, "split"),
405 (7, "merge_maturity"),
406 (8, "disburse_maturity"),
407 (9, "stake_maturity"),
408 (10, "manage_voting_permission"),
409 (11, "unknown"),
410 (-1, "unknown"),
411 ] {
412 assert_eq!(sns_neuron_permission_name(code), expected, "code {code}");
413 }
414 }
415
416 #[test]
417 fn policy_observations_fail_closed_and_prioritize_known_violations() {
418 let mut detail = SnsNeuronDetail {
419 neuron: SnsNeuronRow {
420 neuron_id: "00".repeat(32),
421 cached_neuron_stake_e8s: 0,
422 maturity_e8s_equivalent: 0,
423 staked_maturity_e8s_equivalent: None,
424 created_timestamp_seconds: 0,
425 created_at: "1970-01-01T00:00:00Z".to_string(),
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}