From a9d645da5b64df9d5e294e0e2969e1c95541d3de Mon Sep 17 00:00:00 2001 From: Dusan Maksimovic Date: Wed, 24 Jul 2024 11:50:05 +0200 Subject: [PATCH 1/3] contract pausing implementation --- README.md | 3 + contracts/hydro/src/contract.rs | 91 ++++++++++++++++++++++++++----- contracts/hydro/src/error.rs | 3 + contracts/hydro/src/msg.rs | 4 ++ contracts/hydro/src/query.rs | 2 +- contracts/hydro/src/state.rs | 1 + contracts/hydro/src/testing.rs | 71 ++++++++++++++++++++++++ contracts/tribute/src/contract.rs | 7 ++- contracts/tribute/src/msg.rs | 3 + 9 files changed, 170 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3d13d4b..c88bd30 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,6 @@ 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 to the same Code ID. Such proposal execution will trigger the `migrate()` function that will un-pause the contract and allow normal functioning of the contract to continue. \ No newline at end of file diff --git a/contracts/hydro/src/contract.rs b/contracts/hydro/src/contract.rs index c9ef7e5..6c2fba4 100644 --- a/contracts/hydro/src/contract.rs +++ b/contracts/hydro/src/contract.rs @@ -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, @@ -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)?; @@ -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), } } @@ -145,6 +147,7 @@ fn lock_tokens( ) -> Result { 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)?; @@ -209,6 +212,8 @@ fn refresh_lock_duration( lock_duration: u64, ) -> Result { 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 @@ -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 { + 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 @@ -376,9 +384,12 @@ fn create_proposal( description: String, covenant_params: CovenantParams, ) -> Result { + 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)?; @@ -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)?; @@ -597,6 +610,9 @@ fn add_to_whitelist( info: MessageInfo, covenant_params: CovenantParams, ) -> Result { + let constants = CONSTANTS.load(deps.storage)?; + + validate_contract_is_not_paused(&constants)?; validate_sender_is_whitelist_admin(&deps, &info)?; // Add covenant_params to whitelist @@ -621,6 +637,9 @@ fn remove_from_whitelist( info: MessageInfo, covenant_params: CovenantParams, ) -> Result { + let constants = CONSTANTS.load(deps.storage)?; + + validate_contract_is_not_paused(&constants)?; validate_sender_is_whitelist_admin(&deps, &info)?; // Remove covenant_params from whitelist @@ -636,16 +655,13 @@ fn update_max_locked_tokens( info: MessageInfo, max_locked_tokens: u128, ) -> Result { - validate_sender_is_whitelist_admin(&deps, &info)?; + let mut constants = CONSTANTS.load(deps.storage)?; - CONSTANTS.update( - deps.storage, - |mut constants| -> Result { - 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") @@ -653,6 +669,29 @@ fn update_max_locked_tokens( .add_attribute("max_locked_tokens", max_locked_tokens.to_string())) } +// Pause: +// Validate sender is whitelist admin +// Validate that the contract isn't already locked +// Set paused to true and save the changes +fn pause_contract(deps: DepsMut, info: MessageInfo) -> Result { + validate_sender_is_whitelist_admin(&deps, &info)?; + + let mut constants = CONSTANTS.load(deps.storage)?; + if constants.paused { + return Err(ContractError::Std(StdError::generic_err( + "Contract is already paused", + ))); + } + + 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, @@ -665,6 +704,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 { match msg { @@ -726,7 +772,7 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { )?), 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)?), } } @@ -998,3 +1044,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 { + CONSTANTS.update( + deps.storage, + |mut constants| -> Result { + constants.paused = false; + Ok(constants) + }, + )?; + + Ok(Response::default()) +} diff --git a/contracts/hydro/src/error.rs b/contracts/hydro/src/error.rs index aa81a8e..0f402bc 100644 --- a/contracts/hydro/src/error.rs +++ b/contracts/hydro/src/error.rs @@ -15,4 +15,7 @@ pub enum ContractError { #[error("{0}")] PaymentError(#[from] PaymentError), + + #[error("Paused")] + Paused, } diff --git a/contracts/hydro/src/msg.rs b/contracts/hydro/src/msg.rs index a109674..412593a 100644 --- a/contracts/hydro/src/msg.rs +++ b/contracts/hydro/src/msg.rs @@ -46,4 +46,8 @@ pub enum ExecuteMsg { UpdateMaxLockedTokens { max_locked_tokens: u128, }, + Pause {}, } + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct MigrateMsg {} diff --git a/contracts/hydro/src/query.rs b/contracts/hydro/src/query.rs index eea2034..a56c135 100644 --- a/contracts/hydro/src/query.rs +++ b/contracts/hydro/src/query.rs @@ -50,7 +50,7 @@ pub enum QueryMsg { }, Whitelist {}, WhitelistAdmins {}, - TotalLockedTokens, + TotalLockedTokens {}, } #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug, Default)] diff --git a/contracts/hydro/src/state.rs b/contracts/hydro/src/state.rs index 8010008..bb4292c 100644 --- a/contracts/hydro/src/state.rs +++ b/contracts/hydro/src/state.rs @@ -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 diff --git a/contracts/hydro/src/testing.rs b/contracts/hydro/src/testing.rs index 770fcaa..38eaaa1 100644 --- a/contracts/hydro/src/testing.rs +++ b/contracts/hydro/src/testing.rs @@ -991,3 +991,74 @@ 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, + }, + ]; + + 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")); + } + + // verify that the privileged user receives an error if it tries to pause already paused 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("Contract is already paused")); +} diff --git a/contracts/tribute/src/contract.rs b/contracts/tribute/src/contract.rs index d290fb0..1222350 100644 --- a/contracts/tribute/src/contract.rs +++ b/contracts/tribute/src/contract.rs @@ -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; @@ -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 { + Ok(Response::default()) +} diff --git a/contracts/tribute/src/msg.rs b/contracts/tribute/src/msg.rs index 4e3c3fa..1a25a08 100644 --- a/contracts/tribute/src/msg.rs +++ b/contracts/tribute/src/msg.rs @@ -27,3 +27,6 @@ pub enum ExecuteMsg { tribute_id: u64, }, } + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct MigrateMsg {} From 6c1ae95d8211f0ad8c0f6bb7cacb4598fb2e9c4f Mon Sep 17 00:00:00 2001 From: Dusan Maksimovic <94966669+dusan-maksimovic@users.noreply.github.com> Date: Wed, 24 Jul 2024 12:23:31 +0200 Subject: [PATCH 2/3] Update README.md Co-authored-by: Philip Offtermatt <57488781+p-offtermatt@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c88bd30..141d7ab 100644 --- a/README.md +++ b/README.md @@ -58,4 +58,4 @@ The Hydro forum post mentions “tribute”- funds that proposal creators can at 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 to the same Code ID. Such proposal execution will trigger the `migrate()` function that will un-pause the contract and allow normal functioning of the contract to continue. \ No newline at end of file +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. \ No newline at end of file From 20138b0104d48cb007bae7cb692feb9f983dc1b0 Mon Sep 17 00:00:00 2001 From: Dusan Maksimovic Date: Wed, 24 Jul 2024 13:53:14 +0200 Subject: [PATCH 3/3] CR fixes --- README.md | 3 ++- contracts/hydro/src/contract.rs | 12 ++++-------- contracts/hydro/src/testing.rs | 10 +--------- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 141d7ab..539b821 100644 --- a/README.md +++ b/README.md @@ -58,4 +58,5 @@ The Hydro forum post mentions “tribute”- funds that proposal creators can at 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. \ No newline at end of file +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). \ No newline at end of file diff --git a/contracts/hydro/src/contract.rs b/contracts/hydro/src/contract.rs index 6c2fba4..44b3ab1 100644 --- a/contracts/hydro/src/contract.rs +++ b/contracts/hydro/src/contract.rs @@ -670,18 +670,14 @@ fn update_max_locked_tokens( } // Pause: +// Validate that the contract isn't already paused // Validate sender is whitelist admin -// Validate that the contract isn't already locked // Set paused to true and save the changes fn pause_contract(deps: DepsMut, info: MessageInfo) -> Result { - validate_sender_is_whitelist_admin(&deps, &info)?; - let mut constants = CONSTANTS.load(deps.storage)?; - if constants.paused { - return Err(ContractError::Std(StdError::generic_err( - "Contract is already paused", - ))); - } + + validate_contract_is_not_paused(&constants)?; + validate_sender_is_whitelist_admin(&deps, &info)?; constants.paused = true; CONSTANTS.save(deps.storage, &constants)?; diff --git a/contracts/hydro/src/testing.rs b/contracts/hydro/src/testing.rs index 38eaaa1..8a830f6 100644 --- a/contracts/hydro/src/testing.rs +++ b/contracts/hydro/src/testing.rs @@ -1045,6 +1045,7 @@ fn contract_pausing_test() { ExecuteMsg::UpdateMaxLockedTokens { max_locked_tokens: 0, }, + ExecuteMsg::Pause {}, ]; for msg in msgs { @@ -1052,13 +1053,4 @@ fn contract_pausing_test() { assert!(res.is_err()); assert!(res.unwrap_err().to_string().contains("Paused")); } - - // verify that the privileged user receives an error if it tries to pause already paused 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("Contract is already paused")); }