Skip to content

Commit

Permalink
Contract pausing implementation (#51)
Browse files Browse the repository at this point in the history
* contract pausing implementation

* Update README.md

Co-authored-by: Philip Offtermatt <[email protected]>

* CR fixes

---------

Co-authored-by: Philip Offtermatt <[email protected]>
  • Loading branch information
dusan-maksimovic and p-offtermatt authored Jul 24, 2024
1 parent c82abda commit 752545f
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 15 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,7 @@ Once the round is over, proposals are deployed using Timewave, a product which a
The Hydro forum post mentions “tribute”- funds that proposal creators can attach to proposals which is paid out to the winning proposal. This is not implemented within the main Hydro contract, but it is possible for tribute to be awarded with pluggable tribute contracts that read from the Hydro contract. These can be switched out permissionlessly and even customized or reinvented by proposal authors.

We will deploy an example default tribute contract which pays out tribute to anyone who voted for a proposal- but only if that proposal wins. This can be used as is by proposal authors, or used as a starting point for custom tribute contracts.

# Pausing/Un-pausing of the contract
In case of emergency, the Oversight committee will be able to suspend any actions on the smart contract by submitting the Pause transaction to the smart contract. Once the contract is paused, it can be un-paused through the Cosmos Hub governance by submitting the proposal to migrate the contract state (potentially to the same Code ID, in case the contract is safe to unpause without any changes to the code). Such proposal execution will trigger the `migrate()` function on the new contract code. This function will un-pause the contract and allow normal functioning of the contract to continue.
For more information on contract upgrade through the Cosmos Hub governance see [here](./docs/contract_upgrade.md).
87 changes: 74 additions & 13 deletions contracts/hydro/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use cosmwasm_std::{
use cw2::set_contract_version;

use crate::error::ContractError;
use crate::msg::{ExecuteMsg, InstantiateMsg};
use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg};
use crate::query::{QueryMsg, RoundProposalsResponse, UserLockupsResponse};
use crate::state::{
Constants, CovenantParams, LockEntry, Proposal, Tranche, Vote, CONSTANTS, LOCKED_TOKENS,
Expand Down Expand Up @@ -56,6 +56,7 @@ pub fn instantiate(
lock_epoch_length: msg.lock_epoch_length,
first_round_start: msg.first_round_start,
max_locked_tokens: msg.max_locked_tokens,
paused: false,
};

CONSTANTS.save(deps.storage, &state)?;
Expand Down Expand Up @@ -130,6 +131,7 @@ pub fn execute(
ExecuteMsg::UpdateMaxLockedTokens { max_locked_tokens } => {
update_max_locked_tokens(deps, info, max_locked_tokens)
}
ExecuteMsg::Pause {} => pause_contract(deps, info),
}
}

Expand All @@ -145,6 +147,7 @@ fn lock_tokens(
) -> Result<Response, ContractError> {
let constants = CONSTANTS.load(deps.storage)?;

validate_contract_is_not_paused(&constants)?;
validate_lock_duration(constants.lock_epoch_length, lock_duration)?;
must_pay(&info, &constants.denom)?;

Expand Down Expand Up @@ -209,6 +212,8 @@ fn refresh_lock_duration(
lock_duration: u64,
) -> Result<Response, ContractError> {
let constants = CONSTANTS.load(deps.storage)?;

validate_contract_is_not_paused(&constants)?;
validate_lock_duration(constants.lock_epoch_length, lock_duration)?;

// try to get the lock with the given id
Expand Down Expand Up @@ -291,6 +296,9 @@ fn validate_lock_duration(lock_epoch_length: u64, lock_duration: u64) -> Result<
// Send `amount` tokens back to caller
// Delete entry from LocksMap
fn unlock_tokens(deps: DepsMut, env: Env, info: MessageInfo) -> Result<Response, ContractError> {
let constants = CONSTANTS.load(deps.storage)?;

validate_contract_is_not_paused(&constants)?;
validate_previous_round_vote(&deps, &env, info.sender.clone())?;

// Iterate all locks for the caller and unlock them if lock_end < now
Expand Down Expand Up @@ -376,9 +384,12 @@ fn create_proposal(
description: String,
covenant_params: CovenantParams,
) -> Result<Response, ContractError> {
let constants = CONSTANTS.load(deps.storage)?;
validate_contract_is_not_paused(&constants)?;

// check that the tranche with the given id exists
TRANCHE_MAP.load(deps.storage, tranche_id)?;

let constants = CONSTANTS.load(deps.storage)?;
let round_id = compute_current_round_id(&env, &constants)?;
let proposal_id = PROP_ID.load(deps.storage)?;

Expand Down Expand Up @@ -443,9 +454,11 @@ fn vote(
// - To enable switching votes (and for other stuff too), we store the vote in VOTE_MAP.
// - When a user votes the second time in a round, the information about their previous vote from VOTE_MAP is used to reverse the effect of their previous vote.
// - This leads to slightly higher gas costs for each vote, in exchange for a much lower gas cost at the end of the round.
TRANCHE_MAP.load(deps.storage, tranche_id)?;

let constants = CONSTANTS.load(deps.storage)?;
validate_contract_is_not_paused(&constants)?;

// check that the tranche with the given id exists
TRANCHE_MAP.load(deps.storage, tranche_id)?;

// compute the round_id
let round_id = compute_current_round_id(&env, &constants)?;
Expand Down Expand Up @@ -597,6 +610,9 @@ fn add_to_whitelist(
info: MessageInfo,
covenant_params: CovenantParams,
) -> Result<Response, ContractError> {
let constants = CONSTANTS.load(deps.storage)?;

validate_contract_is_not_paused(&constants)?;
validate_sender_is_whitelist_admin(&deps, &info)?;

// Add covenant_params to whitelist
Expand All @@ -621,6 +637,9 @@ fn remove_from_whitelist(
info: MessageInfo,
covenant_params: CovenantParams,
) -> Result<Response, ContractError> {
let constants = CONSTANTS.load(deps.storage)?;

validate_contract_is_not_paused(&constants)?;
validate_sender_is_whitelist_admin(&deps, &info)?;

// Remove covenant_params from whitelist
Expand All @@ -636,23 +655,39 @@ fn update_max_locked_tokens(
info: MessageInfo,
max_locked_tokens: u128,
) -> Result<Response, ContractError> {
validate_sender_is_whitelist_admin(&deps, &info)?;
let mut constants = CONSTANTS.load(deps.storage)?;

CONSTANTS.update(
deps.storage,
|mut constants| -> Result<Constants, ContractError> {
constants.max_locked_tokens = max_locked_tokens;
validate_contract_is_not_paused(&constants)?;
validate_sender_is_whitelist_admin(&deps, &info)?;

Ok(constants)
},
)?;
constants.max_locked_tokens = max_locked_tokens;
CONSTANTS.save(deps.storage, &constants)?;

Ok(Response::new()
.add_attribute("action", "update_max_locked_tokens")
.add_attribute("sender", info.sender.clone())
.add_attribute("max_locked_tokens", max_locked_tokens.to_string()))
}

// Pause:
// Validate that the contract isn't already paused
// Validate sender is whitelist admin
// Set paused to true and save the changes
fn pause_contract(deps: DepsMut, info: MessageInfo) -> Result<Response, ContractError> {
let mut constants = CONSTANTS.load(deps.storage)?;

validate_contract_is_not_paused(&constants)?;
validate_sender_is_whitelist_admin(&deps, &info)?;

constants.paused = true;
CONSTANTS.save(deps.storage, &constants)?;

Ok(Response::new()
.add_attribute("action", "pause_contract")
.add_attribute("sender", info.sender.clone())
.add_attribute("paused", "true"))
}

fn validate_sender_is_whitelist_admin(
deps: &DepsMut,
info: &MessageInfo,
Expand All @@ -665,6 +700,13 @@ fn validate_sender_is_whitelist_admin(
Ok(())
}

fn validate_contract_is_not_paused(constants: &Constants) -> Result<(), ContractError> {
match constants.paused {
true => Err(ContractError::Paused),
false => Ok(()),
}
}

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
Expand Down Expand Up @@ -726,7 +768,7 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
)?),
QueryMsg::Whitelist {} => to_json_binary(&query_whitelist(deps)?),
QueryMsg::WhitelistAdmins {} => to_json_binary(&query_whitelist_admins(deps)?),
QueryMsg::TotalLockedTokens => to_json_binary(&LOCKED_TOKENS.load(deps.storage)?),
QueryMsg::TotalLockedTokens {} => to_json_binary(&LOCKED_TOKENS.load(deps.storage)?),
}
}

Expand Down Expand Up @@ -998,3 +1040,22 @@ fn get_lock_count(deps: Deps, user_address: Addr) -> usize {
.range(deps.storage, None, None, Order::Ascending)
.count()
}

/// In the first version of Hydro, we allow contract to be un-paused through the Cosmos Hub governance
/// by migrating contract to the same code ID. This will trigger the migrate() function where we set
/// the paused flag to false.
/// Keep in mind that, for the future versions, this function should check the `CONTRACT_VERSION` and
/// perform any state changes needed. It should also handle the un-pausing of the contract, depending if
/// it was previously paused or not.
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result<Response, ContractError> {
CONSTANTS.update(
deps.storage,
|mut constants| -> Result<Constants, ContractError> {
constants.paused = false;
Ok(constants)
},
)?;

Ok(Response::default())
}
3 changes: 3 additions & 0 deletions contracts/hydro/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ pub enum ContractError {

#[error("{0}")]
PaymentError(#[from] PaymentError),

#[error("Paused")]
Paused,
}
4 changes: 4 additions & 0 deletions contracts/hydro/src/msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,8 @@ pub enum ExecuteMsg {
UpdateMaxLockedTokens {
max_locked_tokens: u128,
},
Pause {},
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MigrateMsg {}
2 changes: 1 addition & 1 deletion contracts/hydro/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub enum QueryMsg {
},
Whitelist {},
WhitelistAdmins {},
TotalLockedTokens,
TotalLockedTokens {},
}

#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug, Default)]
Expand Down
1 change: 1 addition & 0 deletions contracts/hydro/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub struct Constants {
pub lock_epoch_length: u64,
pub first_round_start: Timestamp,
pub max_locked_tokens: u128,
pub paused: bool,
}

// the total number of tokens locked in the contract
Expand Down
63 changes: 63 additions & 0 deletions contracts/hydro/src/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -991,3 +991,66 @@ fn max_locked_tokens_test() {
.to_string()
.contains("The limit for locking tokens has been reached. No more tokens can be locked."));
}

#[test]
fn contract_pausing_test() {
let (mut deps, env, mut info) = (mock_dependencies(), mock_env(), mock_info("addr0000", &[]));

let whitelist_admin = "addr0001";
let mut msg = get_default_instantiate_msg();
msg.whitelist_admins = vec![whitelist_admin.to_string()];

let res = instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone());
assert!(res.is_ok());

// verify that non-privileged user can not pause the contract
let msg = ExecuteMsg::Pause {};
let res = execute(deps.as_mut(), env.clone(), info.clone(), msg.clone());
assert!(res.is_err());
assert!(res.unwrap_err().to_string().contains("Unauthorized"));

// verify that privileged user can pause the contract
info = mock_info(whitelist_admin, &[]);
let res = execute(deps.as_mut(), env.clone(), info.clone(), msg.clone());
assert!(res.is_ok());

let constants = query_constants(deps.as_ref());
assert!(constants.is_ok());
assert!(constants.unwrap().paused);

// verify that no action can be executed while the contract is paused
let msgs = vec![
ExecuteMsg::LockTokens { lock_duration: 0 },
ExecuteMsg::RefreshLockDuration {
lock_id: 0,
lock_duration: 0,
},
ExecuteMsg::UnlockTokens {},
ExecuteMsg::CreateProposal {
tranche_id: 0,
title: "".to_string(),
description: "".to_string(),
covenant_params: get_default_covenant_params(),
},
ExecuteMsg::Vote {
tranche_id: 0,
proposal_id: 0,
},
ExecuteMsg::AddToWhitelist {
covenant_params: get_default_covenant_params(),
},
ExecuteMsg::RemoveFromWhitelist {
covenant_params: get_default_covenant_params(),
},
ExecuteMsg::UpdateMaxLockedTokens {
max_locked_tokens: 0,
},
ExecuteMsg::Pause {},
];

for msg in msgs {
let res = execute(deps.as_mut(), env.clone(), info.clone(), msg.clone());
assert!(res.is_err());
assert!(res.unwrap_err().to_string().contains("Paused"));
}
}
7 changes: 6 additions & 1 deletion contracts/tribute/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use cosmwasm_std::{
use cw2::set_contract_version;

use crate::error::ContractError;
use crate::msg::{ExecuteMsg, InstantiateMsg};
use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg};
use crate::query::QueryMsg;
use crate::state::{Config, Tribute, CONFIG, TRIBUTE_CLAIMS, TRIBUTE_ID, TRIBUTE_MAP};
use hydro::query::QueryMsg as HydroQueryMsg;
Expand Down Expand Up @@ -388,3 +388,8 @@ fn get_top_n_proposal(

Ok(None)
}

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result<Response, ContractError> {
Ok(Response::default())
}
3 changes: 3 additions & 0 deletions contracts/tribute/src/msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ pub enum ExecuteMsg {
tribute_id: u64,
},
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MigrateMsg {}

0 comments on commit 752545f

Please sign in to comment.