penumbra_sdk_governance/
proposal_state.rs

1use serde::{Deserialize, Serialize};
2
3use penumbra_sdk_proto::{penumbra::core::component::governance::v1 as pb, DomainType};
4
5use crate::MAX_VALIDATOR_VOTE_REASON_LENGTH;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(try_from = "pb::ProposalState", into = "pb::ProposalState")]
9pub enum State {
10    Voting,
11    Withdrawn { reason: String },
12    Finished { outcome: Outcome<String> },
13    Claimed { outcome: Outcome<String> },
14}
15
16impl State {
17    pub fn is_voting(&self) -> bool {
18        matches!(self, State::Voting)
19    }
20
21    pub fn is_withdrawn(&self) -> bool {
22        matches!(self, State::Withdrawn { .. })
23    }
24
25    pub fn is_finished(&self) -> bool {
26        matches!(self, State::Finished { .. })
27    }
28
29    pub fn is_claimed(&self) -> bool {
30        matches!(self, State::Claimed { .. })
31    }
32
33    pub fn is_passed(&self) -> bool {
34        match self {
35            State::Finished { outcome } => outcome.is_passed(),
36            State::Claimed { outcome } => outcome.is_passed(),
37            _ => false,
38        }
39    }
40
41    pub fn is_failed(&self) -> bool {
42        match self {
43            State::Finished { outcome } => outcome.is_failed(),
44            State::Claimed { outcome } => outcome.is_failed(),
45            _ => false,
46        }
47    }
48
49    pub fn is_slashed(&self) -> bool {
50        match self {
51            State::Finished { outcome } => outcome.is_slashed(),
52            State::Claimed { outcome } => outcome.is_slashed(),
53            _ => false,
54        }
55    }
56}
57
58impl State {
59    pub fn withdrawn(self) -> Withdrawn<String> {
60        match self {
61            State::Voting => Withdrawn::No,
62            State::Withdrawn { reason } => Withdrawn::WithReason { reason },
63            State::Finished { outcome } => match outcome {
64                Outcome::Passed => Withdrawn::No,
65                Outcome::Failed { withdrawn } | Outcome::Slashed { withdrawn } => withdrawn,
66            },
67            State::Claimed { outcome } => match outcome {
68                Outcome::Passed => Withdrawn::No,
69                Outcome::Failed { withdrawn } | Outcome::Slashed { withdrawn } => withdrawn,
70            },
71        }
72    }
73}
74
75impl DomainType for State {
76    type Proto = pb::ProposalState;
77}
78
79impl From<State> for pb::ProposalState {
80    fn from(s: State) -> Self {
81        let state = match s {
82            State::Voting => pb::proposal_state::State::Voting(pb::proposal_state::Voting {}),
83            State::Withdrawn { reason } => {
84                pb::proposal_state::State::Withdrawn(pb::proposal_state::Withdrawn { reason })
85            }
86            State::Finished { outcome } => {
87                pb::proposal_state::State::Finished(pb::proposal_state::Finished {
88                    outcome: Some(outcome.into()),
89                })
90            }
91            State::Claimed { outcome } => {
92                pb::proposal_state::State::Finished(pb::proposal_state::Finished {
93                    outcome: Some(outcome.into()),
94                })
95            }
96        };
97        pb::ProposalState { state: Some(state) }
98    }
99}
100
101impl TryFrom<pb::ProposalState> for State {
102    type Error = anyhow::Error;
103
104    fn try_from(msg: pb::ProposalState) -> Result<Self, Self::Error> {
105        Ok(
106            match msg
107                .state
108                .ok_or_else(|| anyhow::anyhow!("missing proposal state"))?
109            {
110                pb::proposal_state::State::Voting(pb::proposal_state::Voting {}) => State::Voting,
111                pb::proposal_state::State::Withdrawn(pb::proposal_state::Withdrawn { reason }) => {
112                    State::Withdrawn { reason }
113                }
114                pb::proposal_state::State::Finished(pb::proposal_state::Finished { outcome }) => {
115                    State::Finished {
116                        outcome: outcome
117                            .ok_or_else(|| anyhow::anyhow!("missing proposal outcome"))?
118                            .try_into()?,
119                    }
120                }
121                pb::proposal_state::State::Claimed(pb::proposal_state::Claimed { outcome }) => {
122                    State::Claimed {
123                        outcome: outcome
124                            .ok_or_else(|| anyhow::anyhow!("missing proposal outcome"))?
125                            .try_into()?,
126                    }
127                }
128            },
129        )
130    }
131}
132
133// This is parameterized by `W`, the withdrawal reason, so that we can use `()` where a reason
134// doesn't need to be specified. When this is the case, the serialized format in protobufs uses an
135// empty string.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy)]
137#[serde(
138    try_from = "pb::ProposalOutcome",
139    into = "pb::ProposalOutcome",
140    bound = "W: Clone, pb::ProposalOutcome: From<Outcome<W>>, Outcome<W>: TryFrom<pb::ProposalOutcome, Error = anyhow::Error>"
141)]
142pub enum Outcome<W> {
143    Passed,
144    Failed { withdrawn: Withdrawn<W> },
145    Slashed { withdrawn: Withdrawn<W> },
146}
147
148impl<W> Outcome<W> {
149    /// Determines if the outcome should be refunded (i.e. it was not slashed).
150    pub fn should_be_refunded(&self) -> bool {
151        !self.is_slashed()
152    }
153
154    pub fn is_slashed(&self) -> bool {
155        matches!(self, Outcome::Slashed { .. })
156    }
157
158    pub fn is_failed(&self) -> bool {
159        matches!(self, Outcome::Failed { .. } | Outcome::Slashed { .. })
160    }
161
162    pub fn is_passed(&self) -> bool {
163        matches!(self, Outcome::Passed)
164    }
165
166    pub fn as_ref(&self) -> Outcome<&W> {
167        match self {
168            Outcome::Passed => Outcome::Passed,
169            Outcome::Failed { withdrawn } => Outcome::Failed {
170                withdrawn: withdrawn.as_ref(),
171            },
172            Outcome::Slashed { withdrawn } => Outcome::Slashed {
173                withdrawn: withdrawn.as_ref(),
174            },
175        }
176    }
177
178    pub fn map<X>(self, f: impl FnOnce(W) -> X) -> Outcome<X> {
179        match self {
180            Outcome::Passed => Outcome::Passed,
181            Outcome::Failed { withdrawn } => Outcome::Failed {
182                withdrawn: Option::from(withdrawn).map(f).into(),
183            },
184            Outcome::Slashed { withdrawn } => Outcome::Slashed {
185                withdrawn: Option::from(withdrawn).map(f).into(),
186            },
187        }
188    }
189}
190
191// This is parameterized by `W`, the withdrawal reason, so that we can use `()` where a reason
192// doesn't need to be specified. When this is the case, the serialized format in protobufs uses an
193// empty string.
194#[derive(Debug, Clone, PartialEq, Eq, Copy)]
195pub enum Withdrawn<W> {
196    No,
197    WithReason { reason: W },
198}
199
200impl<W> Withdrawn<W> {
201    pub fn as_ref(&self) -> Withdrawn<&W> {
202        match self {
203            Withdrawn::No => Withdrawn::No,
204            Withdrawn::WithReason { reason } => Withdrawn::WithReason { reason },
205        }
206    }
207}
208
209impl<W> From<Option<W>> for Withdrawn<W> {
210    fn from(reason: Option<W>) -> Self {
211        match reason {
212            Some(reason) => Withdrawn::WithReason { reason },
213            None => Withdrawn::No,
214        }
215    }
216}
217
218impl<W> From<Withdrawn<W>> for Option<W> {
219    fn from(withdrawn: Withdrawn<W>) -> Self {
220        match withdrawn {
221            Withdrawn::No => None,
222            Withdrawn::WithReason { reason } => Some(reason),
223        }
224    }
225}
226
227impl TryFrom<Withdrawn<String>> for Withdrawn<()> {
228    type Error = anyhow::Error;
229
230    fn try_from(withdrawn: Withdrawn<String>) -> Result<Self, Self::Error> {
231        Ok(match withdrawn {
232            Withdrawn::No => Withdrawn::No,
233            Withdrawn::WithReason { reason } => {
234                if reason.is_empty() {
235                    Withdrawn::WithReason { reason: () }
236                } else {
237                    anyhow::bail!("withdrawn reason is not empty")
238                }
239            }
240        })
241    }
242}
243
244impl DomainType for Outcome<String> {
245    type Proto = pb::ProposalOutcome;
246}
247
248impl From<Outcome<String>> for pb::ProposalOutcome {
249    fn from(o: Outcome<String>) -> Self {
250        let outcome = match o {
251            Outcome::Passed => {
252                pb::proposal_outcome::Outcome::Passed(pb::proposal_outcome::Passed {})
253            }
254            Outcome::Failed { withdrawn } => {
255                pb::proposal_outcome::Outcome::Failed(pb::proposal_outcome::Failed {
256                    withdrawn: match withdrawn {
257                        Withdrawn::No => None,
258                        Withdrawn::WithReason { reason } => {
259                            Some(pb::proposal_outcome::Withdrawn { reason })
260                        }
261                    },
262                })
263            }
264            Outcome::Slashed { withdrawn } => {
265                pb::proposal_outcome::Outcome::Slashed(pb::proposal_outcome::Slashed {
266                    withdrawn: match withdrawn {
267                        Withdrawn::No => None,
268                        Withdrawn::WithReason { reason } => {
269                            Some(pb::proposal_outcome::Withdrawn { reason })
270                        }
271                    },
272                })
273            }
274        };
275        pb::ProposalOutcome {
276            outcome: Some(outcome),
277        }
278    }
279}
280
281impl TryFrom<pb::ProposalOutcome> for Outcome<String> {
282    type Error = anyhow::Error;
283
284    fn try_from(msg: pb::ProposalOutcome) -> Result<Self, Self::Error> {
285        Ok(
286            match msg
287                .outcome
288                .ok_or_else(|| anyhow::anyhow!("missing proposal outcome"))?
289            {
290                pb::proposal_outcome::Outcome::Passed(pb::proposal_outcome::Passed {}) => {
291                    Outcome::Passed
292                }
293                pb::proposal_outcome::Outcome::Failed(pb::proposal_outcome::Failed {
294                    withdrawn,
295                }) => Outcome::Failed {
296                    withdrawn: if let Some(pb::proposal_outcome::Withdrawn { reason }) = withdrawn {
297                        // Max reason length is 1kb
298                        if reason.len() > MAX_VALIDATOR_VOTE_REASON_LENGTH {
299                            anyhow::bail!("withdrawn reason must be smaller than 1kb")
300                        }
301
302                        Withdrawn::WithReason { reason }
303                    } else {
304                        Withdrawn::No
305                    },
306                },
307                pb::proposal_outcome::Outcome::Slashed(pb::proposal_outcome::Slashed {
308                    withdrawn,
309                }) => Outcome::Slashed {
310                    withdrawn: if let Some(pb::proposal_outcome::Withdrawn { reason }) = withdrawn {
311                        // Max reason length is 1kb
312                        if reason.len() > MAX_VALIDATOR_VOTE_REASON_LENGTH {
313                            anyhow::bail!("withdrawn reason must be smaller than 1kb")
314                        }
315                        Withdrawn::WithReason { reason }
316                    } else {
317                        Withdrawn::No
318                    },
319                },
320            },
321        )
322    }
323}
324
325impl DomainType for Outcome<()> {
326    type Proto = pb::ProposalOutcome;
327}
328
329impl From<Outcome<()>> for pb::ProposalOutcome {
330    fn from(o: Outcome<()>) -> Self {
331        let outcome = match o {
332            Outcome::Passed => {
333                pb::proposal_outcome::Outcome::Passed(pb::proposal_outcome::Passed {})
334            }
335            Outcome::Failed { withdrawn } => {
336                pb::proposal_outcome::Outcome::Failed(pb::proposal_outcome::Failed {
337                    withdrawn: <Option<()>>::from(withdrawn).map(|()| {
338                        pb::proposal_outcome::Withdrawn {
339                            reason: "".to_string(),
340                        }
341                    }),
342                })
343            }
344            Outcome::Slashed { withdrawn } => {
345                pb::proposal_outcome::Outcome::Slashed(pb::proposal_outcome::Slashed {
346                    withdrawn: <Option<()>>::from(withdrawn).map(|()| {
347                        pb::proposal_outcome::Withdrawn {
348                            reason: "".to_string(),
349                        }
350                    }),
351                })
352            }
353        };
354        pb::ProposalOutcome {
355            outcome: Some(outcome),
356        }
357    }
358}
359
360impl TryFrom<pb::ProposalOutcome> for Outcome<()> {
361    type Error = anyhow::Error;
362
363    fn try_from(msg: pb::ProposalOutcome) -> Result<Self, Self::Error> {
364        Ok(
365            match msg
366                .outcome
367                .ok_or_else(|| anyhow::anyhow!("missing proposal outcome"))?
368            {
369                pb::proposal_outcome::Outcome::Passed(pb::proposal_outcome::Passed {}) => {
370                    Outcome::Passed
371                }
372                pb::proposal_outcome::Outcome::Failed(pb::proposal_outcome::Failed {
373                    withdrawn,
374                }) => {
375                    // Max reason length is 1kb
376                    if withdrawn.is_some() {
377                        let reason = &withdrawn.as_ref().expect("reason is some").reason;
378                        if reason.len() > MAX_VALIDATOR_VOTE_REASON_LENGTH {
379                            anyhow::bail!("withdrawn reason must be smaller than 1kb");
380                        }
381                    }
382                    Outcome::Failed {
383                        withdrawn: <Withdrawn<String>>::from(withdrawn.map(|w| w.reason))
384                            .try_into()?,
385                    }
386                }
387                pb::proposal_outcome::Outcome::Slashed(pb::proposal_outcome::Slashed {
388                    withdrawn,
389                }) => {
390                    // Max reason length is 1kb
391                    if withdrawn.is_some() {
392                        let reason = &withdrawn.as_ref().expect("reason is some").reason;
393                        if reason.len() > MAX_VALIDATOR_VOTE_REASON_LENGTH {
394                            anyhow::bail!("withdrawn reason must be smaller than 1kb");
395                        }
396                    }
397
398                    Outcome::Slashed {
399                        withdrawn: <Withdrawn<String>>::from(withdrawn.map(|w| w.reason))
400                            .try_into()?,
401                    }
402                }
403            },
404        )
405    }
406}