Skip to main content

tg_voting_contract/
state.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use cosmwasm_std::{BlockInfo, Decimal, StdResult, Storage, Uint128};
5use cw_storage_plus::{Item, Map};
6use tg3::{Status, Vote};
7use tg4::Tg4Contract;
8use tg_utils::Expiration;
9
10use crate::ContractError;
11
12// we multiply by this when calculating needed_votes in order to round up properly
13// Note: `10u128.pow(9)` fails as "u128::pow` is not yet stable as a const fn"
14const PRECISION_FACTOR: u128 = 1_000_000_000;
15
16/// Contract configuration. Custom config is added to avoid double-fetching config on execution.
17#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
18pub struct Config {
19    pub rules: VotingRules,
20    // Total points and voters are queried from this contract
21    pub group_contract: Tg4Contract,
22}
23
24#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
25pub struct Proposal<P> {
26    pub title: String,
27    pub description: String,
28    pub start_height: u64,
29    pub created_by: String,
30    pub expires: Expiration,
31    pub proposal: P,
32    pub status: Status,
33    /// pass requirements
34    pub rules: VotingRules,
35    // the total number of points when the proposal started (used to calculate percentages)
36    pub total_points: u64,
37    // summary of existing votes
38    pub votes: Votes,
39}
40
41impl<P> From<Proposal<P>> for ProposalInfo {
42    fn from(p: Proposal<P>) -> Self {
43        Self {
44            title: p.title,
45            description: p.description,
46        }
47    }
48}
49
50impl<P> Proposal<P> {
51    /// current_status is non-mutable and returns what the status should be.
52    /// (designed for queries)
53    pub fn current_status(&self, block: &BlockInfo) -> Status {
54        let mut status = self.status;
55
56        // if open, check if voting is passed or timed out
57        if status == Status::Open && self.is_passed(block) {
58            status = Status::Passed;
59        }
60        if status == Status::Open && self.expires.is_expired(block) {
61            status = Status::Rejected;
62        }
63
64        status
65    }
66
67    /// update_status sets the status of the proposal to current_status.
68    /// (designed for handler logic)
69    pub fn update_status(&mut self, block: &BlockInfo) {
70        self.status = self.current_status(block);
71    }
72
73    // returns true iff this proposal is sure to pass (even before expiration if no future
74    // sequence of possible votes can cause it to fail)
75    pub fn is_passed(&self, block: &BlockInfo) -> bool {
76        let VotingRules {
77            quorum,
78            threshold,
79            allow_end_early,
80            ..
81        } = self.rules;
82
83        // we always require the quorum
84        if self.votes.total() < votes_needed(self.total_points, quorum) {
85            return false;
86        }
87        if self.expires.is_expired(block) {
88            // If expired, we compare Yes votes against the total number of votes (minus abstain).
89            let opinions = self.votes.total() - self.votes.abstain;
90            self.votes.yes >= votes_needed(opinions, threshold)
91        } else if allow_end_early {
92            // If not expired, we must assume all non-votes will be cast as No.
93            // We compare threshold against the total points (minus abstain).
94            let possible_opinions = self.total_points - self.votes.abstain;
95            self.votes.yes >= votes_needed(possible_opinions, threshold)
96        } else {
97            false
98        }
99    }
100}
101
102/// Note, if you are storing custom messages in the proposal,
103/// the querier needs to know what possible custom message types
104/// those are in order to parse the response
105#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
106pub struct ProposalResponse<P> {
107    pub id: u64,
108    pub title: String,
109    pub description: String,
110    pub created_by: String,
111    pub proposal: P,
112    pub status: Status,
113    pub expires: Expiration,
114    pub rules: VotingRules,
115    pub total_points: u64,
116    pub votes: Votes,
117}
118
119#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
120pub struct ProposalListResponse<P> {
121    pub proposals: Vec<ProposalResponse<P>>,
122}
123
124#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
125pub struct TextProposalListResponse {
126    pub proposals: Vec<ProposalInfo>,
127}
128
129#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, JsonSchema)]
130pub struct VotingRules {
131    /// Length of voting period in days.
132    pub voting_period: u32,
133    /// quorum requirement (0.0-1.0]
134    pub quorum: Decimal,
135    /// threshold requirement [0.5-1.0]
136    pub threshold: Decimal,
137    /// If true, and absolute threshold and quorum are met, we can end before voting period finished
138    pub allow_end_early: bool,
139}
140
141impl VotingRules {
142    pub fn validate(&self) -> Result<(), ContractError> {
143        let zero = Decimal::percent(0);
144        let hundred = Decimal::percent(100);
145
146        if self.quorum == zero || self.quorum > hundred {
147            return Err(ContractError::InvalidQuorum(self.quorum));
148        }
149
150        if self.threshold < Decimal::percent(50) || self.threshold > hundred {
151            return Err(ContractError::InvalidThreshold(self.threshold));
152        }
153
154        if self.voting_period == 0 || self.voting_period > 365 {
155            return Err(ContractError::InvalidVotingPeriod(self.voting_period));
156        }
157        Ok(())
158    }
159
160    pub fn voting_period_secs(&self) -> u64 {
161        self.voting_period as u64 * 86_400
162    }
163}
164
165pub struct RulesBuilder {
166    voting_period: u32,
167    quorum: Decimal,
168    threshold: Decimal,
169    allow_end_early: bool,
170}
171
172impl RulesBuilder {
173    pub fn new() -> Self {
174        Self {
175            voting_period: 14,
176            quorum: Decimal::percent(20),
177            threshold: Decimal::percent(50),
178            allow_end_early: true,
179        }
180    }
181
182    pub fn with_threshold(mut self, threshold: impl Into<Decimal>) -> Self {
183        self.threshold = threshold.into();
184        self
185    }
186
187    pub fn with_quorum(mut self, quorum: impl Into<Decimal>) -> Self {
188        self.quorum = quorum.into();
189        self
190    }
191
192    pub fn with_allow_early(mut self, allow_end_early: bool) -> Self {
193        self.allow_end_early = allow_end_early;
194        self
195    }
196
197    pub fn build(&self) -> VotingRules {
198        VotingRules {
199            voting_period: self.voting_period,
200            quorum: self.quorum,
201            threshold: self.threshold,
202            allow_end_early: self.allow_end_early,
203        }
204    }
205}
206
207impl Default for RulesBuilder {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213// points of votes for each option
214#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
215pub struct Votes {
216    pub yes: u64,
217    pub no: u64,
218    pub abstain: u64,
219    pub veto: u64,
220}
221
222impl Votes {
223    /// sum of all votes
224    pub fn total(&self) -> u64 {
225        self.yes + self.no + self.abstain + self.veto
226    }
227
228    /// create it with a yes vote for this much
229    pub fn yes(init_points: u64) -> Self {
230        Votes {
231            yes: init_points,
232            no: 0,
233            abstain: 0,
234            veto: 0,
235        }
236    }
237
238    pub fn add_vote(&mut self, vote: Vote, points: u64) {
239        match vote {
240            Vote::Yes => self.yes += points,
241            Vote::Abstain => self.abstain += points,
242            Vote::No => self.no += points,
243            Vote::Veto => self.veto += points,
244        }
245    }
246}
247
248// this is a helper function so Decimal works with u64 rather than Uint128
249// also, we must *round up* here, as we need 8, not 7 votes to reach 50% of 15 total
250fn votes_needed(points: u64, percentage: Decimal) -> u64 {
251    let applied = percentage * Uint128::new(PRECISION_FACTOR * points as u128);
252    // Divide by PRECISION_FACTOR, rounding up to the nearest integer
253    ((applied.u128() + PRECISION_FACTOR - 1) / PRECISION_FACTOR) as u64
254}
255
256// unique items
257pub const CONFIG: Item<Config> = Item::new("voting_config");
258pub const PROPOSAL_COUNT: Item<u64> = Item::new("proposal_count");
259
260pub fn proposals<'m, P>() -> Map<'m, u64, Proposal<P>> {
261    Map::new("proposals")
262}
263
264#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
265pub struct ProposalInfo {
266    pub title: String,
267    pub description: String,
268}
269
270pub const TEXT_PROPOSALS: Map<u64, ProposalInfo> = Map::new("text_proposals");
271
272pub fn next_id(store: &mut dyn Storage) -> StdResult<u64> {
273    let id: u64 = PROPOSAL_COUNT.may_load(store)?.unwrap_or_default() + 1;
274    PROPOSAL_COUNT.save(store, &id)?;
275    Ok(id)
276}