penumbra_sdk_governance/
proposal.rs

1use anyhow::Context;
2use bytes::Bytes;
3use ibc_types::core::client::ClientId;
4use serde::{Deserialize, Serialize};
5use std::str::FromStr;
6
7use crate::change::ParameterChange;
8use penumbra_sdk_proto::{penumbra::core::component::governance::v1 as pb, DomainType};
9
10/// A governance proposal.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(try_from = "pb::Proposal", into = "pb::Proposal")]
13pub struct Proposal {
14    /// The ID number of the proposal.
15    pub id: u64,
16
17    /// A short title describing the intent of the proposal.
18    pub title: String,
19
20    /// A natural-language description of the effect of the proposal and its justification.
21    pub description: String,
22
23    /// The specific kind and attributes of the proposal.
24    pub payload: ProposalPayload,
25}
26
27/// The protobuf type URL for a transaction plan.
28pub const TRANSACTION_PLAN_TYPE_URL: &str = "/penumbra.core.transaction.v1.TransactionPlan";
29
30impl From<Proposal> for pb::Proposal {
31    fn from(inner: Proposal) -> pb::Proposal {
32        let mut proposal = pb::Proposal {
33            id: inner.id,
34            title: inner.title,
35            description: inner.description,
36            ..Default::default() // We're about to fill in precisely one of the fields for the payload
37        };
38        use pb::proposal::Payload;
39        let payload = match inner.payload {
40            ProposalPayload::Signaling { commit } => {
41                Some(Payload::Signaling(pb::proposal::Signaling {
42                    commit: if let Some(c) = commit {
43                        c
44                    } else {
45                        String::default()
46                    },
47                }))
48            }
49            ProposalPayload::Emergency { halt_chain } => {
50                Some(Payload::Emergency(pb::proposal::Emergency { halt_chain }))
51            }
52            ProposalPayload::ParameterChange(change) => {
53                Some(Payload::ParameterChange(change.into()))
54            }
55            ProposalPayload::CommunityPoolSpend { transaction_plan } => Some(
56                Payload::CommunityPoolSpend(pb::proposal::CommunityPoolSpend {
57                    transaction_plan: Some(pbjson_types::Any {
58                        type_url: TRANSACTION_PLAN_TYPE_URL.to_owned(),
59                        value: transaction_plan.into(),
60                    }),
61                }),
62            ),
63            ProposalPayload::UpgradePlan { height } => {
64                Some(Payload::UpgradePlan(pb::proposal::UpgradePlan { height }))
65            }
66            ProposalPayload::FreezeIbcClient { client_id } => {
67                Some(Payload::FreezeIbcClient(pb::proposal::FreezeIbcClient {
68                    client_id: client_id.into(),
69                }))
70            }
71            ProposalPayload::UnfreezeIbcClient { client_id } => Some(Payload::UnfreezeIbcClient(
72                pb::proposal::UnfreezeIbcClient {
73                    client_id: client_id.into(),
74                },
75            )),
76        };
77        proposal.payload = payload;
78        proposal
79    }
80}
81
82impl TryFrom<pb::Proposal> for Proposal {
83    type Error = anyhow::Error;
84
85    fn try_from(inner: pb::Proposal) -> Result<Proposal, Self::Error> {
86        // Validation (matches limits from `impl AppActionHandler for ProposalSubmit`):
87        // - Title has a max length of 80 chars
88        if inner.title.len() > 80 {
89            anyhow::bail!("proposal title field must be less than 80 characters");
90        }
91
92        // - Description has a max length of 10_000 chars
93        if inner.description.len() > 10_000 {
94            anyhow::bail!("proposal description must be less than 10,000 characters");
95        }
96
97        use pb::proposal::Payload;
98        Ok(Proposal {
99            id: inner.id,
100            title: inner.title,
101            description: inner.description,
102            payload: match inner
103                .payload
104                .ok_or_else(|| anyhow::anyhow!("missing proposal payload"))?
105            {
106                Payload::Signaling(signaling) => ProposalPayload::Signaling {
107                    commit: if signaling.commit.is_empty() {
108                        None
109                    } else {
110                        // Commit hash has max length of 255 bytes:
111                        if signaling.commit.len() > 255 {
112                            anyhow::bail!("proposal commit hash must be less than 255 bytes");
113                        }
114
115                        Some(signaling.commit)
116                    },
117                },
118                Payload::Emergency(emergency) => ProposalPayload::Emergency {
119                    halt_chain: emergency.halt_chain,
120                },
121                Payload::ParameterChange(change) => {
122                    ProposalPayload::ParameterChange(change.try_into()?)
123                }
124                Payload::CommunityPoolSpend(community_pool_spend) => {
125                    ProposalPayload::CommunityPoolSpend {
126                        transaction_plan: {
127                            let transaction_plan = community_pool_spend
128                                .transaction_plan
129                                .ok_or_else(|| anyhow::anyhow!("missing transaction plan"))?;
130                            if transaction_plan.type_url != TRANSACTION_PLAN_TYPE_URL {
131                                anyhow::bail!(
132                                    "unknown transaction plan type url: {}",
133                                    transaction_plan.type_url
134                                );
135                            }
136                            transaction_plan.value.to_vec()
137                        },
138                    }
139                }
140                Payload::UpgradePlan(upgrade_plan) => ProposalPayload::UpgradePlan {
141                    height: upgrade_plan.height,
142                },
143                Payload::FreezeIbcClient(freeze_ibc_client) => {
144                    // Validation: client ID has a max length of 128 bytes
145                    if freeze_ibc_client.client_id.len() > 128 {
146                        anyhow::bail!("client ID must be less than 128 bytes");
147                    }
148                    // Validation: Check the client ID is valid using the validation inside `ClientId::from_str`.
149                    ClientId::from_str(&freeze_ibc_client.client_id)
150                        .map_err(|e| anyhow::anyhow!("invalid client id: {e}"))?;
151                    ProposalPayload::FreezeIbcClient {
152                        client_id: freeze_ibc_client.client_id,
153                    }
154                }
155                Payload::UnfreezeIbcClient(unfreeze_ibc_client) => {
156                    // Validation: client ID has a max length of 128 bytes
157                    if unfreeze_ibc_client.client_id.len() > 128 {
158                        anyhow::bail!("client ID must be less than 128 bytes");
159                    }
160                    // Validation: Check the client ID is valid using the validation inside `ClientId::from_str`.
161                    ClientId::from_str(&unfreeze_ibc_client.client_id)
162                        .map_err(|e| anyhow::anyhow!("invalid client id: {e}"))?;
163                    ProposalPayload::UnfreezeIbcClient {
164                        client_id: unfreeze_ibc_client.client_id,
165                    }
166                }
167            },
168        })
169    }
170}
171
172impl DomainType for Proposal {
173    type Proto = pb::Proposal;
174}
175
176/// A human-readable TOML-serializable version of a proposal.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ProposalToml {
179    pub id: u64,
180    pub title: String,
181    pub description: String,
182    #[serde(flatten)]
183    pub payload: ProposalPayloadToml,
184}
185
186impl From<Proposal> for ProposalToml {
187    fn from(proposal: Proposal) -> ProposalToml {
188        ProposalToml {
189            id: proposal.id,
190            title: proposal.title,
191            description: proposal.description,
192            payload: proposal.payload.into(),
193        }
194    }
195}
196
197impl TryFrom<ProposalToml> for Proposal {
198    type Error = anyhow::Error;
199
200    fn try_from(proposal: ProposalToml) -> Result<Proposal, Self::Error> {
201        Ok(Proposal {
202            id: proposal.id,
203            title: proposal.title,
204            description: proposal.description,
205            payload: proposal.payload.try_into()?,
206        })
207    }
208}
209
210/// The specific kind of a proposal.
211#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
212#[serde(try_from = "pb::ProposalKind", into = "pb::ProposalKind")]
213pub enum ProposalKind {
214    /// A signaling proposal.
215    Signaling,
216    /// An emergency proposal.
217    Emergency,
218    /// A parameter change proposal.
219    ParameterChange,
220    /// A Community Pool spend proposal.
221    CommunityPoolSpend,
222    /// An upgrade proposal.
223    UpgradePlan,
224    /// A proposal to freeze an IBC client.
225    FreezeIbcClient,
226    /// A proposal to unfreeze an IBC client.
227    UnfreezeIbcClient,
228}
229
230impl From<ProposalKind> for pb::ProposalKind {
231    fn from(kind: ProposalKind) -> pb::ProposalKind {
232        match kind {
233            ProposalKind::Signaling => pb::ProposalKind::Signaling,
234            ProposalKind::Emergency => pb::ProposalKind::Emergency,
235            ProposalKind::ParameterChange => pb::ProposalKind::ParameterChange,
236            ProposalKind::CommunityPoolSpend => pb::ProposalKind::CommunityPoolSpend,
237            ProposalKind::UpgradePlan => pb::ProposalKind::UpgradePlan,
238            ProposalKind::FreezeIbcClient => pb::ProposalKind::FreezeIbcClient,
239            ProposalKind::UnfreezeIbcClient => pb::ProposalKind::UnfreezeIbcClient,
240        }
241    }
242}
243
244impl TryFrom<pb::ProposalKind> for ProposalKind {
245    type Error = anyhow::Error;
246
247    fn try_from(kind: pb::ProposalKind) -> anyhow::Result<ProposalKind> {
248        let kind = match kind {
249            pb::ProposalKind::Unspecified => anyhow::bail!("unspecified proposal kind"),
250            pb::ProposalKind::Signaling => ProposalKind::Signaling,
251            pb::ProposalKind::Emergency => ProposalKind::Emergency,
252            pb::ProposalKind::ParameterChange => ProposalKind::ParameterChange,
253            pb::ProposalKind::CommunityPoolSpend => ProposalKind::CommunityPoolSpend,
254            pb::ProposalKind::UpgradePlan => ProposalKind::UpgradePlan,
255            pb::ProposalKind::FreezeIbcClient => ProposalKind::FreezeIbcClient,
256            pb::ProposalKind::UnfreezeIbcClient => ProposalKind::UnfreezeIbcClient,
257        };
258        Ok(kind)
259    }
260}
261
262impl FromStr for ProposalKind {
263    type Err = anyhow::Error;
264
265    fn from_str(s: &str) -> Result<Self, Self::Err> {
266        match s {
267            "signaling" => Ok(ProposalKind::Signaling),
268            "emergency" => Ok(ProposalKind::Emergency),
269            "parameter_change" => Ok(ProposalKind::ParameterChange),
270            "community_pool_spend" => Ok(ProposalKind::CommunityPoolSpend),
271            "upgrade_plan" => Ok(ProposalKind::UpgradePlan),
272            _ => Err(anyhow::anyhow!("invalid proposal kind: {}", s)),
273        }
274    }
275}
276
277impl Proposal {
278    /// Get the kind of a proposal.
279    pub fn kind(&self) -> ProposalKind {
280        match self.payload {
281            ProposalPayload::Signaling { .. } => ProposalKind::Signaling,
282            ProposalPayload::Emergency { .. } => ProposalKind::Emergency,
283            ProposalPayload::ParameterChange { .. } => ProposalKind::ParameterChange,
284            ProposalPayload::CommunityPoolSpend { .. } => ProposalKind::CommunityPoolSpend,
285            ProposalPayload::UpgradePlan { .. } => ProposalKind::UpgradePlan,
286            ProposalPayload::FreezeIbcClient { .. } => ProposalKind::FreezeIbcClient,
287            ProposalPayload::UnfreezeIbcClient { .. } => ProposalKind::UnfreezeIbcClient,
288        }
289    }
290}
291
292/// The machine-interpretable body of a proposal.
293#[derive(Debug, Clone, Eq, PartialEq)]
294pub enum ProposalPayload {
295    /// A signaling proposal is merely for coordination; it does not enact anything automatically by
296    /// itself.
297    Signaling {
298        /// An optional commit hash for code that this proposal refers to.
299        commit: Option<String>,
300    },
301    /// An emergency proposal is immediately passed when 1/3 of all validators approve it, without
302    /// waiting for the voting period to conclude.
303    Emergency {
304        /// If `halt_chain == true`, then the chain will immediately halt when the proposal is
305        /// passed.
306        halt_chain: bool,
307    },
308    /// A parameter change proposal describes a change to the app parameters, which should
309    /// take effect when the proposal is passed.
310    ParameterChange(ParameterChange),
311    /// A Community Pool spend proposal describes proposed transaction(s) to be executed or cancelled at
312    /// specific heights, with the spend authority of the Community Pool.
313    CommunityPoolSpend {
314        /// The transaction plan to be executed at the time the proposal is passed.
315        ///
316        /// This must be a transaction plan which can be executed by the Community Pool, which means it can't
317        /// require any witness data or authorization signatures, but it may use the `CommunityPoolSpend`
318        /// action.
319        transaction_plan: Vec<u8>,
320    },
321    /// An upgrade plan proposal describes a planned upgrade to the chain. If ratified, the chain
322    /// will halt at the specified height, trigger an epoch transition, and halt the chain.
323    UpgradePlan { height: u64 },
324    /// A proposal to freeze a specific IBC client.
325    FreezeIbcClient {
326        /// The identifier of the client to freeze.
327        client_id: String,
328    },
329    /// A proposal to unfreeze a specific IBC client.
330    UnfreezeIbcClient {
331        /// The identifier of the client to unfreeze.
332        client_id: String,
333    },
334}
335
336/// A TOML-serializable version of `ProposalPayload`, meant for human consumption.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338#[serde(tag = "kind", rename_all = "snake_case")]
339pub enum ProposalPayloadToml {
340    Signaling { commit: Option<String> },
341    Emergency { halt_chain: bool },
342    ParameterChange(ParameterChange),
343    CommunityPoolSpend { transaction: String },
344    UpgradePlan { height: u64 },
345    FreezeIbcClient { client_id: String },
346    UnfreezeIbcClient { client_id: String },
347}
348
349impl TryFrom<ProposalPayloadToml> for ProposalPayload {
350    type Error = anyhow::Error;
351
352    fn try_from(toml: ProposalPayloadToml) -> Result<Self, Self::Error> {
353        Ok(match toml {
354            ProposalPayloadToml::Signaling { commit } => ProposalPayload::Signaling { commit },
355            ProposalPayloadToml::Emergency { halt_chain } => {
356                ProposalPayload::Emergency { halt_chain }
357            }
358            ProposalPayloadToml::ParameterChange(change) => {
359                ProposalPayload::ParameterChange(change)
360            }
361            ProposalPayloadToml::CommunityPoolSpend { transaction } => {
362                ProposalPayload::CommunityPoolSpend {
363                    transaction_plan: Bytes::from(
364                        base64::Engine::decode(
365                            &base64::engine::general_purpose::STANDARD,
366                            transaction,
367                        )
368                        .context("couldn't decode transaction plan from base64")?,
369                    )
370                    .to_vec(),
371                }
372            }
373            ProposalPayloadToml::UpgradePlan { height } => ProposalPayload::UpgradePlan { height },
374            ProposalPayloadToml::FreezeIbcClient { client_id } => {
375                ProposalPayload::FreezeIbcClient { client_id }
376            }
377            ProposalPayloadToml::UnfreezeIbcClient { client_id } => {
378                ProposalPayload::UnfreezeIbcClient { client_id }
379            }
380        })
381    }
382}
383
384impl From<ProposalPayload> for ProposalPayloadToml {
385    fn from(payload: ProposalPayload) -> Self {
386        match payload {
387            ProposalPayload::Signaling { commit } => ProposalPayloadToml::Signaling { commit },
388            ProposalPayload::Emergency { halt_chain } => {
389                ProposalPayloadToml::Emergency { halt_chain }
390            }
391            ProposalPayload::ParameterChange(change) => {
392                ProposalPayloadToml::ParameterChange(change)
393            }
394            ProposalPayload::CommunityPoolSpend { transaction_plan } => {
395                ProposalPayloadToml::CommunityPoolSpend {
396                    transaction: base64::Engine::encode(
397                        &base64::engine::general_purpose::STANDARD,
398                        transaction_plan,
399                    ),
400                }
401            }
402            ProposalPayload::UpgradePlan { height } => ProposalPayloadToml::UpgradePlan { height },
403            ProposalPayload::FreezeIbcClient { client_id } => {
404                ProposalPayloadToml::FreezeIbcClient { client_id }
405            }
406            ProposalPayload::UnfreezeIbcClient { client_id } => {
407                ProposalPayloadToml::UnfreezeIbcClient { client_id }
408            }
409        }
410    }
411}
412
413impl ProposalPayload {
414    pub fn is_signaling(&self) -> bool {
415        matches!(self, ProposalPayload::Signaling { .. })
416    }
417
418    pub fn is_emergency(&self) -> bool {
419        matches!(self, ProposalPayload::Emergency { .. })
420    }
421
422    pub fn is_ibc_freeze(&self) -> bool {
423        matches!(self, ProposalPayload::FreezeIbcClient { .. })
424            || matches!(self, ProposalPayload::UnfreezeIbcClient { .. })
425    }
426
427    pub fn is_parameter_change(&self) -> bool {
428        matches!(self, ProposalPayload::ParameterChange { .. })
429    }
430
431    pub fn is_community_pool_spend(&self) -> bool {
432        matches!(self, ProposalPayload::CommunityPoolSpend { .. })
433    }
434}