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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(try_from = "pb::Proposal", into = "pb::Proposal")]
13pub struct Proposal {
14 pub id: u64,
16
17 pub title: String,
19
20 pub description: String,
22
23 pub payload: ProposalPayload,
25}
26
27pub 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() };
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 if inner.title.len() > 80 {
89 anyhow::bail!("proposal title field must be less than 80 characters");
90 }
91
92 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 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 if freeze_ibc_client.client_id.len() > 128 {
146 anyhow::bail!("client ID must be less than 128 bytes");
147 }
148 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 if unfreeze_ibc_client.client_id.len() > 128 {
158 anyhow::bail!("client ID must be less than 128 bytes");
159 }
160 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#[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#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
212#[serde(try_from = "pb::ProposalKind", into = "pb::ProposalKind")]
213pub enum ProposalKind {
214 Signaling,
216 Emergency,
218 ParameterChange,
220 CommunityPoolSpend,
222 UpgradePlan,
224 FreezeIbcClient,
226 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 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#[derive(Debug, Clone, Eq, PartialEq)]
294pub enum ProposalPayload {
295 Signaling {
298 commit: Option<String>,
300 },
301 Emergency {
304 halt_chain: bool,
307 },
308 ParameterChange(ParameterChange),
311 CommunityPoolSpend {
314 transaction_plan: Vec<u8>,
320 },
321 UpgradePlan { height: u64 },
324 FreezeIbcClient {
326 client_id: String,
328 },
329 UnfreezeIbcClient {
331 client_id: String,
333 },
334}
335
336#[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}