Skip to main content

ic_query/sns/report/model/reports/proposals/
row.rs

1//! Module: sns::report::model::reports::proposals::row
2//!
3//! Responsibility: define SNS proposal row and nested value DTOs.
4//! Does not own: source conversion, report-level metadata, or rendering.
5//! Boundary: preserves proposal detail fields for cache snapshots and JSON output.
6
7use serde::{Deserialize as SerdeDeserialize, Deserializer, Serialize, Serializer, de::Error as _};
8use std::{borrow::Cow, fmt};
9
10///
11/// SnsProposalAction
12///
13/// Native SNS Governance proposal action, including generic and unknown action ids.
14///
15
16#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
17pub enum SnsProposalAction {
18    /// No native action was specified.
19    Unspecified,
20    /// Adopt a governance motion.
21    Motion,
22    /// Change nervous-system parameters.
23    ManageNervousSystemParameters,
24    /// Upgrade an SNS-controlled canister.
25    UpgradeSnsControlledCanister,
26    /// Add a generic nervous-system function.
27    AddGenericNervousSystemFunction,
28    /// Remove a generic nervous-system function.
29    RemoveGenericNervousSystemFunction,
30    /// Execute a generic nervous-system function.
31    ExecuteGenericNervousSystemFunction,
32    /// Upgrade the SNS to its next blessed version.
33    UpgradeSnsToNextVersion,
34    /// Change SNS metadata.
35    ManageSnsMetadata,
36    /// Transfer SNS treasury funds.
37    TransferSnsTreasuryFunds,
38    /// Register dapp canisters with SNS Root.
39    RegisterDappCanisters,
40    /// Deregister dapp canisters from SNS Root.
41    DeregisterDappCanisters,
42    /// Mint SNS ledger tokens.
43    MintSnsTokens,
44    /// Change SNS ledger parameters.
45    ManageLedgerParameters,
46    /// Change dapp-canister settings.
47    ManageDappCanisterSettings,
48    /// Advance the SNS target version.
49    AdvanceSnsTargetVersion,
50    /// Assign topics to custom proposals.
51    SetTopicsForCustomProposals,
52    /// Register an SNS extension.
53    RegisterExtension,
54    /// Execute an SNS extension operation.
55    ExecuteExtensionOperation,
56    /// Upgrade an SNS extension.
57    UpgradeExtension,
58    /// A generic nervous-system function identified by its native action id.
59    Generic(u64),
60    /// An unrecognized native action id retained as evidence.
61    Unknown(u64),
62}
63
64impl SnsProposalAction {
65    /// Classify one raw native action id without discarding generic or unknown evidence.
66    #[must_use]
67    pub const fn from_id(id: u64) -> Self {
68        match id {
69            0 => Self::Unspecified,
70            1 => Self::Motion,
71            2 => Self::ManageNervousSystemParameters,
72            3 => Self::UpgradeSnsControlledCanister,
73            4 => Self::AddGenericNervousSystemFunction,
74            5 => Self::RemoveGenericNervousSystemFunction,
75            6 => Self::ExecuteGenericNervousSystemFunction,
76            7 => Self::UpgradeSnsToNextVersion,
77            8 => Self::ManageSnsMetadata,
78            9 => Self::TransferSnsTreasuryFunds,
79            10 => Self::RegisterDappCanisters,
80            11 => Self::DeregisterDappCanisters,
81            12 => Self::MintSnsTokens,
82            13 => Self::ManageLedgerParameters,
83            14 => Self::ManageDappCanisterSettings,
84            15 => Self::AdvanceSnsTargetVersion,
85            16 => Self::SetTopicsForCustomProposals,
86            17 => Self::RegisterExtension,
87            18 => Self::ExecuteExtensionOperation,
88            19 => Self::UpgradeExtension,
89            id if id >= 1_000 => Self::Generic(id),
90            id => Self::Unknown(id),
91        }
92    }
93
94    /// Return the raw native action id represented by this classification.
95    #[must_use]
96    pub const fn id(self) -> u64 {
97        match self {
98            Self::Unspecified => 0,
99            Self::Motion => 1,
100            Self::ManageNervousSystemParameters => 2,
101            Self::UpgradeSnsControlledCanister => 3,
102            Self::AddGenericNervousSystemFunction => 4,
103            Self::RemoveGenericNervousSystemFunction => 5,
104            Self::ExecuteGenericNervousSystemFunction => 6,
105            Self::UpgradeSnsToNextVersion => 7,
106            Self::ManageSnsMetadata => 8,
107            Self::TransferSnsTreasuryFunds => 9,
108            Self::RegisterDappCanisters => 10,
109            Self::DeregisterDappCanisters => 11,
110            Self::MintSnsTokens => 12,
111            Self::ManageLedgerParameters => 13,
112            Self::ManageDappCanisterSettings => 14,
113            Self::AdvanceSnsTargetVersion => 15,
114            Self::SetTopicsForCustomProposals => 16,
115            Self::RegisterExtension => 17,
116            Self::ExecuteExtensionOperation => 18,
117            Self::UpgradeExtension => 19,
118            Self::Generic(id) | Self::Unknown(id) => id,
119        }
120    }
121
122    /// Return the stable cache, JSON, and text label.
123    #[must_use]
124    pub fn label(self) -> Cow<'static, str> {
125        match self {
126            Self::Unspecified => Cow::Borrowed("unspecified"),
127            Self::Motion => Cow::Borrowed("motion"),
128            Self::ManageNervousSystemParameters => {
129                Cow::Borrowed("manage_nervous_system_parameters")
130            }
131            Self::UpgradeSnsControlledCanister => Cow::Borrowed("upgrade_sns_controlled_canister"),
132            Self::AddGenericNervousSystemFunction => {
133                Cow::Borrowed("add_generic_nervous_system_function")
134            }
135            Self::RemoveGenericNervousSystemFunction => {
136                Cow::Borrowed("remove_generic_nervous_system_function")
137            }
138            Self::ExecuteGenericNervousSystemFunction => {
139                Cow::Borrowed("execute_generic_nervous_system_function")
140            }
141            Self::UpgradeSnsToNextVersion => Cow::Borrowed("upgrade_sns_to_next_version"),
142            Self::ManageSnsMetadata => Cow::Borrowed("manage_sns_metadata"),
143            Self::TransferSnsTreasuryFunds => Cow::Borrowed("transfer_sns_treasury_funds"),
144            Self::RegisterDappCanisters => Cow::Borrowed("register_dapp_canisters"),
145            Self::DeregisterDappCanisters => Cow::Borrowed("deregister_dapp_canisters"),
146            Self::MintSnsTokens => Cow::Borrowed("mint_sns_tokens"),
147            Self::ManageLedgerParameters => Cow::Borrowed("manage_ledger_parameters"),
148            Self::ManageDappCanisterSettings => Cow::Borrowed("manage_dapp_canister_settings"),
149            Self::AdvanceSnsTargetVersion => Cow::Borrowed("advance_sns_target_version"),
150            Self::SetTopicsForCustomProposals => Cow::Borrowed("set_topics_for_custom_proposals"),
151            Self::RegisterExtension => Cow::Borrowed("register_extension"),
152            Self::ExecuteExtensionOperation => Cow::Borrowed("execute_extension_operation"),
153            Self::UpgradeExtension => Cow::Borrowed("upgrade_extension"),
154            Self::Generic(id) => Cow::Owned(format!("generic:{id}")),
155            Self::Unknown(id) => Cow::Owned(format!("unknown:{id}")),
156        }
157    }
158
159    fn from_label(label: &str) -> Option<Self> {
160        let action = match label {
161            "unspecified" => Self::Unspecified,
162            "motion" => Self::Motion,
163            "manage_nervous_system_parameters" => Self::ManageNervousSystemParameters,
164            "upgrade_sns_controlled_canister" => Self::UpgradeSnsControlledCanister,
165            "add_generic_nervous_system_function" => Self::AddGenericNervousSystemFunction,
166            "remove_generic_nervous_system_function" => Self::RemoveGenericNervousSystemFunction,
167            "execute_generic_nervous_system_function" => Self::ExecuteGenericNervousSystemFunction,
168            "upgrade_sns_to_next_version" => Self::UpgradeSnsToNextVersion,
169            "manage_sns_metadata" => Self::ManageSnsMetadata,
170            "transfer_sns_treasury_funds" => Self::TransferSnsTreasuryFunds,
171            "register_dapp_canisters" => Self::RegisterDappCanisters,
172            "deregister_dapp_canisters" => Self::DeregisterDappCanisters,
173            "mint_sns_tokens" => Self::MintSnsTokens,
174            "manage_ledger_parameters" => Self::ManageLedgerParameters,
175            "manage_dapp_canister_settings" => Self::ManageDappCanisterSettings,
176            "advance_sns_target_version" => Self::AdvanceSnsTargetVersion,
177            "set_topics_for_custom_proposals" => Self::SetTopicsForCustomProposals,
178            "register_extension" => Self::RegisterExtension,
179            "execute_extension_operation" => Self::ExecuteExtensionOperation,
180            "upgrade_extension" => Self::UpgradeExtension,
181            _ => {
182                let id = label
183                    .strip_prefix("generic:")
184                    .or_else(|| label.strip_prefix("unknown:"))?
185                    .parse::<u64>()
186                    .ok()?;
187                Self::from_id(id)
188            }
189        };
190        (action.label().as_ref() == label).then_some(action)
191    }
192}
193
194impl fmt::Display for SnsProposalAction {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        formatter.write_str(self.label().as_ref())
197    }
198}
199
200impl Serialize for SnsProposalAction {
201    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
202    where
203        S: Serializer,
204    {
205        serializer.serialize_str(self.label().as_ref())
206    }
207}
208
209impl<'de> SerdeDeserialize<'de> for SnsProposalAction {
210    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
211    where
212        D: Deserializer<'de>,
213    {
214        let label = String::deserialize(deserializer)?;
215        Self::from_label(&label)
216            .ok_or_else(|| D::Error::custom(format!("invalid SNS proposal action {label:?}")))
217    }
218}
219
220///
221/// SnsProposalVote
222///
223/// Native SNS Governance ballot vote, retaining unknown raw codes.
224///
225
226#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
227pub enum SnsProposalVote {
228    /// No native vote was specified.
229    Unspecified,
230    /// Affirmative ballot.
231    Yes,
232    /// Negative ballot.
233    No,
234    /// An unrecognized native vote code retained as evidence.
235    Unknown(i32),
236}
237
238impl SnsProposalVote {
239    /// Classify one raw native vote code without discarding unknown evidence.
240    #[must_use]
241    pub const fn from_code(code: i32) -> Self {
242        match code {
243            0 => Self::Unspecified,
244            1 => Self::Yes,
245            2 => Self::No,
246            code => Self::Unknown(code),
247        }
248    }
249
250    /// Return the raw native vote code represented by this classification.
251    #[must_use]
252    pub const fn code(self) -> i32 {
253        match self {
254            Self::Unspecified => 0,
255            Self::Yes => 1,
256            Self::No => 2,
257            Self::Unknown(code) => code,
258        }
259    }
260
261    /// Return the stable cache, JSON, and text label.
262    #[must_use]
263    pub fn label(self) -> Cow<'static, str> {
264        match self {
265            Self::Unspecified => Cow::Borrowed("unspecified"),
266            Self::Yes => Cow::Borrowed("yes"),
267            Self::No => Cow::Borrowed("no"),
268            Self::Unknown(code) => Cow::Owned(format!("unknown:{code}")),
269        }
270    }
271
272    fn from_label(label: &str) -> Option<Self> {
273        let vote = match label {
274            "unspecified" => Self::Unspecified,
275            "yes" => Self::Yes,
276            "no" => Self::No,
277            _ => Self::from_code(label.strip_prefix("unknown:")?.parse::<i32>().ok()?),
278        };
279        (vote.label().as_ref() == label).then_some(vote)
280    }
281}
282
283impl fmt::Display for SnsProposalVote {
284    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285        formatter.write_str(self.label().as_ref())
286    }
287}
288
289impl Serialize for SnsProposalVote {
290    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
291    where
292        S: Serializer,
293    {
294        serializer.serialize_str(self.label().as_ref())
295    }
296}
297
298impl<'de> SerdeDeserialize<'de> for SnsProposalVote {
299    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300    where
301        D: Deserializer<'de>,
302    {
303        let label = String::deserialize(deserializer)?;
304        Self::from_label(&label)
305            .ok_or_else(|| D::Error::custom(format!("invalid SNS proposal vote {label:?}")))
306    }
307}
308
309///
310/// SnsProposalDecisionState
311///
312/// Derived lifecycle state for one SNS Governance proposal.
313///
314
315#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, SerdeDeserialize, Serialize)]
316#[serde(rename_all = "snake_case")]
317pub enum SnsProposalDecisionState {
318    /// The proposal has not reached a decision.
319    Open,
320    /// The proposal was decided without an execution or failure timestamp.
321    Decided,
322    /// The proposal has an execution timestamp.
323    Executed,
324    /// The proposal has a failure timestamp.
325    Failed,
326}
327
328impl SnsProposalDecisionState {
329    /// Return the stable cache, JSON, and text label.
330    #[must_use]
331    pub const fn as_str(self) -> &'static str {
332        match self {
333            Self::Open => "open",
334            Self::Decided => "decided",
335            Self::Executed => "executed",
336            Self::Failed => "failed",
337        }
338    }
339}
340
341///
342/// SnsProposalRow
343///
344/// Serializable row for one SNS governance proposal.
345///
346
347#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
348pub struct SnsProposalRow {
349    pub proposal_id: u64,
350    pub action_id: u64,
351    pub action: SnsProposalAction,
352    pub title: String,
353    pub summary: String,
354    pub url: Option<String>,
355    pub decision_state: SnsProposalDecisionState,
356    #[serde(deserialize_with = "deserialize_required_option")]
357    pub status: Option<i32>,
358    #[serde(deserialize_with = "deserialize_required_option")]
359    pub topic: Option<String>,
360    pub reject_cost_e8s: u64,
361    pub proposal_creation_timestamp_seconds: u64,
362    pub created_at: String,
363    pub decided_timestamp_seconds: Option<u64>,
364    pub decided_at: Option<String>,
365    pub executed_timestamp_seconds: Option<u64>,
366    pub executed_at: Option<String>,
367    pub failed_timestamp_seconds: Option<u64>,
368    pub failed_at: Option<String>,
369    pub failure_reason: Option<SnsProposalFailureReason>,
370    pub reward_event_round: u64,
371    pub reward_event_end_timestamp_seconds: Option<u64>,
372    pub is_eligible_for_rewards: bool,
373    pub latest_tally: Option<SnsProposalTally>,
374    pub ballot_count: usize,
375    pub ballots: Vec<SnsProposalBallotRow>,
376    pub payload_text_rendering: Option<String>,
377    pub proposer_neuron_id: Option<String>,
378}
379
380fn deserialize_required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
381where
382    D: Deserializer<'de>,
383    T: SerdeDeserialize<'de>,
384{
385    Option::<T>::deserialize(deserializer)
386}
387
388///
389/// SnsProposalBallotRow
390///
391/// Serializable row for one proposal ballot.
392///
393
394#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
395pub struct SnsProposalBallotRow {
396    pub neuron_id: String,
397    pub vote: i32,
398    pub vote_text: SnsProposalVote,
399    pub cast_timestamp_seconds: u64,
400    pub cast_at: Option<String>,
401    pub voting_power: u64,
402}
403
404///
405/// SnsProposalFailureReason
406///
407/// Serializable SNS governance failure reason attached to a proposal.
408///
409
410#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
411pub struct SnsProposalFailureReason {
412    pub error_type: i32,
413    pub error_message: String,
414}
415
416///
417/// SnsProposalTally
418///
419/// Serializable SNS proposal vote tally.
420///
421
422#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
423pub struct SnsProposalTally {
424    pub timestamp_seconds: u64,
425    pub yes: u64,
426    pub no: u64,
427    pub total: u64,
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    const NATIVE_ACTION_CASES: [(u64, SnsProposalAction, &str); 20] = [
435        (0, SnsProposalAction::Unspecified, "unspecified"),
436        (1, SnsProposalAction::Motion, "motion"),
437        (
438            2,
439            SnsProposalAction::ManageNervousSystemParameters,
440            "manage_nervous_system_parameters",
441        ),
442        (
443            3,
444            SnsProposalAction::UpgradeSnsControlledCanister,
445            "upgrade_sns_controlled_canister",
446        ),
447        (
448            4,
449            SnsProposalAction::AddGenericNervousSystemFunction,
450            "add_generic_nervous_system_function",
451        ),
452        (
453            5,
454            SnsProposalAction::RemoveGenericNervousSystemFunction,
455            "remove_generic_nervous_system_function",
456        ),
457        (
458            6,
459            SnsProposalAction::ExecuteGenericNervousSystemFunction,
460            "execute_generic_nervous_system_function",
461        ),
462        (
463            7,
464            SnsProposalAction::UpgradeSnsToNextVersion,
465            "upgrade_sns_to_next_version",
466        ),
467        (
468            8,
469            SnsProposalAction::ManageSnsMetadata,
470            "manage_sns_metadata",
471        ),
472        (
473            9,
474            SnsProposalAction::TransferSnsTreasuryFunds,
475            "transfer_sns_treasury_funds",
476        ),
477        (
478            10,
479            SnsProposalAction::RegisterDappCanisters,
480            "register_dapp_canisters",
481        ),
482        (
483            11,
484            SnsProposalAction::DeregisterDappCanisters,
485            "deregister_dapp_canisters",
486        ),
487        (12, SnsProposalAction::MintSnsTokens, "mint_sns_tokens"),
488        (
489            13,
490            SnsProposalAction::ManageLedgerParameters,
491            "manage_ledger_parameters",
492        ),
493        (
494            14,
495            SnsProposalAction::ManageDappCanisterSettings,
496            "manage_dapp_canister_settings",
497        ),
498        (
499            15,
500            SnsProposalAction::AdvanceSnsTargetVersion,
501            "advance_sns_target_version",
502        ),
503        (
504            16,
505            SnsProposalAction::SetTopicsForCustomProposals,
506            "set_topics_for_custom_proposals",
507        ),
508        (
509            17,
510            SnsProposalAction::RegisterExtension,
511            "register_extension",
512        ),
513        (
514            18,
515            SnsProposalAction::ExecuteExtensionOperation,
516            "execute_extension_operation",
517        ),
518        (19, SnsProposalAction::UpgradeExtension, "upgrade_extension"),
519    ];
520
521    #[test]
522    fn proposal_decision_state_labels_round_trip() {
523        for (state, label) in [
524            (SnsProposalDecisionState::Open, "open"),
525            (SnsProposalDecisionState::Decided, "decided"),
526            (SnsProposalDecisionState::Executed, "executed"),
527            (SnsProposalDecisionState::Failed, "failed"),
528        ] {
529            assert_eq!(
530                serde_json::to_string(&state).unwrap(),
531                format!("\"{label}\"")
532            );
533            assert_eq!(
534                serde_json::from_str::<SnsProposalDecisionState>(&format!("\"{label}\"")).unwrap(),
535                state
536            );
537        }
538        assert!(serde_json::from_str::<SnsProposalDecisionState>("\"unknown\"").is_err());
539    }
540
541    #[test]
542    fn proposal_action_labels_round_trip_native_generic_and_unknown_ids() {
543        for (id, action, label) in NATIVE_ACTION_CASES.into_iter().chain([
544            (20, SnsProposalAction::Unknown(20), "unknown:20"),
545            (1_000, SnsProposalAction::Generic(1_000), "generic:1000"),
546        ]) {
547            assert_eq!(SnsProposalAction::from_id(id), action);
548            assert_eq!(action.id(), id);
549            assert_eq!(action.label(), label);
550            assert_eq!(
551                serde_json::to_string(&action).unwrap(),
552                format!("\"{label}\"")
553            );
554            assert_eq!(
555                serde_json::from_str::<SnsProposalAction>(&format!("\"{label}\"")).unwrap(),
556                action
557            );
558        }
559        for invalid in ["unknown:1", "generic:20", "unknown:020", "future"] {
560            assert!(serde_json::from_str::<SnsProposalAction>(&format!("\"{invalid}\"")).is_err());
561        }
562    }
563
564    #[test]
565    fn proposal_vote_labels_round_trip_known_and_unknown_codes() {
566        for (code, vote, label) in [
567            (0, SnsProposalVote::Unspecified, "unspecified"),
568            (1, SnsProposalVote::Yes, "yes"),
569            (2, SnsProposalVote::No, "no"),
570            (99, SnsProposalVote::Unknown(99), "unknown:99"),
571            (-1, SnsProposalVote::Unknown(-1), "unknown:-1"),
572        ] {
573            assert_eq!(SnsProposalVote::from_code(code), vote);
574            assert_eq!(vote.code(), code);
575            assert_eq!(vote.label(), label);
576            assert_eq!(
577                serde_json::to_string(&vote).unwrap(),
578                format!("\"{label}\"")
579            );
580            assert_eq!(
581                serde_json::from_str::<SnsProposalVote>(&format!("\"{label}\"")).unwrap(),
582                vote
583            );
584        }
585        for invalid in ["unknown:1", "unknown:+3", "unknown:03", "maybe"] {
586            assert!(serde_json::from_str::<SnsProposalVote>(&format!("\"{invalid}\"")).is_err());
587        }
588    }
589}