1use 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#[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 SnsNeuronPermissionRow {
61 pub principal: Option<String>,
63 pub permission_types: Vec<i32>,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
74#[serde(deny_unknown_fields)]
75pub struct SnsNeuronAccount {
76 pub owner: Option<String>,
78 pub subaccount_hex: Option<String>,
80}
81
82#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
89#[serde(deny_unknown_fields)]
90pub struct SnsMaturityDisbursementRow {
91 pub timestamp_of_disbursement_seconds: u64,
93 pub amount_e8s: u64,
95 pub account_to_disburse_to: Option<SnsNeuronAccount>,
97 pub finalize_disbursement_timestamp_seconds: Option<u64>,
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
108#[serde(deny_unknown_fields)]
109pub struct SnsNeuronFolloweesRow {
110 pub function_id: u64,
112 pub followee_neuron_ids: Vec<String>,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
123#[serde(deny_unknown_fields)]
124pub struct SnsNeuronFolloweeRow {
125 pub neuron_id: Option<String>,
127 pub alias: Option<String>,
129}
130
131#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
138#[serde(deny_unknown_fields)]
139pub struct SnsNeuronTopicFolloweesRow {
140 pub topic_code: i32,
142 pub topic: Option<String>,
144 pub followees: Vec<SnsNeuronFolloweeRow>,
146}
147
148#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
155#[serde(deny_unknown_fields)]
156pub struct SnsNeuronDetail {
157 pub neuron: SnsNeuronRow,
159 pub permissions: Vec<SnsNeuronPermissionRow>,
161 pub disburse_maturity_in_progress: Vec<SnsMaturityDisbursementRow>,
163 pub followees: Vec<SnsNeuronFolloweesRow>,
165 pub topic_followees: Option<Vec<SnsNeuronTopicFolloweesRow>>,
167}
168
169impl SnsNeuronDetail {
170 #[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#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
245#[serde(deny_unknown_fields)]
246pub struct SnsNeuronDetailReport {
247 pub schema_version: u32,
249 #[serde(flatten)]
251 pub context: SnsReportContext,
252 pub neuron_id: String,
254 pub data_source: ReportDataSource,
256 pub detail: SnsNeuronDetail,
258}
259
260#[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}