diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e010b368..1cbe220c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -65,7 +65,7 @@ jobs: uses: taiki-e/install-action@cargo-llvm-cov - name: Generate code coverage - run: cargo +nightly llvm-cov --all-features --workspace --exclude hpl-tests --codecov --output-path codecov.json + run: cargo llvm-cov --workspace --exclude hpl-tests --codecov --output-path codecov.json - name: Upload to codecov.io uses: codecov/codecov-action@v3 diff --git a/Cargo.toml b/Cargo.toml index 643fe642..9ef3ffa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,7 @@ hpl-mailbox = { path = "./contracts/core/mailbox" } hpl-validator-announce = { path = "./contracts/core/va" } hpl-hook-merkle = { path = "./contracts/hooks/merkle" } +hpl-hook-fee = { path = "./contracts/hooks/fee" } hpl-hook-pausable = { path = "./contracts/hooks/pausable" } hpl-hook-routing = { path = "./contracts/hooks/routing" } hpl-hook-routing-custom = { path = "./contracts/hooks/routing-custom" } diff --git a/contracts/core/mailbox/src/execute.rs b/contracts/core/mailbox/src/execute.rs index 3e43a62f..33792d39 100644 --- a/contracts/core/mailbox/src/execute.rs +++ b/contracts/core/mailbox/src/execute.rs @@ -1,13 +1,14 @@ use cosmwasm_std::{ - ensure, ensure_eq, to_json_binary, wasm_execute, BankMsg, Coins, DepsMut, Env, HexBinary, - MessageInfo, Response, StdResult, + ensure, ensure_eq, to_json_binary, wasm_execute, Coin, Coins, DepsMut, Env, + HexBinary, MessageInfo, Response, }; +use cw_utils::PaymentError::MissingDenom; use hpl_interface::{ core::{ mailbox::{DispatchMsg, DispatchResponse}, HandleMsg, }, - hook::{self, post_dispatch}, + hook::{post_dispatch, quote_dispatch}, ism, types::Message, }; @@ -107,74 +108,47 @@ pub fn dispatch( } ); - // calculate gas - let default_hook = config.get_default_hook(); - let required_hook = config.get_required_hook(); - + // build hyperlane message let msg = dispatch_msg .clone() .to_msg(MAILBOX_VERSION, nonce, config.local_domain, &info.sender)?; - let msg_id = msg.id(); + let metadata = dispatch_msg.clone().metadata.unwrap_or_default(); + let hook = dispatch_msg.get_hook_addr(deps.api, config.get_default_hook())?; - let base_fee = hook::quote_dispatch( - &deps.querier, - dispatch_msg.get_hook_addr(deps.api, default_hook)?, - dispatch_msg.metadata.clone().unwrap_or_default(), - msg.clone(), - )? - .fees; - - let required_fee = hook::quote_dispatch( - &deps.querier, - &required_hook, - dispatch_msg.metadata.clone().unwrap_or_default(), - msg.clone(), - )? - .fees; - - // assert gas received is satisfies required gas - let mut total_fee = required_fee.clone().into_iter().try_fold( - Coins::try_from(base_fee.clone())?, - |mut acc, fee| { - acc.add(fee)?; - StdResult::Ok(acc) - }, - )?; - for fund in info.funds { - total_fee.sub(fund)?; - } + // assert gas received satisfies required gas + let required_hook = config.get_required_hook(); + let required_hook_fees: Vec = + quote_dispatch(&deps.querier, &required_hook, metadata.clone(), msg.clone())?.fees; - // interaction - let hook = dispatch_msg.get_hook_addr(deps.api, config.get_default_hook())?; - let hook_metadata = dispatch_msg.metadata.unwrap_or_default(); + let mut funds = Coins::try_from(info.funds)?; + for coin in required_hook_fees.iter() { + if let Err(_) = funds.sub(coin.clone()) { + return Err(ContractError::Payment(MissingDenom(coin.denom.clone()))); + } + } - // effects + // commit to message + let msg_id = msg.id(); NONCE.save(deps.storage, &(nonce + 1))?; LATEST_DISPATCHED_ID.save(deps.storage, &msg_id.to_vec())?; - // make message + // build post dispatch calls let post_dispatch_msgs = vec![ post_dispatch( required_hook, - hook_metadata.clone(), + metadata.clone(), msg.clone(), - Some(required_fee), + Some(required_hook_fees), )?, - post_dispatch(hook, hook_metadata, msg.clone(), Some(base_fee))?, + post_dispatch(hook, metadata, msg.clone(), Some(funds.to_vec()))?, ]; - let refund_msg = BankMsg::Send { - to_address: info.sender.to_string(), - amount: total_fee.to_vec(), - }; - Ok(Response::new() .add_event(emit_dispatch_id(msg_id.clone())) .add_event(emit_dispatch(msg)) .set_data(to_json_binary(&DispatchResponse { message_id: msg_id })?) - .add_messages(post_dispatch_msgs) - .add_message(refund_msg)) + .add_messages(post_dispatch_msgs)) } pub fn process( diff --git a/contracts/hooks/fee/Cargo.toml b/contracts/hooks/fee/Cargo.toml new file mode 100644 index 00000000..d5f6e792 --- /dev/null +++ b/contracts/hooks/fee/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "hpl-hook-fee" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +keywords.workspace = true + +[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 +cosmwasm-storage.workspace = true +cosmwasm-schema.workspace = true + +cw-storage-plus.workspace = true +cw2.workspace = true +cw-utils.workspace = true + +schemars.workspace = true +serde-json-wasm.workspace = true + +thiserror.workspace = true + +hpl-ownable.workspace = true +hpl-interface.workspace = true + +[dev-dependencies] +rstest.workspace = true +ibcx-test-utils.workspace = true + +anyhow.workspace = true diff --git a/contracts/hooks/fee/src/lib.rs b/contracts/hooks/fee/src/lib.rs new file mode 100644 index 00000000..743d6e79 --- /dev/null +++ b/contracts/hooks/fee/src/lib.rs @@ -0,0 +1,275 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + ensure, ensure_eq, BankMsg, Coin, CosmosMsg, Deps, DepsMut, Env, Event, MessageInfo, + QueryResponse, Response, StdError, +}; +use cw_storage_plus::Item; +use hpl_interface::{ + hook::{ + fee::{ExecuteMsg, FeeHookMsg, FeeHookQueryMsg, FeeResponse, InstantiateMsg, QueryMsg}, + HookQueryMsg, MailboxResponse, QuoteDispatchResponse, + }, + to_binary, +}; + +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + PaymentError(#[from] cw_utils::PaymentError), + + #[error("unauthorized")] + Unauthorized {}, + + #[error("hook paused")] + Paused {}, +} + +// version info for migration info +pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub const COIN_FEE_KEY: &str = "coin_fee"; +pub const COIN_FEE: Item = Item::new(COIN_FEE_KEY); + +fn new_event(name: &str) -> Event { + Event::new(format!("hpl_hook_fee::{}", name)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let owner = deps.api.addr_validate(&msg.owner)?; + + hpl_ownable::initialize(deps.storage, &owner)?; + COIN_FEE.save(deps.storage, &msg.fee)?; + + Ok(Response::new().add_event( + new_event("initialize") + .add_attribute("sender", info.sender) + .add_attribute("owner", owner) + .add_attribute("fee_denom", msg.fee.denom) + .add_attribute("fee_amount", msg.fee.amount), + )) +} + +fn get_fee(deps: Deps) -> Result { + let fee = COIN_FEE.load(deps.storage)?; + + Ok(FeeResponse { fee }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Ownable(msg) => Ok(hpl_ownable::handle(deps, env, info, msg)?), + ExecuteMsg::FeeHook(msg) => match msg { + FeeHookMsg::SetFee { fee } => { + let owner = hpl_ownable::get_owner(deps.storage)?; + ensure_eq!(owner, info.sender, StdError::generic_err("unauthorized")); + + COIN_FEE.save(deps.storage, &fee)?; + + Ok(Response::new().add_event( + new_event("set_fee") + .add_attribute("fee_denom", fee.denom) + .add_attribute("fee_amount", fee.amount), + )) + } + FeeHookMsg::Claim { recipient } => { + let owner = hpl_ownable::get_owner(deps.storage)?; + ensure_eq!(owner, info.sender, StdError::generic_err("unauthorized")); + + let recipient = recipient.unwrap_or(owner); + let balances = deps.querier.query_all_balances(&env.contract.address)?; + + let claim_msg: CosmosMsg = BankMsg::Send { + to_address: recipient.into_string(), + amount: balances, + } + .into(); + + Ok(Response::new() + .add_message(claim_msg) + .add_event(new_event("claim"))) + } + }, + ExecuteMsg::PostDispatch(_) => { + let fee = COIN_FEE.load(deps.storage)?; + let supplied = cw_utils::must_pay(&info, &fee.denom)?; + + ensure!( + supplied.u128() >= fee.amount.u128(), + // TODO: improve error + StdError::generic_err("insufficient funds") + ); + + Ok(Response::new().add_event( + new_event("post_dispatch") + .add_attribute("paid_denom", fee.denom) + .add_attribute("paid_amount", supplied), + )) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::Ownable(msg) => Ok(hpl_ownable::handle_query(deps, env, msg)?), + QueryMsg::Hook(msg) => match msg { + HookQueryMsg::Mailbox {} => to_binary(get_mailbox(deps)), + HookQueryMsg::QuoteDispatch(_) => to_binary(quote_dispatch(deps)), + }, + QueryMsg::FeeHook(FeeHookQueryMsg::Fee {}) => to_binary(get_fee(deps)), + } +} + +fn get_mailbox(_deps: Deps) -> Result { + Ok(MailboxResponse { + mailbox: "unrestricted".to_string(), + }) +} + +fn quote_dispatch(deps: Deps) -> Result { + let fee = COIN_FEE.load(deps.storage)?; + Ok(QuoteDispatchResponse { fees: vec![fee] }) +} + +#[cfg(test)] +mod test { + use cosmwasm_schema::serde::{de::DeserializeOwned, Serialize}; + use cosmwasm_std::{ + coin, from_json, + testing::{mock_dependencies, mock_env, mock_info, MockApi, MockQuerier, MockStorage}, + to_json_binary, Addr, HexBinary, OwnedDeps, + }; + use hpl_interface::hook::{PostDispatchMsg, QuoteDispatchMsg}; + use hpl_ownable::get_owner; + use ibcx_test_utils::{addr, gen_bz}; + use rstest::{fixture, rstest}; + + use super::*; + + type TestDeps = OwnedDeps; + + fn query(deps: Deps, msg: S) -> T { + let req: QueryMsg = from_json(to_json_binary(&msg).unwrap()).unwrap(); + let res = crate::query(deps, mock_env(), req).unwrap(); + from_json(res).unwrap() + } + + #[fixture] + fn deps( + #[default(addr("deployer"))] sender: Addr, + #[default(addr("owner"))] owner: Addr, + #[default(coin(100, "uusd"))] fee: Coin, + ) -> TestDeps { + let mut deps = mock_dependencies(); + + instantiate( + deps.as_mut(), + mock_env(), + mock_info(sender.as_str(), &[]), + InstantiateMsg { + owner: owner.to_string(), + fee, + }, + ) + .unwrap(); + + deps + } + + #[rstest] + fn test_init(deps: TestDeps) { + assert_eq!("uusd", get_fee(deps.as_ref()).unwrap().fee.denom.as_str()); + assert_eq!("owner", get_owner(deps.as_ref().storage).unwrap().as_str()); + } + + #[rstest] + #[case(&[coin(100, "uusd")])] + #[should_panic(expected = "Generic error: insufficient funds")] + #[case(&[coin(99, "uusd")])] + fn test_post_dispatch(mut deps: TestDeps, #[case] funds: &[Coin]) { + execute( + deps.as_mut(), + mock_env(), + mock_info("owner", funds), + ExecuteMsg::PostDispatch(PostDispatchMsg { + metadata: HexBinary::default(), + message: gen_bz(100), + }), + ) + .map_err(|e| e.to_string()) + .unwrap(); + } + + #[rstest] + fn test_query(deps: TestDeps) { + let res: MailboxResponse = query(deps.as_ref(), QueryMsg::Hook(HookQueryMsg::Mailbox {})); + assert_eq!("unrestricted", res.mailbox.as_str()); + + let res: QuoteDispatchResponse = query( + deps.as_ref(), + QueryMsg::Hook(HookQueryMsg::QuoteDispatch(QuoteDispatchMsg::default())), + ); + assert_eq!(res.fees, vec![coin(100, "uusd")]); + } + + #[rstest] + #[case(addr("owner"), coin(200, "uusd"))] + #[should_panic(expected = "unauthorized")] + #[case(addr("deployer"), coin(200, "uusd"))] + fn test_set_fee(mut deps: TestDeps, #[case] sender: Addr, #[case] fee: Coin) { + execute( + deps.as_mut(), + mock_env(), + mock_info(sender.as_str(), &[]), + ExecuteMsg::FeeHook(FeeHookMsg::SetFee { fee: fee.clone() }), + ) + .map_err(|e| e.to_string()) + .unwrap(); + + assert_eq!(fee, get_fee(deps.as_ref()).unwrap().fee); + } + + #[rstest] + #[case(addr("owner"), Some(addr("deployer")))] + #[case(addr("owner"), None)] + #[should_panic(expected = "unauthorized")] + #[case(addr("deployer"), None)] + fn test_claim(mut deps: TestDeps, #[case] sender: Addr, #[case] recipient: Option) { + let res = execute( + deps.as_mut(), + mock_env(), + mock_info(sender.as_str(), &[]), + ExecuteMsg::FeeHook(FeeHookMsg::Claim { recipient: recipient.clone() }), + ) + .map_err(|e| e.to_string()) + .unwrap(); + + assert_eq!( + CosmosMsg::Bank(BankMsg::Send { + to_address: recipient.unwrap_or_else(|| addr("owner")).into_string(), + amount: vec![], + }), + res.messages[0].msg + ); + println!("{:?}", res); + } +} diff --git a/contracts/hooks/merkle/src/lib.rs b/contracts/hooks/merkle/src/lib.rs index e837cb83..be13388a 100644 --- a/contracts/hooks/merkle/src/lib.rs +++ b/contracts/hooks/merkle/src/lib.rs @@ -59,18 +59,14 @@ pub fn instantiate( ) -> Result { cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - let owner = deps.api.addr_validate(&msg.owner)?; let mailbox = deps.api.addr_validate(&msg.mailbox)?; - hpl_ownable::initialize(deps.storage, &owner)?; - MAILBOX.save(deps.storage, &mailbox)?; MESSAGE_TREE.save(deps.storage, &MerkleTree::default())?; Ok(Response::new().add_event( new_event("initialize") .add_attribute("sender", info.sender) - .add_attribute("owner", owner) .add_attribute("mailbox", mailbox), )) } @@ -78,12 +74,11 @@ pub fn instantiate( #[cfg_attr(not(feature = "library"), entry_point)] pub fn execute( deps: DepsMut, - env: Env, - info: MessageInfo, + _env: Env, + _info: MessageInfo, msg: ExecuteMsg, ) -> Result { match msg { - ExecuteMsg::Ownable(msg) => Ok(hpl_ownable::handle(deps, env, info, msg)?), ExecuteMsg::PostDispatch(PostDispatchMsg { message, .. }) => { let mailbox = MAILBOX.load(deps.storage)?; @@ -123,11 +118,10 @@ pub fn execute( } #[cfg_attr(not(feature = "library"), entry_point)] -pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result { +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result { use MerkleHookQueryMsg::*; match msg { - QueryMsg::Ownable(msg) => Ok(hpl_ownable::handle_query(deps, env, msg)?), QueryMsg::Hook(msg) => match msg { HookQueryMsg::Mailbox {} => to_binary(get_mailbox(deps)), HookQueryMsg::QuoteDispatch(_) => to_binary(quote_dispatch()), @@ -214,7 +208,6 @@ mod test { use hpl_interface::{ build_test_executor, build_test_querier, core::mailbox, hook::QuoteDispatchMsg, }; - use hpl_ownable::get_owner; use ibcx_test_utils::hex; use rstest::{fixture, rstest}; @@ -228,7 +221,6 @@ mod test { #[fixture] fn deps( #[default(Addr::unchecked("deployer"))] sender: Addr, - #[default(Addr::unchecked("owner"))] owner: Addr, #[default(Addr::unchecked("mailbox"))] mailbox: Addr, ) -> TestDeps { let mut deps = mock_dependencies(); @@ -238,7 +230,6 @@ mod test { mock_env(), mock_info(sender.as_str(), &[]), InstantiateMsg { - owner: owner.to_string(), mailbox: mailbox.to_string(), }, ) @@ -249,7 +240,6 @@ mod test { #[rstest] fn test_init(deps: TestDeps) { - assert_eq!("owner", get_owner(deps.as_ref().storage).unwrap().as_str()); assert_eq!( "mailbox", MAILBOX.load(deps.as_ref().storage).unwrap().as_str() diff --git a/integration-test/tests/contracts/cw/hook.rs b/integration-test/tests/contracts/cw/hook.rs index c7d4cfb3..413c8cd2 100644 --- a/integration-test/tests/contracts/cw/hook.rs +++ b/integration-test/tests/contracts/cw/hook.rs @@ -99,7 +99,6 @@ impl Hook { .instantiate( codes.hook_merkle, &hook::merkle::InstantiateMsg { - owner: owner.address(), mailbox, }, Some(deployer.address().as_str()), diff --git a/packages/interface/src/hook/fee.rs b/packages/interface/src/hook/fee.rs new file mode 100644 index 00000000..b814b642 --- /dev/null +++ b/packages/interface/src/hook/fee.rs @@ -0,0 +1,83 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Coin}; + +use crate::ownable::{OwnableMsg, OwnableQueryMsg}; + +use super::{HookQueryMsg, PostDispatchMsg}; + +pub const TREE_DEPTH: usize = 32; + +#[cw_serde] +pub struct InstantiateMsg { + pub owner: String, + pub fee: Coin, +} + +#[cw_serde] +pub enum ExecuteMsg { + Ownable(OwnableMsg), + PostDispatch(PostDispatchMsg), + FeeHook(FeeHookMsg), +} + +#[cw_serde] +pub enum FeeHookMsg { + SetFee { + fee: Coin, + }, + Claim { + recipient: Option + } +} + +#[cw_serde] +#[derive(QueryResponses)] +#[query_responses(nested)] +pub enum QueryMsg { + Ownable(OwnableQueryMsg), + Hook(HookQueryMsg), + FeeHook(FeeHookQueryMsg), +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum FeeHookQueryMsg { + #[returns(FeeResponse)] + Fee {} +} + +#[cw_serde] +pub struct FeeResponse { + pub fee: Coin, +} + +#[cfg(test)] +mod test { + use cosmwasm_std::HexBinary; + + use super::*; + use crate::{ + hook::{ExpectedHookQueryMsg, PostDispatchMsg, QuoteDispatchMsg}, + msg_checker, + }; + + #[test] + fn test_hook_interface() { + let _checked: ExecuteMsg = msg_checker( + PostDispatchMsg { + metadata: HexBinary::default(), + message: HexBinary::default(), + } + .wrap(), + ); + + let _checked: QueryMsg = msg_checker(ExpectedHookQueryMsg::Hook(HookQueryMsg::Mailbox {})); + let _checked: QueryMsg = msg_checker( + QuoteDispatchMsg { + metadata: HexBinary::default(), + message: HexBinary::default(), + } + .request(), + ); + } +} diff --git a/packages/interface/src/hook/merkle.rs b/packages/interface/src/hook/merkle.rs index 007ac839..85d6180d 100644 --- a/packages/interface/src/hook/merkle.rs +++ b/packages/interface/src/hook/merkle.rs @@ -1,21 +1,17 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::HexBinary; -use crate::ownable::{OwnableMsg, OwnableQueryMsg}; - use super::{HookQueryMsg, PostDispatchMsg}; pub const TREE_DEPTH: usize = 32; #[cw_serde] pub struct InstantiateMsg { - pub owner: String, pub mailbox: String, } #[cw_serde] pub enum ExecuteMsg { - Ownable(OwnableMsg), PostDispatch(PostDispatchMsg), } @@ -23,7 +19,6 @@ pub enum ExecuteMsg { #[derive(QueryResponses)] #[query_responses(nested)] pub enum QueryMsg { - Ownable(OwnableQueryMsg), Hook(HookQueryMsg), MerkleHook(MerkleHookQueryMsg), } diff --git a/packages/interface/src/hook/mod.rs b/packages/interface/src/hook/mod.rs index 0c368b0b..1c76d998 100644 --- a/packages/interface/src/hook/mod.rs +++ b/packages/interface/src/hook/mod.rs @@ -4,6 +4,7 @@ pub mod pausable; pub mod routing; pub mod routing_custom; pub mod routing_fallback; +pub mod fee; use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::{