diff --git a/Cargo.lock b/Cargo.lock index 0bdc6c4b1..fdccf74b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -953,6 +953,46 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cwd-proposal-delegate" +version = "1.0.0-beta" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-denom", + "cw-hooks", + "cw-multi-test", + "cw-paginate 2.0.0-beta", + "cw-storage-plus 0.16.0", + "cw-utils 0.16.0", + "cw2 0.16.0", + "cw20 0.16.0", + "cw20-base", + "cw20-stake", + "cw3 0.16.0", + "cw4", + "cw4-group", + "cw721-base", + "dao-core", + "dao-interface", + "dao-macros", + "dao-pre-propose-base", + "dao-pre-propose-multiple", + "dao-proposal-hooks", + "dao-testing", + "dao-vote-hooks", + "dao-voting", + "dao-voting-cw20-balance", + "dao-voting-cw20-staked", + "dao-voting-cw4", + "dao-voting-cw721-staked", + "dao-voting-native-staked", + "rand", + "thiserror", + "voting", +] + [[package]] name = "dao-core" version = "2.0.0-beta" diff --git a/contracts/proposal/cwd-proposal-delegate/.cargo/config b/contracts/proposal/cwd-proposal-delegate/.cargo/config new file mode 100644 index 000000000..af5698e58 --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" diff --git a/contracts/proposal/cwd-proposal-delegate/.gitignore b/contracts/proposal/cwd-proposal-delegate/.gitignore new file mode 100644 index 000000000..9095deaa4 --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/.gitignore @@ -0,0 +1,16 @@ +# Build results +/target +/schema + +# Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327) +.cargo-ok + +# Text file backups +**/*.rs.bk + +# macOS +.DS_Store + +# IDEs +*.iml +.idea diff --git a/contracts/proposal/cwd-proposal-delegate/Cargo.toml b/contracts/proposal/cwd-proposal-delegate/Cargo.toml new file mode 100644 index 000000000..cf751d914 --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "cwd-proposal-delegate" +version = "1.0.0-beta" +authors = ["baoskee"] +edition = "2021" +repository = "https://github.com/DA0-DA0/dao-contracts" +description = "Proposal execute delegation to any smart contract" +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true, features = ["ibc3"] } +cosmwasm-storage = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw3 = { workspace = true } +thiserror = { version = "1.0" } +dao-core = { workspace = true, features = ["library"] } +dao-macros = { workspace = true } +dao-pre-propose-base = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +cw-hooks = { workspace = true } +cw-paginate = { workspace = true } +dao-proposal-hooks = { workspace = true } +dao-vote-hooks = { workspace = true } +dao-pre-propose-multiple = { workspace = true } +voting-v1 = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting-cw20-balance = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +dao-voting-native-staked = { workspace = true } +dao-voting-cw721-staked = { workspace = true } +cw-denom = { workspace = true } +dao-testing = { workspace = true } +cw20-stake = { workspace = true } +cw20-base = { workspace = true } +cw721-base = { workspace = true } +cw4 = { workspace = true } +cw4-group = { workspace = true } +rand = { workspace = true } diff --git a/contracts/proposal/cwd-proposal-delegate/README.md b/contracts/proposal/cwd-proposal-delegate/README.md new file mode 100644 index 000000000..a60b1f7c6 --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/README.md @@ -0,0 +1,95 @@ +# Introduction + +Delegation is basically: “we trust you have the judgment, we give you the power to do this specific action within some X time frame. You have the right but not the obligation.” + +## Examples +- You have the power to kick this specific member after review. Perhaps you make an investigative committee after a member is accused of something, and delegate the power to a multisig to execute the message: “Kick this member” +- You can pause one of our SubDAOs for 1 week if necessary +- You have the power to add a specific item to our DAO config at any time +- You have the power to increase Bob’s salary by 800 JUNO through DAO treasury +- Liquidate 8000 JUNO through JunoSwap at any time. Perhaps you want to delegate this to a hedge fund who promises to “time the market” + +## When would it not be useful? + +- **You can compose it through something other than a proposal module**. A separate option contract. However, this would not allow you to execute messages through the core module. Only proposal modules can pass messages through the core module. + - In order to control Treasury funds, you can siphon off to a SubDAO, and then SubDAO can make the decisions. However, this does not constrain the action of the SubDAO. It can totally misuse those funds. We want allowance of specific and concrete messages. + - In most cases, an escrow contract would do the job, but in certain cases, we want to be very granular with the actions we delegate + +## When would it be useful? + +- **For specific, constrained DAO-related actions**. For the things the DAO control and in which an escrow would give too much power over the resource. The DAO wants delegation on specific message that gives the delegate constrained power +- **Time-based actions**. Actions where the time of execution matters, and an executive decision maker needs power to execute at any time +- **Judgment-based actions**. Perhaps another DAO or oracle service specializes in identity-proving activities, and you would like to mark certain members as a real person for some sort of one person one vote thing. Or, perhaps, you want to remove a multisig member, and decide to delegate said action to a Judiciary DAO, some sort of tribunal + + + +# Design + +This is kind of a “messages escrow” module in which the execution of the proposal is wrapped into an option and execution is given to another party. + + +It would be a “proposal module” that allows arbitrary wrapping of messages, and would be required for the DAO to add as a Proposal Module. However, it would not use a voting module, but rather, another proposal module like `cwd-proposal-single` would pass a delegation message to the Core module, whereby the core module would execute a delegation message. + +Execution messages: + +```rust +// Gives back a delegation ID +Delegate { msgs: Vec>, addr: String, expiration: Expiration } +// Authorized execution only +Execute { delegation_id: u64 } +``` + +State: + +```rust +struct Config { + admin: Addr, +} + +struct Delegation { + addr: Addr, + msgs: Vec>, + expiration: Expiration, +} + +const DELEGATIONS: Map = Map::new("delegations"); +const CONFIG: Item = Item::new("config"); +``` + +## How could a DaoDao core module use this? + +1. Add as a proposal module +2. Pass a delegation message through another proposal module (single or multiple-choice) as a Wasm message (converted into Cosmos message) + + ```rust + WasmMsg::Execute { + contract_addr: // address of Delegation Proposal Module + msg: to_binary(&DelegationExecuteMsg::Delegate { + msgs: // Cosmos messages to delegate, + addr: "0x2342", // Delegate with power to execute, possibly another DAO + expiration: 123123324 + })?, + funds: vec![] + } + ``` + +3. The delegate has the power to execute said proposal through the Delegate Proposal Module at any time up until expiration. Said execution would go through the DaoDao core module + + +# Smart Contract Risks +Proposal modules can route arbitrary messages to +the core module so we have to take special care. +I can classify risks into these domains: +* Un-authorized delegation +* Un-authorized execution +* Un-authorized revocation +* Multiple execution +* Expired execution + +Policy risks: +* Forbidden revoking when policy enabled +* Does not preserve on failure when policy enabled + + +Of all the risks, un-authorized delegation will definitely +compromise the core module. diff --git a/contracts/proposal/cwd-proposal-delegate/src/bin/schema.rs b/contracts/proposal/cwd-proposal-delegate/src/bin/schema.rs new file mode 100644 index 000000000..8003efecd --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/src/bin/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; + +use cwd_proposal_delegate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/contracts/proposal/cwd-proposal-delegate/src/contract.rs b/contracts/proposal/cwd-proposal-delegate/src/contract.rs new file mode 100644 index 000000000..9ea552776 --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/src/contract.rs @@ -0,0 +1,241 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Binary, BlockInfo, Deps, DepsMut, Empty, Env, MessageInfo, OverflowError, Reply, + Response, StdError, StdResult, Storage, SubMsg, WasmMsg, +}; +use cw_paginate::paginate_map_values; +use cw_utils::Expiration; +// use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{DelegationResponse, ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::{Config, Delegation, CONFIG, DELEGATIONS, DELEGATION_COUNT, EXECUTE_CTX}; + +/* +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:cwd-proposal-delegate"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +*/ + +const DEFAULT_POLICY_IRREVOCABLE: bool = false; +const DEFAULT_POLICY_PRESERVE_ON_FAILURE: bool = false; + +pub const REPLY_ID_EXECUTE_PROPOSAL_HOOK: u64 = 0; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let admin = deps.api.addr_validate(&msg.admin)?; + + CONFIG.save(deps.storage, &Config { admin })?; + + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Delegate { + delegate, + msgs, + expiration, + policy_irrevocable, + policy_preserve_on_failure, + } => { + let policy_module_irrevocable = + policy_irrevocable.unwrap_or(DEFAULT_POLICY_IRREVOCABLE); + let policy_preserve_on_failure = + policy_preserve_on_failure.unwrap_or(DEFAULT_POLICY_PRESERVE_ON_FAILURE); + let delegate = deps.api.addr_validate(&delegate)?; + execute_delegate( + deps, + env, + info, + Delegation { + delegate, + msgs, + expiration, + policy_module_irrevocable, + policy_preserve_on_failure, + }, + ) + } + ExecuteMsg::RemoveDelegation { delegation_id } => { + execute_remove_delegation(deps, env, info, delegation_id) + } + ExecuteMsg::Execute { delegation_id } => execute_execute(deps, env, info, delegation_id), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + match msg.id { + REPLY_ID_EXECUTE_PROPOSAL_HOOK => { + let id = EXECUTE_CTX.load(deps.storage)?; + let Delegation { + policy_preserve_on_failure, + .. + } = DELEGATIONS.load(deps.storage, id)?; + + if msg.result.is_err() && policy_preserve_on_failure { + return Ok(Response::default() + .add_attribute("execute_failed_but_preserved", id.to_string())); + } + // Delete delegation in both success and error case + DELEGATIONS.remove(deps.storage, id); + Ok(Response::default()) + } + _ => Err(ContractError::Std(StdError::GenericErr { + msg: "Reply handler ID not found".into(), + })), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Delegation { delegation_id } => { + Ok(to_binary(&query_delegation(deps, delegation_id)?)?) + } + QueryMsg::Delegations { start_after, limit } => { + let delegations = paginate_map_values( + deps, + &DELEGATIONS, + start_after, + limit, + cosmwasm_std::Order::Ascending, + )?; + Ok(to_binary(&delegations)?) + } + } +} + +// MARK: Query helpers + +fn query_delegation(deps: Deps, delegation_id: u64) -> StdResult { + let delegation = DELEGATIONS.load(deps.storage, delegation_id)?; + Ok(delegation) +} + +// MARK: Execute subroutines + +pub fn execute_delegate( + deps: DepsMut, + env: Env, + info: MessageInfo, + delegation: Delegation, +) -> Result { + let Config { admin } = CONFIG.load(deps.storage)?; + if info.sender != admin { + return Err(ContractError::Unauthorized {}); + } + assert_not_expired(&delegation.expiration, &env.block)?; + + let id = advance_delegation_count(deps.storage)?; + DELEGATIONS.save(deps.storage, id, &delegation)?; + + Ok(Response::default().add_attribute("delegate_id", id.to_string())) +} + +pub fn execute_remove_delegation( + deps: DepsMut, + _env: Env, + info: MessageInfo, + delegation_id: u64, +) -> Result { + let Config { admin } = CONFIG.load(deps.storage)?; + if info.sender != admin { + return Err(ContractError::Unauthorized {}); + } + // If delegation is irrevocable, return Error + let Delegation { + policy_module_irrevocable, + .. + } = DELEGATIONS.load(deps.storage, delegation_id)?; + if policy_module_irrevocable { + return Err(ContractError::DelegationIrrevocable {}); + } + // Else remove the delegation + DELEGATIONS.remove(deps.storage, delegation_id); + Ok(Response::default()) +} + +pub fn execute_execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + delegation_id: u64, +) -> Result { + let Delegation { + delegate, + msgs, + expiration, + .. + } = match DELEGATIONS.load(deps.storage, delegation_id) { + Ok(res) => res, + Err(_) => return Err(ContractError::DelegationNotFound {}), + }; + + if delegate != info.sender { + return Err(ContractError::Unauthorized {}); + } + assert_not_expired(&expiration, &env.block)?; + + let Config { admin } = CONFIG.load(deps.storage)?; + let wasm_msg = WasmMsg::Execute { + contract_addr: admin.to_string(), + msg: to_binary(&dao_core::msg::ExecuteMsg::ExecuteProposalHook { msgs })?, + funds: vec![], + }; + let submsg: SubMsg = SubMsg::reply_always(wasm_msg, REPLY_ID_EXECUTE_PROPOSAL_HOOK); + // For reply handler + EXECUTE_CTX.save(deps.storage, &delegation_id)?; + + Ok(Response::default().add_submessage(submsg)) +} + +// MARK: Helpers + +fn advance_delegation_count(store: &mut dyn Storage) -> StdResult { + let lhs = DELEGATION_COUNT.may_load(store)?.unwrap_or_default(); + let res = lhs.checked_add(1); + match res { + Some(id) => { + DELEGATION_COUNT.save(store, &id)?; + Ok(id) + } + None => Err(StdError::Overflow { + source: OverflowError { + operation: cosmwasm_std::OverflowOperation::Add, + operand1: lhs.to_string(), + operand2: 1.to_string(), + }, + }), + } +} + +fn assert_not_expired( + expiration: &Option, + block: &BlockInfo, +) -> Result<(), ContractError> { + match expiration { + Some(e) => { + if e.is_expired(block) { + Err(ContractError::DelegationExpired {}) + } else { + Ok(()) + } + } + None => Ok(()), + } +} diff --git a/contracts/proposal/cwd-proposal-delegate/src/error.rs b/contracts/proposal/cwd-proposal-delegate/src/error.rs new file mode 100644 index 000000000..79c93c2c4 --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/src/error.rs @@ -0,0 +1,20 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Delegation not found")] + DelegationNotFound {}, + + #[error("Delegation is irrevocable")] + DelegationIrrevocable {}, + + #[error("Delegation is expired")] + DelegationExpired {}, +} diff --git a/contracts/proposal/cwd-proposal-delegate/src/lib.rs b/contracts/proposal/cwd-proposal-delegate/src/lib.rs new file mode 100644 index 000000000..c039c63d9 --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/src/lib.rs @@ -0,0 +1,9 @@ +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; + +#[cfg(test)] +mod testing; diff --git a/contracts/proposal/cwd-proposal-delegate/src/msg.rs b/contracts/proposal/cwd-proposal-delegate/src/msg.rs new file mode 100644 index 000000000..c3a7e1b1a --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/src/msg.rs @@ -0,0 +1,41 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Empty}; + +use cw_utils::Expiration; + +use crate::state::Delegation; + +#[cw_serde] +pub struct InstantiateMsg { + pub admin: String, +} + +#[cw_serde] +pub enum ExecuteMsg { + Delegate { + delegate: String, + msgs: Vec>, + expiration: Option, + + policy_irrevocable: Option, + policy_preserve_on_failure: Option, + }, + /// Fails if delegation is non-revocable + RemoveDelegation { delegation_id: u64 }, + /// Only delegate can execute + Execute { delegation_id: u64 }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(Delegation)] + Delegation { delegation_id: u64 }, + #[returns(Vec)] + Delegations { + start_after: Option, + limit: Option, + }, +} + +pub type DelegationResponse = Delegation; diff --git a/contracts/proposal/cwd-proposal-delegate/src/state.rs b/contracts/proposal/cwd-proposal-delegate/src/state.rs new file mode 100644 index 000000000..36f90fea6 --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/src/state.rs @@ -0,0 +1,29 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, CosmosMsg, Empty}; +use cw_storage_plus::{Item, Map}; +use cw_utils::Expiration; + +#[cw_serde] +pub struct Config { + pub admin: Addr, +} + +#[cw_serde] +pub struct Delegation { + pub delegate: Addr, + pub msgs: Vec>, + pub expiration: Option, + + pub policy_module_irrevocable: bool, + pub policy_preserve_on_failure: bool, +} + +// Delegation ID between executions +pub type ExecuteContext = u64; + +pub const CONFIG: Item = Item::new("config"); + +pub const DELEGATIONS: Map = Map::new("delegations"); +pub const DELEGATION_COUNT: Item = Item::new("delegation_count"); + +pub const EXECUTE_CTX: Item = Item::new("execute_ctx"); diff --git a/contracts/proposal/cwd-proposal-delegate/src/testing/mod.rs b/contracts/proposal/cwd-proposal-delegate/src/testing/mod.rs new file mode 100644 index 000000000..14f00389d --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/src/testing/mod.rs @@ -0,0 +1 @@ +mod tests; diff --git a/contracts/proposal/cwd-proposal-delegate/src/testing/tests.rs b/contracts/proposal/cwd-proposal-delegate/src/testing/tests.rs new file mode 100644 index 000000000..260959882 --- /dev/null +++ b/contracts/proposal/cwd-proposal-delegate/src/testing/tests.rs @@ -0,0 +1,419 @@ +use crate::contract::{ + execute_delegate, execute_execute, execute_remove_delegation, instantiate, reply, + REPLY_ID_EXECUTE_PROPOSAL_HOOK, +}; +use crate::error::ContractError; +use crate::msg::InstantiateMsg; +use crate::state::{Delegation, EXECUTE_CTX}; +use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; +use cosmwasm_std::{Addr, Reply, SubMsgResponse, SubMsgResult}; +use cw_utils::Expiration; +use std::matches; + +const ADMIN_ADDR: &str = "admin"; + +// Non-admin cannot delegate a message +// Admin can delegate a message +#[test] +fn test_unauthorized_delegation() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("any_addr", &[]); + + instantiate( + deps.as_mut(), + env, + info, + InstantiateMsg { + admin: ADMIN_ADDR.to_string(), + }, + ) + .unwrap(); + + let info = mock_info("not_admin", &[]); + let env = mock_env(); + let err = execute_delegate( + deps.as_mut(), + env, + info, + Delegation { + delegate: Addr::unchecked("dest_addr"), + msgs: Vec::new(), + expiration: None, + policy_module_irrevocable: false, + policy_preserve_on_failure: true, + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized {})); + + // Admin delegation succeeds + let env = mock_env(); + let info = mock_info(ADMIN_ADDR, &[]); + execute_delegate( + deps.as_mut(), + env, + info, + Delegation { + delegate: Addr::unchecked("dest_addr"), + msgs: Vec::new(), + expiration: None, + policy_module_irrevocable: false, + policy_preserve_on_failure: false, + }, + ) + .unwrap(); +} + +// Only delegated can execute +// Admin cannot execute +// Non-admin non-delegated cannot execute +#[test] +fn test_execute_authorization() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("any_addr", &[]); + + instantiate( + deps.as_mut(), + env, + info, + InstantiateMsg { + admin: ADMIN_ADDR.to_string(), + }, + ) + .unwrap(); + let env = mock_env(); + let info = mock_info(ADMIN_ADDR, &[]); + let res = execute_delegate( + deps.as_mut(), + env, + info, + Delegation { + delegate: Addr::unchecked("dest_addr"), + msgs: Vec::new(), + expiration: None, + policy_module_irrevocable: false, + policy_preserve_on_failure: false, + }, + ) + .unwrap(); + + let delegate_id_attr = &res + .attributes + .iter() + .find(|&attr| attr.key == "delegate_id") + .unwrap(); + let delegate_id = delegate_id_attr.value.parse::().unwrap(); + assert_eq!(delegate_id, 1); + + // Non-admin cannot execute + let err = execute_execute( + deps.as_mut(), + mock_env(), + mock_info("not an admin", &[]), + delegate_id, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized {})); + + // Admin cannot execute + let err = execute_execute( + deps.as_mut(), + mock_env(), + mock_info(ADMIN_ADDR, &[]), + delegate_id, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized {})); + + // Delegated address can execute + execute_execute( + deps.as_mut(), + mock_env(), + mock_info("dest_addr", &[]), + delegate_id, + ) + .unwrap(); + + // - Second delegation independent from the first + let res = execute_delegate( + deps.as_mut(), + mock_env(), + mock_info(ADMIN_ADDR, &[]), + Delegation { + delegate: Addr::unchecked("dest_addr_2"), + msgs: Vec::new(), + expiration: None, + policy_module_irrevocable: false, + policy_preserve_on_failure: false, + }, + ) + .unwrap(); + let delegate_id_attr = &res + .attributes + .iter() + .find(|&attr| attr.key == "delegate_id") + .unwrap(); + let delegate_id = delegate_id_attr.value.parse::().unwrap(); + assert_eq!(delegate_id, 2); + + // Previously Delegated address cannot execute + let err = execute_execute( + deps.as_mut(), + mock_env(), + mock_info("dest_addr", &[]), + delegate_id, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized {})); + + // New delegate executes new delegation + execute_execute( + deps.as_mut(), + mock_env(), + mock_info("dest_addr_2", &[]), + delegate_id, + ) + .unwrap(); + + // New delegate cannot execute previous delegation + let err = + execute_execute(deps.as_mut(), mock_env(), mock_info("dest_addr_2", &[]), 1).unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized {})); +} + +// Can only execute once for false `preserve_on_failure` +// Can execute multiple if `preserve_on_failure` is true +// and execution fails. +#[test] +fn test_execute_on_failure_policy() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("any_addr", &[]); + + instantiate( + deps.as_mut(), + env, + info, + InstantiateMsg { + admin: ADMIN_ADDR.to_string(), + }, + ) + .unwrap(); + let env = mock_env(); + let info = mock_info(ADMIN_ADDR, &[]); + execute_delegate( + deps.as_mut(), + env, + info, + Delegation { + delegate: Addr::unchecked("dest_addr"), + msgs: Vec::new(), + expiration: None, + policy_module_irrevocable: false, + policy_preserve_on_failure: false, + }, + ) + .unwrap(); + + // Multiple execution fails + let failed_reply_msg = Reply { + id: REPLY_ID_EXECUTE_PROPOSAL_HOOK, + result: SubMsgResult::Err("Execution of delegated message failed".to_string()), + }; + EXECUTE_CTX.save(deps.as_mut().storage, &1).unwrap(); + reply(deps.as_mut(), mock_env(), failed_reply_msg).unwrap(); + + let err = + execute_execute(deps.as_mut(), mock_env(), mock_info("dest_addr", &[]), 1).unwrap_err(); + assert!(matches!(err, ContractError::DelegationNotFound {})); + + // `preserve_on_failure` set to true + { + let delegate_id: u64 = 2; + let info = mock_info(ADMIN_ADDR, &[]); + execute_delegate( + deps.as_mut(), + mock_env(), + info, + Delegation { + delegate: Addr::unchecked("dest_addr"), + msgs: Vec::new(), + expiration: None, + policy_module_irrevocable: false, + policy_preserve_on_failure: true, + }, + ) + .unwrap(); + // For `preserve_on_failure`, multiple failed execution is ok + let failed_reply_msg = Reply { + id: REPLY_ID_EXECUTE_PROPOSAL_HOOK, + result: SubMsgResult::Err("Execution of delegated message failed".to_string()), + }; + EXECUTE_CTX + .save(deps.as_mut().storage, &delegate_id) + .unwrap(); + reply(deps.as_mut(), mock_env(), failed_reply_msg.clone()).unwrap(); + reply(deps.as_mut(), mock_env(), failed_reply_msg.clone()).unwrap(); + reply(deps.as_mut(), mock_env(), failed_reply_msg.clone()).unwrap(); + + let ok_reply_msg = Reply { + id: REPLY_ID_EXECUTE_PROPOSAL_HOOK, + result: SubMsgResult::Ok(SubMsgResponse { + events: Vec::new(), + data: None, + }), + }; + reply(deps.as_mut(), mock_env(), ok_reply_msg).unwrap(); + + // Successful execution prevents future execution + let err = execute_execute( + deps.as_mut(), + mock_env(), + mock_info("dest_addr", &[]), + delegate_id, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::DelegationNotFound {})); + } +} + +// Cannot execute delegation if expired +#[test] +fn test_execute_on_expired() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("any_addr", &[]); + + instantiate( + deps.as_mut(), + env, + info, + InstantiateMsg { + admin: ADMIN_ADDR.to_string(), + }, + ) + .unwrap(); + + let mut env = mock_env(); + env.block.height = 0; + execute_delegate( + deps.as_mut(), + env, + mock_info(ADMIN_ADDR, &[]), + Delegation { + delegate: Addr::unchecked("dest_addr"), + msgs: Vec::new(), + expiration: Some(Expiration::AtHeight(10)), + policy_module_irrevocable: false, + policy_preserve_on_failure: false, + }, + ) + .unwrap(); + + // 10 height should fail + let mut expired_env = mock_env(); + expired_env.block.height = 10; + let err = + execute_execute(deps.as_mut(), expired_env, mock_info("dest_addr", &[]), 1).unwrap_err(); + assert!(matches!(err, ContractError::DelegationExpired {})); + + // 9 height should succeed + let mut not_expired_env = mock_env(); + not_expired_env.block.height = 9; + execute_execute( + deps.as_mut(), + not_expired_env, + mock_info("dest_addr", &[]), + 1, + ) + .unwrap(); +} + +// Un-authorized revocation should fail +// If delegation is irrevocable, admin cannot revoke +#[test] +fn test_revocable_policy() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("any_addr", &[]); + + instantiate( + deps.as_mut(), + env, + info, + InstantiateMsg { + admin: ADMIN_ADDR.to_string(), + }, + ) + .unwrap(); + let env = mock_env(); + let info = mock_info(ADMIN_ADDR, &[]); + execute_delegate( + deps.as_mut(), + env, + info, + Delegation { + delegate: Addr::unchecked("dest_addr"), + msgs: Vec::new(), + expiration: None, + policy_module_irrevocable: true, // Make it irrevocable + policy_preserve_on_failure: false, + }, + ) + .unwrap(); + + // Admin and non-admin cannot revoke an irrevocable delegation + let err = execute_remove_delegation(deps.as_mut(), mock_env(), mock_info(ADMIN_ADDR, &[]), 1) + .unwrap_err(); + assert!(matches!(err, ContractError::DelegationIrrevocable {})); + let err = execute_remove_delegation(deps.as_mut(), mock_env(), mock_info("non-admin", &[]), 1) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized {})); + + // Make revocable delegation + execute_delegate( + deps.as_mut(), + mock_env(), + mock_info(ADMIN_ADDR, &[]), + Delegation { + delegate: Addr::unchecked("dest_addr"), + msgs: Vec::new(), + expiration: None, + policy_module_irrevocable: false, + policy_preserve_on_failure: false, + }, + ) + .unwrap(); // has id of `2` + let revocable_delegate_id: u64 = 2; + + // Non-admin cannot revoke a revocable delegation + let err = execute_remove_delegation( + deps.as_mut(), + mock_env(), + mock_info("non-admin", &[]), + revocable_delegate_id, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized {})); + + // Admin can revoke a revocable delegation + execute_remove_delegation( + deps.as_mut(), + mock_env(), + mock_info(ADMIN_ADDR, &[]), + revocable_delegate_id, + ) + .unwrap(); + + // Can no longer execute + let err = execute_execute( + deps.as_mut(), + mock_env(), + mock_info("dest_addr", &[]), + revocable_delegate_id, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::DelegationNotFound {})); +}