cw3/
proposal.rs

1use cosmwasm_schema::cw_serde;
2use cosmwasm_std::{Addr, BlockInfo, CosmosMsg, Decimal, Empty, Uint128};
3use cw_utils::{Expiration, Threshold};
4
5use crate::{DepositInfo, Status, Vote};
6
7// we multiply by this when calculating needed_votes in order to round up properly
8// Note: `10u128.pow(9)` fails as "u128::pow` is not yet stable as a const fn"
9const PRECISION_FACTOR: u128 = 1_000_000_000;
10
11#[cw_serde]
12pub struct Proposal {
13    pub title: String,
14    pub description: String,
15    pub start_height: u64,
16    pub expires: Expiration,
17    pub msgs: Vec<CosmosMsg<Empty>>,
18    pub status: Status,
19    /// pass requirements
20    pub threshold: Threshold,
21    // the total weight when the proposal started (used to calculate percentages)
22    pub total_weight: u64,
23    // summary of existing votes
24    pub votes: Votes,
25    /// The address that created the proposal.
26    pub proposer: Addr,
27    /// The deposit that was paid along with this proposal. This may
28    /// be refunded upon proposal completion.
29    pub deposit: Option<DepositInfo>,
30}
31
32impl Proposal {
33    /// current_status is non-mutable and returns what the status should be.
34    /// (designed for queries)
35    pub fn current_status(&self, block: &BlockInfo) -> Status {
36        let mut status = self.status;
37
38        // if open, check if voting is passed or timed out
39        if status == Status::Open && self.is_passed(block) {
40            status = Status::Passed;
41        }
42        if status == Status::Open && (self.is_rejected(block) || self.expires.is_expired(block)) {
43            status = Status::Rejected;
44        }
45
46        status
47    }
48
49    /// update_status sets the status of the proposal to current_status.
50    /// (designed for handler logic)
51    pub fn update_status(&mut self, block: &BlockInfo) {
52        self.status = self.current_status(block);
53    }
54
55    /// Returns true if this proposal is sure to pass (even before expiration, if no future
56    /// sequence of possible votes could cause it to fail).
57    pub fn is_passed(&self, block: &BlockInfo) -> bool {
58        match self.threshold {
59            Threshold::AbsoluteCount {
60                weight: weight_needed,
61            } => self.votes.yes >= weight_needed,
62            Threshold::AbsolutePercentage {
63                percentage: percentage_needed,
64            } => {
65                self.votes.yes
66                    >= votes_needed(self.total_weight - self.votes.abstain, percentage_needed)
67            }
68            Threshold::ThresholdQuorum { threshold, quorum } => {
69                // we always require the quorum
70                if self.votes.total() < votes_needed(self.total_weight, quorum) {
71                    return false;
72                }
73                if self.expires.is_expired(block) {
74                    // If expired, we compare vote_count against the total number of votes (minus abstain).
75                    let opinions = self.votes.total() - self.votes.abstain;
76                    self.votes.yes >= votes_needed(opinions, threshold)
77                } else {
78                    // If not expired, we must assume all non-votes will be cast against
79                    let possible_opinions = self.total_weight - self.votes.abstain;
80                    self.votes.yes >= votes_needed(possible_opinions, threshold)
81                }
82            }
83        }
84    }
85
86    /// Returns true if this proposal is sure to be rejected (even before expiration, if
87    /// no future sequence of possible votes could cause it to pass).
88    pub fn is_rejected(&self, block: &BlockInfo) -> bool {
89        match self.threshold {
90            Threshold::AbsoluteCount {
91                weight: weight_needed,
92            } => {
93                let weight = self.total_weight - weight_needed;
94                self.votes.no > weight
95            }
96            Threshold::AbsolutePercentage {
97                percentage: percentage_needed,
98            } => {
99                self.votes.no
100                    > votes_needed(
101                        self.total_weight - self.votes.abstain,
102                        Decimal::one() - percentage_needed,
103                    )
104            }
105            Threshold::ThresholdQuorum {
106                threshold,
107                quorum: _,
108            } => {
109                if self.expires.is_expired(block) {
110                    // If expired, we compare vote_count against the total number of votes (minus abstain).
111                    let opinions = self.votes.total() - self.votes.abstain;
112                    self.votes.no > votes_needed(opinions, Decimal::one() - threshold)
113                } else {
114                    // If not expired, we must assume all non-votes will be cast for
115                    let possible_opinions = self.total_weight - self.votes.abstain;
116                    self.votes.no > votes_needed(possible_opinions, Decimal::one() - threshold)
117                }
118            }
119        }
120    }
121}
122
123// weight of votes for each option
124#[cw_serde]
125pub struct Votes {
126    pub yes: u64,
127    pub no: u64,
128    pub abstain: u64,
129    pub veto: u64,
130}
131
132impl Votes {
133    /// sum of all votes
134    pub fn total(&self) -> u64 {
135        self.yes + self.no + self.abstain + self.veto
136    }
137
138    /// create it with a yes vote for this much
139    pub fn yes(init_weight: u64) -> Self {
140        Votes {
141            yes: init_weight,
142            no: 0,
143            abstain: 0,
144            veto: 0,
145        }
146    }
147
148    pub fn add_vote(&mut self, vote: Vote, weight: u64) {
149        match vote {
150            Vote::Yes => self.yes += weight,
151            Vote::Abstain => self.abstain += weight,
152            Vote::No => self.no += weight,
153            Vote::Veto => self.veto += weight,
154        }
155    }
156}
157
158// this is a helper function so Decimal works with u64 rather than Uint128
159// also, we must *round up* here, as we need 8, not 7 votes to reach 50% of 15 total
160fn votes_needed(weight: u64, percentage: Decimal) -> u64 {
161    let applied = Uint128::new(PRECISION_FACTOR * weight as u128).mul_floor(percentage);
162    // Divide by PRECISION_FACTOR, rounding up to the nearest integer
163    ((applied.u128() + PRECISION_FACTOR - 1) / PRECISION_FACTOR) as u64
164}
165
166// we cast a ballot with our chosen vote and a given weight
167// stored under the key that voted
168#[cw_serde]
169pub struct Ballot {
170    pub weight: u64,
171    pub vote: Vote,
172}
173
174#[cfg(test)]
175mod test {
176    use super::*;
177    use cosmwasm_std::testing::mock_env;
178
179    #[test]
180    fn count_votes() {
181        let mut votes = Votes::yes(5);
182        votes.add_vote(Vote::No, 10);
183        votes.add_vote(Vote::Veto, 20);
184        votes.add_vote(Vote::Yes, 30);
185        votes.add_vote(Vote::Abstain, 40);
186
187        assert_eq!(votes.total(), 105);
188        assert_eq!(votes.yes, 35);
189        assert_eq!(votes.no, 10);
190        assert_eq!(votes.veto, 20);
191        assert_eq!(votes.abstain, 40);
192    }
193
194    #[test]
195    // we ensure this rounds up (as it calculates needed votes)
196    fn votes_needed_rounds_properly() {
197        // round up right below 1
198        assert_eq!(1, votes_needed(3, Decimal::permille(333)));
199        // round up right over 1
200        assert_eq!(2, votes_needed(3, Decimal::permille(334)));
201        assert_eq!(11, votes_needed(30, Decimal::permille(334)));
202
203        // exact matches don't round
204        assert_eq!(17, votes_needed(34, Decimal::percent(50)));
205        assert_eq!(12, votes_needed(48, Decimal::percent(25)));
206    }
207
208    fn setup_prop(
209        threshold: Threshold,
210        votes: Votes,
211        total_weight: u64,
212        is_expired: bool,
213    ) -> (Proposal, BlockInfo) {
214        let block = mock_env().block;
215        let expires = match is_expired {
216            true => Expiration::AtHeight(block.height - 5),
217            false => Expiration::AtHeight(block.height + 100),
218        };
219        let prop = Proposal {
220            title: "Demo".to_string(),
221            description: "Info".to_string(),
222            start_height: 100,
223            expires,
224            msgs: vec![],
225            status: Status::Open,
226            proposer: Addr::unchecked("Proposer"),
227            deposit: None,
228            threshold,
229            total_weight,
230            votes,
231        };
232
233        (prop, block)
234    }
235
236    fn check_is_passed(
237        threshold: Threshold,
238        votes: Votes,
239        total_weight: u64,
240        is_expired: bool,
241    ) -> bool {
242        let (prop, block) = setup_prop(threshold, votes, total_weight, is_expired);
243        prop.is_passed(&block)
244    }
245
246    fn check_is_rejected(
247        threshold: Threshold,
248        votes: Votes,
249        total_weight: u64,
250        is_expired: bool,
251    ) -> bool {
252        let (prop, block) = setup_prop(threshold, votes, total_weight, is_expired);
253        prop.is_rejected(&block)
254    }
255
256    #[test]
257    fn proposal_passed_absolute_count() {
258        let fixed = Threshold::AbsoluteCount { weight: 10 };
259        let mut votes = Votes::yes(7);
260        votes.add_vote(Vote::Veto, 4);
261        // same expired or not, total_weight or whatever
262        assert!(!check_is_passed(fixed.clone(), votes.clone(), 30, false));
263        assert!(!check_is_passed(fixed.clone(), votes.clone(), 30, true));
264        // a few more yes votes and we are good
265        votes.add_vote(Vote::Yes, 3);
266        assert!(check_is_passed(fixed.clone(), votes.clone(), 30, false));
267        assert!(check_is_passed(fixed, votes, 30, true));
268    }
269
270    #[test]
271    fn proposal_rejected_absolute_count() {
272        let fixed = Threshold::AbsoluteCount { weight: 10 };
273        let mut votes = Votes::yes(0);
274        votes.add_vote(Vote::Veto, 4);
275        votes.add_vote(Vote::No, 7);
276        // In order to reject the proposal we need no votes > 30 - 10, currently it is not rejected
277        assert!(!check_is_rejected(fixed.clone(), votes.clone(), 30, false));
278        assert!(!check_is_rejected(fixed.clone(), votes.clone(), 30, true));
279        // 7 + 14 = 21 > 20, we can now reject
280        votes.add_vote(Vote::No, 14);
281        assert!(check_is_rejected(fixed.clone(), votes.clone(), 30, false));
282        assert!(check_is_rejected(fixed, votes, 30, true));
283    }
284
285    #[test]
286    fn proposal_passed_absolute_percentage() {
287        let percent = Threshold::AbsolutePercentage {
288            percentage: Decimal::percent(50),
289        };
290        let mut votes = Votes::yes(7);
291        votes.add_vote(Vote::No, 4);
292        votes.add_vote(Vote::Abstain, 2);
293        // same expired or not, if yes >= ceiling(0.5 * (total - abstained))
294        // 7 of (15-2) passes
295        assert!(check_is_passed(percent.clone(), votes.clone(), 15, false));
296        assert!(check_is_passed(percent.clone(), votes.clone(), 15, true));
297        // but 7 of (17-2) fails
298        assert!(!check_is_passed(percent.clone(), votes.clone(), 17, false));
299
300        // if the total were a bit lower, this would pass
301        assert!(check_is_passed(percent.clone(), votes.clone(), 14, false));
302        assert!(check_is_passed(percent, votes, 14, true));
303    }
304
305    #[test]
306    fn proposal_rejected_absolute_percentage() {
307        let percent = Threshold::AbsolutePercentage {
308            percentage: Decimal::percent(60),
309        };
310
311        // 4 YES, 7 NO, 2 ABSTAIN
312        let mut votes = Votes::yes(4);
313        votes.add_vote(Vote::No, 7);
314        votes.add_vote(Vote::Abstain, 2);
315
316        // 15 total voting power
317        // we need no votes > 0.4 * 15, no votes > 6
318        assert!(check_is_rejected(percent.clone(), votes.clone(), 15, false));
319        assert!(check_is_rejected(percent.clone(), votes.clone(), 15, true));
320
321        // 17 total voting power
322        // we need no votes > 0.4 * 17, no votes > 6.8
323        // still rejected
324        assert!(check_is_rejected(percent.clone(), votes.clone(), 17, false));
325        assert!(check_is_rejected(percent.clone(), votes.clone(), 17, true));
326
327        // Not rejected if total weight is 20
328        // as no votes > 0.4 * 18, no votes > 8
329        assert!(!check_is_rejected(
330            percent.clone(),
331            votes.clone(),
332            20,
333            false
334        ));
335        assert!(!check_is_rejected(percent, votes.clone(), 20, true));
336    }
337
338    #[test]
339    fn proposal_passed_quorum() {
340        let quorum = Threshold::ThresholdQuorum {
341            threshold: Decimal::percent(50),
342            quorum: Decimal::percent(40),
343        };
344        // all non-yes votes are counted for quorum
345        let passing = Votes {
346            yes: 7,
347            no: 3,
348            abstain: 2,
349            veto: 1,
350        };
351        // abstain votes are not counted for threshold => yes / (yes + no + veto)
352        let passes_ignoring_abstain = Votes {
353            yes: 6,
354            no: 4,
355            abstain: 5,
356            veto: 2,
357        };
358        // fails any way you look at it
359        let failing = Votes {
360            yes: 6,
361            no: 5,
362            abstain: 2,
363            veto: 2,
364        };
365
366        // first, expired (voting period over)
367        // over quorum (40% of 30 = 12), over threshold (7/11 > 50%)
368        assert!(check_is_passed(quorum.clone(), passing.clone(), 30, true));
369        // under quorum it is not passing (40% of 33 = 13.2 > 13)
370        assert!(!check_is_passed(quorum.clone(), passing.clone(), 33, true));
371        // over quorum, threshold passes if we ignore abstain
372        // 17 total votes w/ abstain => 40% quorum of 40 total
373        // 6 yes / (6 yes + 4 no + 2 votes) => 50% threshold
374        assert!(check_is_passed(
375            quorum.clone(),
376            passes_ignoring_abstain.clone(),
377            40,
378            true
379        ));
380        // over quorum, but under threshold fails also
381        assert!(!check_is_passed(quorum.clone(), failing, 20, true));
382
383        // now, check with open voting period
384        // would pass if closed, but fail here, as remaining votes no -> fail
385        assert!(!check_is_passed(quorum.clone(), passing.clone(), 30, false));
386        assert!(!check_is_passed(
387            quorum.clone(),
388            passes_ignoring_abstain.clone(),
389            40,
390            false
391        ));
392        // if we have threshold * total_weight as yes votes this must pass
393        assert!(check_is_passed(quorum.clone(), passing.clone(), 14, false));
394        // all votes have been cast, some abstain
395        assert!(check_is_passed(
396            quorum.clone(),
397            passes_ignoring_abstain,
398            17,
399            false
400        ));
401        // 3 votes uncast, if they all vote no, we have 7 yes, 7 no+veto, 2 abstain (out of 16)
402        assert!(check_is_passed(quorum, passing, 16, false));
403    }
404
405    #[test]
406    fn proposal_rejected_quorum() {
407        let quorum = Threshold::ThresholdQuorum {
408            threshold: Decimal::percent(60),
409            quorum: Decimal::percent(40),
410        };
411        // all non-yes votes are counted for quorum
412        let rejecting = Votes {
413            yes: 3,
414            no: 7,
415            abstain: 2,
416            veto: 1,
417        };
418        // abstain votes are not counted for threshold => yes / (yes + no + veto)
419        let rejected_ignoring_abstain = Votes {
420            yes: 4,
421            no: 6,
422            abstain: 5,
423            veto: 2,
424        };
425        // fails any way you look at it
426        let failing = Votes {
427            yes: 5,
428            no: 5,
429            abstain: 2,
430            veto: 3,
431        };
432
433        // first, expired (voting period over)
434        // over quorum (40% of 30 = 12, 13 votes casted)
435        // 13 - 2 abstains = 11
436        // we need no votes > 0.4 * 11, no votes > 4.4
437        // We can reject this
438        assert!(check_is_rejected(
439            quorum.clone(),
440            rejecting.clone(),
441            30,
442            true
443        ));
444
445        // Under quorum and cannot reject as it is not expired
446        assert!(!check_is_rejected(
447            quorum.clone(),
448            rejecting.clone(),
449            50,
450            false
451        ));
452        // Can reject when expired.
453        assert!(check_is_rejected(
454            quorum.clone(),
455            rejecting.clone(),
456            50,
457            true
458        ));
459
460        // Check edgecase where quorum is not met but we can reject
461        // 35% vote no
462        let quorum_edgecase = Threshold::ThresholdQuorum {
463            threshold: Decimal::percent(67),
464            quorum: Decimal::percent(40),
465        };
466        assert!(check_is_rejected(
467            quorum_edgecase,
468            Votes {
469                yes: 15,
470                no: 35,
471                abstain: 0,
472                veto: 10
473            },
474            100,
475            true
476        ));
477
478        // over quorum, threshold passes if we ignore abstain
479        // 17 total votes > 40% quorum
480        // 6 no > 0.4 * (6 no + 4 yes + 2 votes)
481        // 6 > 4.8
482        // we can reject
483        assert!(check_is_rejected(
484            quorum.clone(),
485            rejected_ignoring_abstain.clone(),
486            40,
487            true
488        ));
489
490        // over quorum
491        // total opinions due to abstains: 13
492        // no votes > 0.4 * 13, no votes > 5 to reject, we have 5 exactly so cannot reject
493        assert!(!check_is_rejected(quorum.clone(), failing, 20, true));
494
495        // voting period on going
496        // over quorum (40% of 14 = 5, 13 votes casted)
497        // 13 - 2 abstains = 11
498        // we need no votes > 0.4 * 11, no votes > 4.4
499        // We can reject this even when it hasn't expired
500        assert!(check_is_rejected(
501            quorum.clone(),
502            rejecting.clone(),
503            14,
504            false
505        ));
506        // all votes have been cast, some abstain
507        // voting period on going
508        // over quorum (40% of 17 = 7, 17 casted_
509        // 17 - 5 = 12 total opinions
510        // we need no votes > 0.4 * 12, no votes > 4.8
511        // We can reject this even when it hasn't expired
512        assert!(check_is_rejected(
513            quorum.clone(),
514            rejected_ignoring_abstain,
515            17,
516            false
517        ));
518
519        // 3 votes uncast, if they all vote yes, we have 7 no, 7 yes+veto, 2 abstain (out of 16)
520        assert!(check_is_rejected(quorum, rejecting, 16, false));
521    }
522
523    #[test]
524    fn quorum_edge_cases() {
525        // when we pass absolute threshold (everyone else voting no, we pass), but still don't hit quorum
526        let quorum = Threshold::ThresholdQuorum {
527            threshold: Decimal::percent(60),
528            quorum: Decimal::percent(80),
529        };
530
531        // try 9 yes, 1 no (out of 15) -> 90% voter threshold, 60% absolute threshold, still no quorum
532        // doesn't matter if expired or not
533        let missing_voters = Votes {
534            yes: 9,
535            no: 1,
536            abstain: 0,
537            veto: 0,
538        };
539        assert!(!check_is_passed(
540            quorum.clone(),
541            missing_voters.clone(),
542            15,
543            false
544        ));
545        assert!(!check_is_passed(quorum.clone(), missing_voters, 15, true));
546
547        // 1 less yes, 3 vetos and this passes only when expired
548        let wait_til_expired = Votes {
549            yes: 8,
550            no: 1,
551            abstain: 0,
552            veto: 3,
553        };
554        assert!(!check_is_passed(
555            quorum.clone(),
556            wait_til_expired.clone(),
557            15,
558            false
559        ));
560        assert!(check_is_passed(quorum.clone(), wait_til_expired, 15, true));
561
562        // 9 yes and 3 nos passes early
563        let passes_early = Votes {
564            yes: 9,
565            no: 3,
566            abstain: 0,
567            veto: 0,
568        };
569        assert!(check_is_passed(
570            quorum.clone(),
571            passes_early.clone(),
572            15,
573            false
574        ));
575        assert!(check_is_passed(quorum, passes_early, 15, true));
576    }
577}