tg_voting_contract/
lib.rs

1pub mod ballots;
2pub mod error;
3pub mod msg;
4#[cfg(test)]
5mod multitest;
6pub mod state;
7
8use serde::de::DeserializeOwned;
9use serde::Serialize;
10
11use ballots::ballots;
12pub use error::ContractError;
13use state::{
14    next_id, proposals, Config, Proposal, ProposalListResponse, ProposalResponse,
15    TextProposalListResponse, Votes, VotingRules, CONFIG, TEXT_PROPOSALS,
16};
17
18use cosmwasm_std::{
19    Addr, BlockInfo, CustomQuery, Deps, DepsMut, Env, MessageInfo, Order, StdResult, Storage,
20};
21use cw_storage_plus::Bound;
22use cw_utils::maybe_addr;
23use tg3::{
24    Status, Vote, VoteInfo, VoteListResponse, VoteResponse, VoterDetail, VoterListResponse,
25    VoterResponse,
26};
27use tg4::{Member, Tg4Contract};
28use tg_bindings::TgradeMsg;
29use tg_utils::Expiration;
30
31type Response = cosmwasm_std::Response<TgradeMsg>;
32
33pub fn instantiate<Q: CustomQuery>(
34    deps: DepsMut<Q>,
35    rules: VotingRules,
36    group_addr: &str,
37) -> Result<Response, ContractError> {
38    let group_contract = Tg4Contract(deps.api.addr_validate(group_addr).map_err(|_| {
39        ContractError::InvalidGroup {
40            addr: group_addr.to_owned(),
41        }
42    })?);
43
44    let cfg = Config {
45        rules,
46        group_contract,
47    };
48
49    cfg.rules.validate()?;
50    CONFIG.save(deps.storage, &cfg)?;
51
52    Ok(Response::default())
53}
54
55pub fn propose<P, Q: CustomQuery>(
56    deps: DepsMut<Q>,
57    env: Env,
58    info: MessageInfo,
59    title: String,
60    description: String,
61    proposal: P,
62) -> Result<Response, ContractError>
63where
64    P: DeserializeOwned + Serialize,
65{
66    let cfg = CONFIG.load(deps.storage)?;
67
68    // Only members of the multisig can create a proposal
69    // Additional check if points >= 1
70    let vote_power = cfg
71        .group_contract
72        .is_voting_member(&deps.querier, info.sender.as_str())?;
73
74    // calculate expiry time
75    let expires =
76        Expiration::at_timestamp(env.block.time.plus_seconds(cfg.rules.voting_period_secs()));
77
78    // create a proposal
79    let mut prop = Proposal {
80        title,
81        description,
82        created_by: info.sender.to_string(),
83        start_height: env.block.height,
84        expires,
85        proposal,
86        status: Status::Open,
87        votes: Votes::yes(vote_power),
88        rules: cfg.rules,
89        total_points: cfg.group_contract.total_points(&deps.querier)?,
90    };
91    prop.update_status(&env.block);
92    let id = next_id(deps.storage)?;
93    proposals().save(deps.storage, id, &prop)?;
94
95    // add the first yes vote from voter
96    ballots().create_ballot(deps.storage, &info.sender, id, vote_power, Vote::Yes)?;
97
98    let resp = msg::ProposalCreationResponse { proposal_id: id };
99
100    Ok(Response::new()
101        .add_attribute("action", "propose")
102        .add_attribute("sender", info.sender)
103        .add_attribute("proposal_id", id.to_string())
104        .add_attribute("status", format!("{:?}", prop.status))
105        .set_data(cosmwasm_std::to_binary(&resp)?))
106}
107
108pub fn vote<P, Q: CustomQuery>(
109    deps: DepsMut<Q>,
110    env: Env,
111    info: MessageInfo,
112    proposal_id: u64,
113    vote: Vote,
114) -> Result<Response, ContractError>
115where
116    P: Serialize + DeserializeOwned,
117{
118    // ensure proposal exists and can be voted on
119    let mut prop = proposals().load(deps.storage, proposal_id)?;
120
121    if ![Status::Open, Status::Passed, Status::Rejected].contains(&prop.status) {
122        return Err(ContractError::NotOpen {});
123    }
124
125    // if they are not expired
126    if prop.expires.is_expired(&env.block) {
127        return Err(ContractError::Expired {});
128    }
129
130    // use a snapshot of "start of proposal"
131    // Must be a member of voting group and have voting power >= 1
132    let cfg = CONFIG.load(deps.storage)?;
133    let vote_power =
134        cfg.group_contract
135            .was_voting_member(&deps.querier, &info.sender, prop.start_height)?;
136
137    // cast vote if no vote previously cast
138    ballots().create_ballot(deps.storage, &info.sender, proposal_id, vote_power, vote)?;
139
140    // update vote tally
141    prop.votes.add_vote(vote, vote_power);
142    prop.update_status(&env.block);
143    proposals::<P>().save(deps.storage, proposal_id, &prop)?;
144
145    Ok(Response::new()
146        .add_attribute("action", "vote")
147        .add_attribute("sender", info.sender)
148        .add_attribute("proposal_id", proposal_id.to_string())
149        .add_attribute("status", format!("{:?}", prop.status)))
150}
151
152/// Checks if a given proposal is passed and can then be executed, and returns it.
153/// Notice that this call is mutable, so, better execute the returned proposal after this succeeds,
154/// as you you wouldn't be able to execute it in the future (If the contract call errors, this status
155/// change will be reverted / ignored).
156pub fn mark_executed<P>(
157    storage: &mut dyn Storage,
158    env: Env,
159    proposal_id: u64,
160) -> Result<Proposal<P>, ContractError>
161where
162    P: Serialize + DeserializeOwned,
163{
164    let mut proposal = proposals::<P>().load(storage, proposal_id)?;
165    // Update Status
166    proposal.update_status(&env.block);
167    // We allow execution even after the proposal "expiration" as long as all votes come in before
168    // that point. If it was approved on time, it can be executed any time.
169    if proposal.current_status(&env.block) != Status::Passed {
170        return Err(ContractError::WrongExecuteStatus {});
171    }
172
173    // Set it to executed
174    proposal.status = Status::Executed;
175    proposals::<P>().save(storage, proposal_id, &proposal)?;
176    Ok(proposal)
177}
178
179pub fn execute_text<P, Q: CustomQuery>(
180    deps: DepsMut<Q>,
181    id: u64,
182    proposal: Proposal<P>,
183) -> Result<(), ContractError>
184where
185    P: Serialize + DeserializeOwned,
186{
187    TEXT_PROPOSALS.save(deps.storage, id, &proposal.into())?;
188
189    Ok(())
190}
191
192pub fn close<P, Q: CustomQuery>(
193    deps: DepsMut<Q>,
194    env: Env,
195    info: MessageInfo,
196    proposal_id: u64,
197) -> Result<Response, ContractError>
198where
199    P: Serialize + DeserializeOwned,
200{
201    // anyone can trigger this if the vote passed
202
203    let mut prop = proposals().load(deps.storage, proposal_id)?;
204
205    if prop.status == Status::Rejected {
206        return Err(ContractError::NotOpen {});
207    }
208
209    prop.update_status(&env.block);
210
211    if [Status::Executed, Status::Passed]
212        .iter()
213        .any(|x| *x == prop.status)
214    {
215        return Err(ContractError::WrongCloseStatus {});
216    }
217    if !prop.expires.is_expired(&env.block) {
218        return Err(ContractError::NotExpired {});
219    }
220
221    prop.status = Status::Rejected;
222    proposals::<P>().save(deps.storage, proposal_id, &prop)?;
223
224    Ok(Response::new()
225        .add_attribute("action", "close")
226        .add_attribute("sender", info.sender)
227        .add_attribute("proposal_id", proposal_id.to_string()))
228}
229
230pub fn query_rules<Q: CustomQuery>(deps: Deps<Q>) -> StdResult<VotingRules> {
231    let cfg = CONFIG.load(deps.storage)?;
232    Ok(cfg.rules)
233}
234
235pub fn query_proposal<P, Q: CustomQuery>(
236    deps: Deps<Q>,
237    env: Env,
238    id: u64,
239) -> StdResult<ProposalResponse<P>>
240where
241    P: Serialize + DeserializeOwned,
242{
243    let prop = proposals().load(deps.storage, id)?;
244    let status = prop.current_status(&env.block);
245    let rules = prop.rules;
246    Ok(ProposalResponse {
247        id,
248        title: prop.title,
249        description: prop.description,
250        proposal: prop.proposal,
251        created_by: prop.created_by,
252        status,
253        expires: prop.expires,
254        rules,
255        total_points: prop.total_points,
256        votes: prop.votes,
257    })
258}
259
260fn map_proposal<P>(
261    block: &BlockInfo,
262    item: StdResult<(u64, Proposal<P>)>,
263) -> StdResult<ProposalResponse<P>> {
264    let (id, prop) = item?;
265    let status = prop.current_status(block);
266    Ok(ProposalResponse {
267        id,
268        title: prop.title,
269        description: prop.description,
270        proposal: prop.proposal,
271        created_by: prop.created_by,
272        status,
273        expires: prop.expires,
274        rules: prop.rules,
275        total_points: prop.total_points,
276        votes: prop.votes,
277    })
278}
279
280pub fn list_proposals<P, Q: CustomQuery>(
281    deps: Deps<Q>,
282    env: Env,
283    start_after: Option<u64>,
284    limit: usize,
285) -> StdResult<ProposalListResponse<P>>
286where
287    P: Serialize + DeserializeOwned,
288{
289    let start = start_after.map(Bound::exclusive);
290    let props: StdResult<Vec<_>> = proposals()
291        .range(deps.storage, start, None, Order::Ascending)
292        .take(limit)
293        .map(|p| map_proposal(&env.block, p))
294        .collect();
295
296    Ok(ProposalListResponse { proposals: props? })
297}
298
299pub fn list_text_proposals<Q: CustomQuery>(
300    deps: Deps<Q>,
301    start_after: Option<u64>,
302    limit: usize,
303) -> StdResult<TextProposalListResponse> {
304    let start = start_after.map(Bound::exclusive);
305    let props: StdResult<Vec<_>> = TEXT_PROPOSALS
306        .range(deps.storage, start, None, Order::Ascending)
307        .take(limit)
308        .map(|r| r.map(|(_, p)| p))
309        .collect();
310
311    Ok(TextProposalListResponse { proposals: props? })
312}
313
314pub fn reverse_proposals<P, Q: CustomQuery>(
315    deps: Deps<Q>,
316    env: Env,
317    start_before: Option<u64>,
318    limit: usize,
319) -> StdResult<ProposalListResponse<P>>
320where
321    P: Serialize + DeserializeOwned,
322{
323    let end = start_before.map(Bound::exclusive);
324    let props: StdResult<Vec<_>> = proposals()
325        .range(deps.storage, None, end, Order::Descending)
326        .take(limit)
327        .map(|p| map_proposal(&env.block, p))
328        .collect();
329
330    Ok(ProposalListResponse { proposals: props? })
331}
332
333pub fn query_vote<Q: CustomQuery>(
334    deps: Deps<Q>,
335    proposal_id: u64,
336    voter: String,
337) -> StdResult<VoteResponse> {
338    let voter_addr = deps.api.addr_validate(&voter)?;
339    let prop = ballots()
340        .ballots
341        .may_load(deps.storage, (proposal_id, &voter_addr))?;
342    let vote = prop.map(|b| VoteInfo {
343        proposal_id,
344        voter,
345        vote: b.vote,
346        points: b.points,
347    });
348    Ok(VoteResponse { vote })
349}
350
351pub fn list_votes<Q: CustomQuery>(
352    deps: Deps<Q>,
353    proposal_id: u64,
354    start_after: Option<String>,
355    limit: usize,
356) -> StdResult<VoteListResponse> {
357    let addr = maybe_addr(deps.api, start_after)?;
358    let start = addr.as_ref().map(Bound::exclusive);
359
360    let votes: StdResult<Vec<_>> = ballots()
361        .ballots
362        .prefix(proposal_id)
363        .range(deps.storage, start, None, Order::Ascending)
364        .take(limit)
365        .map(|item| {
366            let (voter, ballot) = item?;
367            Ok(VoteInfo {
368                proposal_id,
369                voter: voter.into(),
370                vote: ballot.vote,
371                points: ballot.points,
372            })
373        })
374        .collect();
375
376    Ok(VoteListResponse { votes: votes? })
377}
378
379pub fn list_votes_by_voter<Q: CustomQuery>(
380    deps: Deps<Q>,
381    voter: String,
382    start_after: Option<u64>,
383    limit: usize,
384) -> StdResult<VoteListResponse> {
385    let voter_addr = deps.api.addr_validate(&voter)?;
386    // PrimaryKey of that IndexMap is (proposal_id, voter_address) -> (u64, Addr)
387    let start = start_after.map(|m| Bound::exclusive((m, voter_addr.clone())));
388
389    let votes: StdResult<Vec<_>> = ballots()
390        .ballots
391        .idx
392        .voter
393        .prefix(voter_addr)
394        .range(deps.storage, start, None, Order::Ascending)
395        .take(limit)
396        .map(|item| {
397            let ((proposal_id, _), ballot) = item?;
398            Ok(VoteInfo {
399                proposal_id,
400                voter: ballot.voter.into(),
401                vote: ballot.vote,
402                points: ballot.points,
403            })
404        })
405        .collect();
406
407    Ok(VoteListResponse { votes: votes? })
408}
409
410pub fn query_voter<Q: CustomQuery>(deps: Deps<Q>, voter: String) -> StdResult<VoterResponse> {
411    let cfg = CONFIG.load(deps.storage)?;
412    let voter_addr = deps.api.addr_validate(&voter)?;
413    let points = cfg.group_contract.is_member(&deps.querier, &voter_addr)?;
414
415    Ok(VoterResponse { points })
416}
417
418pub fn list_voters<Q: CustomQuery>(
419    deps: Deps<Q>,
420    start_after: Option<String>,
421    limit: Option<u32>,
422) -> StdResult<VoterListResponse> {
423    let cfg = CONFIG.load(deps.storage)?;
424    let voters = cfg
425        .group_contract
426        .list_members(&deps.querier, start_after, limit)?
427        .into_iter()
428        .map(|Member { addr, points, .. }| VoterDetail { addr, points })
429        .collect();
430    Ok(VoterListResponse { voters })
431}
432
433pub fn query_group_contract<Q: CustomQuery>(deps: Deps<Q>) -> StdResult<Addr> {
434    let cfg = CONFIG.load(deps.storage)?;
435    Ok(cfg.group_contract.addr())
436}