Skip to content

Commit

Permalink
Implement simple protocol fee hook (#89)
Browse files Browse the repository at this point in the history
* Add pausable ism with tests

* Fix paused query error case

* Run CI against all PRs

* Add pausable ISM to README

* Build wasm

* Fix scripts

* Allow threshold == set size and add tests

* Upload artifacts

* Force

* Move into makefile

* Install rename

* Rename properly

* Update test.yaml

* Implement simple fee hook_

* Make merkle hook not ownable

* Address pr comments

* Fix unit tests

* Fix renaming

* Fix makefile indentation

* Force cargo install

* Fix fee hook tests

* Make set fee only owner

* Implement remaining unit tests

* Fix merkle integration test use

---------

Co-authored-by: nambrot <[email protected]>
  • Loading branch information
yorhodes and nambrot authored Jan 23, 2024
1 parent f3b2896 commit 32e46b5
Show file tree
Hide file tree
Showing 10 changed files with 432 additions and 27 deletions.
21 changes: 17 additions & 4 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,25 @@ jobs:
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

- name: Install Rust
run: rustup update stable
run: rustup update 1.72

- name: Install target
run: rustup target add wasm32-unknown-unknown
- name: Install rename
run: sudo apt-get install -y rename

- name: Install rust deps
run: make install

- run: cargo test --workspace --exclude hpl-tests
- name: Run tests
run: cargo test --workspace --exclude hpl-tests

- name: Build wasm
run: make ci-build

- name: Upload wasm archive
uses: actions/upload-artifact@v2
with:
name: wasm_codes
path: wasm_codes.zip

coverage:
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
14 changes: 10 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@

clean:
@cargo clean
@rm -rf ./artifacts

install:
cargo install --force cw-optimizoor cosmwasm-check beaker
rustup target add wasm32-unknown-unknown

schema:
ls ./contracts | xargs -n 1 -t beaker wasm ts-gen

build:
cargo build
cargo wasm

build-dev: clean
cargo cw-optimizoor
rename --force 's/(.*)-(.*)\.wasm/$$1\.wasm/d' artifacts/*

check: build-dev
check: build
ls -d ./artifacts/*.wasm | xargs -I x cosmwasm-check x

ci-build: check
zip -jr wasm_codes.zip artifacts
42 changes: 42 additions & 0 deletions contracts/hooks/fee/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
275 changes: 275 additions & 0 deletions contracts/hooks/fee/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Coin> = 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<Response, ContractError> {
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<FeeResponse, ContractError> {
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<Response, ContractError> {
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<QueryResponse, ContractError> {
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<MailboxResponse, ContractError> {
Ok(MailboxResponse {
mailbox: "unrestricted".to_string(),
})
}

fn quote_dispatch(deps: Deps) -> Result<QuoteDispatchResponse, ContractError> {
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<MockStorage, MockApi, MockQuerier>;

fn query<S: Serialize, T: DeserializeOwned>(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<Addr>) {
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);
}
}
Loading

0 comments on commit 32e46b5

Please sign in to comment.