astro_assembly/
contract.rs

1use std::str::FromStr;
2
3use astroport::asset::addr_opt_validate;
4use astroport::staking;
5#[cfg(not(feature = "library"))]
6use cosmwasm_std::entry_point;
7use cosmwasm_std::{
8    attr, coins, ensure, wasm_execute, Addr, Api, BankMsg, CosmosMsg, Decimal, DepsMut, Env,
9    MessageInfo, QuerierWrapper, Response, StdError, Storage, SubMsg, Uint128, Uint64, WasmMsg,
10};
11use cw2::set_contract_version;
12use cw_utils::must_pay;
13use ibc_controller_package::ExecuteMsg as ControllerExecuteMsg;
14
15use astroport_governance::assembly::{
16    validate_links, Config, ExecuteMsg, InstantiateMsg, Proposal, ProposalStatus,
17    ProposalVoteOption, UpdateConfig,
18};
19use astroport_governance::emissions_controller::hub::HubMsg;
20use astroport_governance::utils::check_contract_supports_channel;
21use astroport_governance::{emissions_controller, voting_escrow};
22
23use crate::error::ContractError;
24use crate::state::{CONFIG, PROPOSALS, PROPOSAL_COUNT, PROPOSAL_VOTERS};
25use crate::utils::{calc_total_voting_power_at, calc_voting_power};
26
27// Contract name and version used for migration.
28pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME");
29pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
30
31/// Creates a new contract with the specified parameters in the `msg` variable.
32#[cfg_attr(not(feature = "library"), entry_point)]
33pub fn instantiate(
34    deps: DepsMut,
35    _env: Env,
36    _info: MessageInfo,
37    msg: InstantiateMsg,
38) -> Result<Response, ContractError> {
39    set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
40
41    if msg.whitelisted_links.is_empty() {
42        return Err(ContractError::WhitelistEmpty {});
43    }
44
45    validate_links(&msg.whitelisted_links)?;
46
47    let staking_config = deps
48        .querier
49        .query_wasm_smart::<staking::Config>(&msg.staking_addr, &staking::QueryMsg::Config {})?;
50
51    let tracker_config = deps.querier.query_wasm_smart::<staking::TrackerData>(
52        &msg.staking_addr,
53        &staking::QueryMsg::TrackerConfig {},
54    )?;
55
56    let config = Config {
57        xastro_denom: staking_config.xastro_denom,
58        xastro_denom_tracking: tracker_config.tracker_addr,
59        vxastro_contract: None,
60        emissions_controller: None,
61        ibc_controller: addr_opt_validate(deps.api, &msg.ibc_controller)?,
62        builder_unlock_addr: deps.api.addr_validate(&msg.builder_unlock_addr)?,
63        proposal_voting_period: msg.proposal_voting_period,
64        proposal_effective_delay: msg.proposal_effective_delay,
65        proposal_expiration_period: msg.proposal_expiration_period,
66        proposal_required_deposit: msg.proposal_required_deposit,
67        proposal_required_quorum: Decimal::from_str(&msg.proposal_required_quorum)?,
68        proposal_required_threshold: Decimal::from_str(&msg.proposal_required_threshold)?,
69        whitelisted_links: msg.whitelisted_links,
70    };
71
72    #[cfg(not(feature = "testnet"))]
73    config.validate()?;
74
75    CONFIG.save(deps.storage, &config)?;
76
77    PROPOSAL_COUNT.save(deps.storage, &Uint64::zero())?;
78
79    Ok(Response::default())
80}
81
82/// Exposes all the execute functions available in the contract.
83///
84/// ## Execute messages
85/// * **ExecuteMsg::SubmitProposal { title, description, link, messages, ibc_channel }** Submits a new proposal.
86///
87/// * **ExecuteMsg::CheckMessages { messages }** Checks if the messages are correct.
88/// Executes arbitrary messages on behalf of the Assembly contract. Always appends failing message to the end of the list.
89///
90/// * **ExecuteMsg::CheckMessagesPassed {}** Closing message for the `CheckMessages` endpoint.
91///
92/// * **ExecuteMsg::CastVote { proposal_id, vote }** Cast a vote on a specific proposal.
93///
94/// * **ExecuteMsg::CastVoteOutpost { voter, voting_power, proposal_id, vote }** Applies a vote on a specific proposal from outpost.
95/// Only emissions controller is allowed to call this endpoint.
96///
97/// * **ExecuteMsg::EndProposal { proposal_id }** Sets the status of an expired/finalized proposal.
98///
99/// * **ExecuteMsg::ExecuteProposal { proposal_id }** Executes a successful proposal.
100///
101/// * **ExecuteMsg::UpdateConfig(config)** Updates the contract configuration.
102///
103/// * **ExecuteMsg::IBCProposalCompleted { proposal_id, status }** Updates proposal status InProgress -> Executed or Failed.
104/// This endpoint processes callbacks from the ibc controller.
105#[cfg_attr(not(feature = "library"), entry_point)]
106pub fn execute(
107    deps: DepsMut,
108    env: Env,
109    info: MessageInfo,
110    msg: ExecuteMsg,
111) -> Result<Response, ContractError> {
112    match msg {
113        ExecuteMsg::SubmitProposal {
114            title,
115            description,
116            link,
117            messages,
118            ibc_channel,
119        } => submit_proposal(
120            deps,
121            env,
122            info,
123            title,
124            description,
125            link,
126            messages,
127            ibc_channel,
128        ),
129        ExecuteMsg::CastVote { proposal_id, vote } => {
130            let voter = info.sender.to_string();
131            let proposal = PROPOSALS.load(deps.storage, proposal_id)?;
132
133            let voting_power = calc_voting_power(deps.as_ref(), voter.clone(), &proposal)?;
134            ensure!(!voting_power.is_zero(), ContractError::NoVotingPower {});
135
136            cast_vote(
137                deps.storage,
138                env,
139                voter,
140                voting_power,
141                proposal_id,
142                proposal,
143                vote,
144            )
145        }
146        ExecuteMsg::CastVoteOutpost {
147            voter,
148            voting_power,
149            proposal_id,
150            vote,
151        } => {
152            let config = CONFIG.load(deps.storage)?;
153            ensure!(
154                Some(info.sender) == config.emissions_controller,
155                ContractError::Unauthorized {}
156            );
157
158            // This endpoint should never fail if called from the emissions controller.
159            // Otherwise, an IBC packet will never be acknowledged.
160            (|| {
161                let proposal = PROPOSALS.load(deps.storage, proposal_id)?;
162
163                cast_vote(
164                    deps.storage,
165                    env,
166                    voter,
167                    voting_power,
168                    proposal_id,
169                    proposal,
170                    vote,
171                )
172            })()
173            .or_else(|err| {
174                Ok(Response::new()
175                    .add_attribute("action", "cast_vote")
176                    .add_attribute("error", err.to_string()))
177            })
178        }
179        ExecuteMsg::EndProposal { proposal_id } => end_proposal(deps, env, proposal_id),
180        ExecuteMsg::ExecuteProposal { proposal_id } => execute_proposal(deps, env, proposal_id),
181        ExecuteMsg::CheckMessages(messages) => check_messages(deps.api, env, messages),
182        ExecuteMsg::CheckMessagesPassed {} => Err(ContractError::MessagesCheckPassed {}),
183        ExecuteMsg::UpdateConfig(config) => update_config(deps, env, info, config),
184        ExecuteMsg::IBCProposalCompleted {
185            proposal_id,
186            status,
187        } => update_ibc_proposal_status(deps, info, proposal_id, status),
188        ExecuteMsg::ExecuteFromMultisig(proposal_messages) => {
189            exec_from_multisig(deps.querier, info, env, proposal_messages)
190        }
191    }
192}
193
194/// Submit a brand new proposal and lock some xASTRO as an anti-spam mechanism.
195///
196/// * **sender** proposal submitter.
197///
198/// * **deposit_amount**  amount of xASTRO to deposit in order to submit the proposal.
199///
200/// * **title** proposal title.
201///
202/// * **description** proposal description.
203///
204/// * **link** proposal link.
205///
206/// * **messages** executable messages (actions to perform if the proposal passes).
207#[allow(clippy::too_many_arguments)]
208pub fn submit_proposal(
209    deps: DepsMut,
210    env: Env,
211    info: MessageInfo,
212    title: String,
213    description: String,
214    link: Option<String>,
215    messages: Vec<CosmosMsg>,
216    ibc_channel: Option<String>,
217) -> Result<Response, ContractError> {
218    let config = CONFIG.load(deps.storage)?;
219
220    // Ensure that the correct token is sent. This will fail if
221    // zero tokens are sent.
222    let deposit_amount = must_pay(&info, &config.xastro_denom)?;
223
224    if deposit_amount < config.proposal_required_deposit {
225        return Err(ContractError::InsufficientDeposit {});
226    }
227
228    // Update the proposal count
229    let count = PROPOSAL_COUNT.update::<_, StdError>(deps.storage, |c| Ok(c + Uint64::one()))?;
230
231    // Check that controller exists and it supports this channel
232    if let Some(ibc_channel) = &ibc_channel {
233        if let Some(ibc_controller) = &config.ibc_controller {
234            check_contract_supports_channel(deps.querier, ibc_controller, ibc_channel)?;
235        } else {
236            return Err(ContractError::MissingIBCController {});
237        }
238    }
239
240    let proposal = Proposal {
241        proposal_id: count,
242        submitter: info.sender.clone(),
243        status: ProposalStatus::Active,
244        for_power: Uint128::zero(),
245        against_power: Uint128::zero(),
246        start_block: env.block.height,
247        start_time: env.block.time.seconds(),
248        end_block: env.block.height + config.proposal_voting_period,
249        delayed_end_block: env.block.height
250            + config.proposal_voting_period
251            + config.proposal_effective_delay,
252        expiration_block: env.block.height
253            + config.proposal_voting_period
254            + config.proposal_effective_delay
255            + config.proposal_expiration_period,
256        title,
257        description,
258        link,
259        messages,
260        deposit_amount,
261        ibc_channel,
262        // Seal total voting power. Query the total voting power one second before the proposal starts because
263        // this is the last up to date finalized state of token factory tracker contract.
264        total_voting_power: calc_total_voting_power_at(
265            deps.querier,
266            &config,
267            env.block.time.seconds() - 1,
268        )?,
269    };
270
271    proposal.validate(config.whitelisted_links)?;
272
273    PROPOSALS.save(deps.storage, count.u64(), &proposal)?;
274
275    let mut response = Response::new().add_attributes([
276        attr("action", "submit_proposal"),
277        attr("submitter", info.sender),
278        attr("proposal_id", count),
279        attr(
280            "proposal_end_height",
281            (env.block.height + config.proposal_voting_period).to_string(),
282        ),
283    ]);
284
285    if let Some(emissions_controller) = config.emissions_controller {
286        // Send IBC packets to all outposts to register this proposal.
287        let outposts_register_msg = wasm_execute(
288            emissions_controller,
289            &emissions_controller::msg::ExecuteMsg::Custom(HubMsg::RegisterProposal {
290                proposal_id: count.u64(),
291            }),
292            vec![],
293        )?;
294        response = response.add_message(outposts_register_msg);
295    }
296
297    Ok(response)
298}
299
300/// Cast a vote on a proposal.
301///
302/// * **voter** is the bech32 address of the voter from any of the supported outposts.
303///
304/// * **voting_power** is the voting power of the voter.
305///
306/// * **proposal_id** is the identifier of the proposal.
307///
308/// * **proposal** is [`Proposal`] object.
309///
310/// * **vote_option** contains the vote option.
311pub fn cast_vote(
312    storage: &mut dyn Storage,
313    env: Env,
314    voter: String,
315    voting_power: Uint128,
316    proposal_id: u64,
317    mut proposal: Proposal,
318    vote_option: ProposalVoteOption,
319) -> Result<Response, ContractError> {
320    if proposal.status != ProposalStatus::Active {
321        return Err(ContractError::ProposalNotActive {});
322    }
323
324    if env.block.height > proposal.end_block {
325        return Err(ContractError::VotingPeriodEnded {});
326    }
327
328    if PROPOSAL_VOTERS.has(storage, (proposal_id, voter.clone())) {
329        return Err(ContractError::UserAlreadyVoted {});
330    }
331
332    match vote_option {
333        ProposalVoteOption::For => {
334            proposal.for_power = proposal.for_power.checked_add(voting_power)?;
335        }
336        ProposalVoteOption::Against => {
337            proposal.against_power = proposal.against_power.checked_add(voting_power)?;
338        }
339    };
340    PROPOSAL_VOTERS.save(storage, (proposal_id, voter.clone()), &vote_option)?;
341
342    PROPOSALS.save(storage, proposal_id, &proposal)?;
343
344    Ok(Response::new().add_attributes(vec![
345        attr("action", "cast_vote"),
346        attr("proposal_id", proposal_id.to_string()),
347        attr("voter", &voter),
348        attr("vote", vote_option.to_string()),
349        attr("voting_power", voting_power),
350    ]))
351}
352
353/// Ends proposal voting period, sets the proposal status by id and returns
354/// xASTRO submitted for the proposal.
355pub fn end_proposal(deps: DepsMut, env: Env, proposal_id: u64) -> Result<Response, ContractError> {
356    let mut proposal = PROPOSALS.load(deps.storage, proposal_id)?;
357
358    if proposal.status != ProposalStatus::Active {
359        return Err(ContractError::ProposalNotActive {});
360    }
361
362    if env.block.height <= proposal.end_block {
363        return Err(ContractError::VotingPeriodNotEnded {});
364    }
365
366    let config = CONFIG.load(deps.storage)?;
367
368    let for_votes = proposal.for_power;
369    let against_votes = proposal.against_power;
370    let total_votes = for_votes + against_votes;
371
372    let proposal_quorum =
373        Decimal::checked_from_ratio(total_votes, proposal.total_voting_power).unwrap_or_default();
374    let proposal_threshold =
375        Decimal::checked_from_ratio(for_votes, total_votes).unwrap_or_default();
376
377    // Determine the proposal result
378    proposal.status = if proposal_quorum >= config.proposal_required_quorum
379        && proposal_threshold > config.proposal_required_threshold
380    {
381        ProposalStatus::Passed
382    } else {
383        ProposalStatus::Rejected
384    };
385
386    PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
387
388    let response = Response::new()
389        .add_attributes([
390            attr("action", "end_proposal"),
391            attr("proposal_id", proposal_id.to_string()),
392            attr("proposal_result", proposal.status.to_string()),
393        ])
394        .add_message(BankMsg::Send {
395            to_address: proposal.submitter.to_string(),
396            amount: coins(proposal.deposit_amount.into(), config.xastro_denom),
397        });
398
399    Ok(response)
400}
401
402/// Executes a successful proposal by id.
403pub fn execute_proposal(
404    deps: DepsMut,
405    env: Env,
406    proposal_id: u64,
407) -> Result<Response, ContractError> {
408    let mut proposal = PROPOSALS.load(deps.storage, proposal_id)?;
409
410    if proposal.status != ProposalStatus::Passed {
411        return Err(ContractError::ProposalNotPassed {});
412    }
413
414    if env.block.height < proposal.delayed_end_block {
415        return Err(ContractError::ProposalDelayNotEnded {});
416    }
417
418    let mut response = Response::new().add_attributes([
419        attr("action", "execute_proposal"),
420        attr("proposal_id", proposal_id.to_string()),
421    ]);
422
423    if env.block.height > proposal.expiration_block {
424        proposal.status = ProposalStatus::Expired;
425    } else if let Some(channel) = &proposal.ibc_channel {
426        if !proposal.messages.is_empty() {
427            let config = CONFIG.load(deps.storage)?;
428
429            proposal.status = ProposalStatus::InProgress;
430            response.messages.push(SubMsg::new(wasm_execute(
431                config
432                    .ibc_controller
433                    .ok_or(ContractError::MissingIBCController {})?,
434                &ControllerExecuteMsg::IbcExecuteProposal {
435                    channel_id: channel.to_string(),
436                    proposal_id,
437                    messages: proposal.messages.clone(),
438                },
439                vec![],
440            )?))
441        } else {
442            proposal.status = ProposalStatus::Executed;
443        }
444    } else {
445        proposal.status = ProposalStatus::Executed;
446        response
447            .messages
448            .extend(proposal.messages.iter().cloned().map(SubMsg::new))
449    }
450
451    PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
452
453    Ok(response.add_attribute("proposal_status", proposal.status.to_string()))
454}
455
456/// Checks that proposal messages are correct.
457pub fn check_messages(
458    api: &dyn Api,
459    env: Env,
460    mut messages: Vec<CosmosMsg>,
461) -> Result<Response, ContractError> {
462    messages.iter().try_for_each(|msg| match msg {
463        CosmosMsg::Wasm(
464            WasmMsg::Migrate { contract_addr, .. } | WasmMsg::UpdateAdmin { contract_addr, .. },
465        ) if api.addr_validate(contract_addr)? == env.contract.address => {
466            Err(StdError::generic_err(
467                "Can't check messages with a migration or update admin message of the contract itself",
468            ))
469        }
470        CosmosMsg::Stargate { type_url, .. } if type_url.contains("MsgGrant") => Err(
471            StdError::generic_err("Can't check messages with a MsgGrant message"),
472        ),
473        _ => Ok(()),
474    })?;
475
476    messages.push(
477        wasm_execute(
478            env.contract.address,
479            &ExecuteMsg::CheckMessagesPassed {},
480            vec![],
481        )?
482        .into(),
483    );
484
485    Ok(Response::new()
486        .add_attribute("action", "check_messages")
487        .add_messages(messages))
488}
489
490/// Updates Assembly contract parameters.
491///
492/// * **updated_config** new contract configuration.
493pub fn update_config(
494    deps: DepsMut,
495    env: Env,
496    info: MessageInfo,
497    updated_config: Box<UpdateConfig>,
498) -> Result<Response, ContractError> {
499    let mut config = CONFIG.load(deps.storage)?;
500
501    // Only the Assembly is allowed to update its own parameters (through a successful proposal)
502    if info.sender != env.contract.address {
503        return Err(ContractError::Unauthorized {});
504    }
505
506    let mut attrs = vec![attr("action", "update_config")];
507
508    if let Some(ibc_controller) = updated_config.ibc_controller {
509        config.ibc_controller = Some(deps.api.addr_validate(&ibc_controller)?);
510        attrs.push(attr("new_ibc_controller", ibc_controller));
511    }
512
513    if let Some(builder_unlock_addr) = updated_config.builder_unlock_addr {
514        config.builder_unlock_addr = deps.api.addr_validate(&builder_unlock_addr)?;
515        attrs.push(attr("new_builder_unlock_addr", builder_unlock_addr));
516    }
517
518    if let Some(proposal_voting_period) = updated_config.proposal_voting_period {
519        config.proposal_voting_period = proposal_voting_period;
520        attrs.push(attr(
521            "new_proposal_voting_period",
522            proposal_voting_period.to_string(),
523        ));
524    }
525
526    if let Some(proposal_effective_delay) = updated_config.proposal_effective_delay {
527        config.proposal_effective_delay = proposal_effective_delay;
528        attrs.push(attr(
529            "new_proposal_effective_delay",
530            proposal_effective_delay.to_string(),
531        ));
532    }
533
534    if let Some(proposal_expiration_period) = updated_config.proposal_expiration_period {
535        config.proposal_expiration_period = proposal_expiration_period;
536        attrs.push(attr(
537            "new_proposal_expiration_period",
538            proposal_expiration_period.to_string(),
539        ));
540    }
541
542    if let Some(proposal_required_deposit) = updated_config.proposal_required_deposit {
543        config.proposal_required_deposit = proposal_required_deposit;
544        attrs.push(attr(
545            "new_proposal_required_deposit",
546            proposal_required_deposit.to_string(),
547        ));
548    }
549
550    if let Some(proposal_required_quorum) = updated_config.proposal_required_quorum {
551        config.proposal_required_quorum = proposal_required_quorum;
552        attrs.push(attr(
553            "new_proposal_required_quorum",
554            proposal_required_quorum.to_string(),
555        ));
556    }
557
558    if let Some(proposal_required_threshold) = updated_config.proposal_required_threshold {
559        config.proposal_required_threshold = proposal_required_threshold;
560        attrs.push(attr(
561            "new_proposal_required_threshold",
562            proposal_required_threshold.to_string(),
563        ));
564    }
565
566    if let Some(whitelist_add) = updated_config.whitelist_add {
567        validate_links(&whitelist_add)?;
568
569        let mut new_links = whitelist_add
570            .into_iter()
571            .filter(|link| !config.whitelisted_links.contains(link))
572            .collect::<Vec<_>>();
573
574        attrs.push(attr("new_whitelisted_links", new_links.join(", ")));
575
576        config.whitelisted_links.append(&mut new_links);
577    }
578
579    if let Some(whitelist_remove) = updated_config.whitelist_remove {
580        config
581            .whitelisted_links
582            .retain(|link| !whitelist_remove.contains(link));
583
584        attrs.push(attr(
585            "removed_whitelisted_links",
586            whitelist_remove.join(", "),
587        ));
588
589        if config.whitelisted_links.is_empty() {
590            return Err(ContractError::WhitelistEmpty {});
591        }
592    }
593
594    if let Some(vxastro) = updated_config.vxastro {
595        let emissions_controller = deps
596            .querier
597            .query_wasm_smart::<voting_escrow::Config>(
598                &vxastro,
599                &voting_escrow::QueryMsg::Config {},
600            )?
601            .emissions_controller;
602
603        config.emissions_controller = Some(Addr::unchecked(&emissions_controller));
604        config.vxastro_contract = Some(Addr::unchecked(&vxastro));
605
606        attrs.push(attr("new_emissions_controller", emissions_controller));
607        attrs.push(attr("new_vxastro_contract", vxastro));
608    }
609
610    #[cfg(not(feature = "testnet"))]
611    config.validate()?;
612
613    CONFIG.save(deps.storage, &config)?;
614
615    Ok(Response::new().add_attributes(attrs))
616}
617
618/// Updates proposal status InProgress -> Executed or Failed. Intended to be called in the end of
619/// the ibc execution cycle via ibc-controller. Only ibc controller is able to call this function.
620///
621/// * **id** proposal's id,
622///
623/// * **status** a new proposal status reported by ibc controller.
624fn update_ibc_proposal_status(
625    deps: DepsMut,
626    info: MessageInfo,
627    id: u64,
628    new_status: ProposalStatus,
629) -> Result<Response, ContractError> {
630    let config = CONFIG.load(deps.storage)?;
631    if Some(info.sender) == config.ibc_controller {
632        let mut proposal = PROPOSALS.load(deps.storage, id)?;
633
634        if proposal.status != ProposalStatus::InProgress {
635            return Err(ContractError::WrongIbcProposalStatus(
636                proposal.status.to_string(),
637            ));
638        }
639
640        match new_status {
641            ProposalStatus::Executed {} | ProposalStatus::Failed {} => {
642                proposal.status = new_status;
643                PROPOSALS.save(deps.storage, id, &proposal)?;
644                Ok(Response::new().add_attribute("action", "ibc_proposal_completed"))
645            }
646            _ => Err(ContractError::InvalidRemoteIbcProposalStatus(
647                new_status.to_string(),
648            )),
649        }
650    } else {
651        Err(ContractError::InvalidIBCController {})
652    }
653}
654
655pub fn exec_from_multisig(
656    querier: QuerierWrapper,
657    info: MessageInfo,
658    env: Env,
659    messages: Vec<CosmosMsg>,
660) -> Result<Response, ContractError> {
661    match querier
662        .query_wasm_contract_info(&env.contract.address)?
663        .admin
664    {
665        None => Err(ContractError::Unauthorized {}),
666        // Don't allow to execute this endpoint if the contract is admin of itself
667        Some(admin) if admin != info.sender || admin == env.contract.address => {
668            Err(ContractError::Unauthorized {})
669        }
670        _ => Ok(()),
671    }?;
672
673    Ok(Response::new().add_messages(messages))
674}