diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 00000000000..6ddb63b5b15 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,108 @@ +name: Cosmwasm Contracts +on: + pull_request: + push: + + +jobs: + test: + name: Test Suite + runs-on: ubuntu-latest + strategy: + matrix: + contract: [{workdir: ./x/ibc-rate-limit/, output: testdata/rate_limiter.wasm, build: artifacts/rate_limiter-x86_64.wasm, name: rate_limiter}] + + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + target: wasm32-unknown-unknown + + - name: Add the wasm target + working-directory: ${{ matrix.contract.workdir }} + run: > + rustup target add wasm32-unknown-unknown; + + + - name: Build + working-directory: ${{ matrix.contract.workdir }} + run: > + cargo build --release --target wasm32-unknown-unknown + + - name: Test + working-directory: ${{ matrix.contract.workdir }} + run: > + cargo test + + - name: Set latest cw-optimizoor version + run: > + echo "CW_OPTIMIZOOR_VERSION=`cargo search cw-optimizoor -q | cut -d '"' -f 2`" >> $GITHUB_ENV + + - name: Cache cw-optimizoor + id: cache-cw-optimizoor + uses: actions/cache@v3 + env: + cache-name: cache-cw-optimizoor + with: + # cargo bin files are stored in `~/.cargo/bin/` on Linux/macOS + path: ~/.cargo/bin/cargo-cw-optimizoor + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.CW_OPTIMIZOOR_VERSION }} + + - if: ${{ steps.cache-cw-optimizoor.outputs.cache-hit != 'true' }} + name: Install cw-optimizoor + continue-on-error: true + run: > + cargo install cw-optimizoor + + - name: Optimize + working-directory: ${{ matrix.contract.workdir }} + run: > + cargo cw-optimizoor + + - name: 'Upload optimized contract artifact' + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.contract.name }} + path: ${{ matrix.contract.workdir }}${{ matrix.contract.build }} + retention-days: 1 + +# - name: Check Test Data +# working-directory: ${{ matrix.contract.workdir }} +# if: ${{ matrix.contract.output != null }} +# run: > +# diff ${{ matrix.contract.output }} ${{ matrix.contract.build }} + + + lints: + name: Cosmwasm Lints + runs-on: ubuntu-latest + strategy: + matrix: + workdir: [./x/ibc-rate-limit] + + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: nightly + override: true + components: rustfmt, clippy + + - name: Format + working-directory: ${{ matrix.workdir }} + run: > + cargo fmt --all -- --check + + - name: run cargo clippy + working-directory: ${{ matrix.workdir }} + run: > + cargo clippy -- -D warnings + diff --git a/.gitignore b/.gitignore index 7540fbb4940..8ca74da7ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -206,4 +206,25 @@ tools-stamp *.save.* mutation_test_result.txt + +# Rust ignores. Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Generated by rust-optimizer +artifacts/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Ignores beaker state +.beaker blocks.db diff --git a/x/ibc-rate-limit/Cargo.toml b/x/ibc-rate-limit/Cargo.toml new file mode 100644 index 00000000000..9e4bf04d415 --- /dev/null +++ b/x/ibc-rate-limit/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] + +members = [ + 'contracts/*', +] + +[profile.release] +codegen-units = 1 +debug = false +debug-assertions = false +incremental = false +lto = true +opt-level = 3 +overflow-checks = true +panic = 'abort' +rpath = false diff --git a/x/ibc-rate-limit/contracts/rate-limiter/.cargo/config b/x/ibc-rate-limit/contracts/rate-limiter/.cargo/config new file mode 100644 index 00000000000..f31de6c2a75 --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/.cargo/config @@ -0,0 +1,3 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" diff --git a/x/ibc-rate-limit/contracts/rate-limiter/.gitignore b/x/ibc-rate-limit/contracts/rate-limiter/.gitignore new file mode 100644 index 00000000000..dfdaaa6bcda --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/.gitignore @@ -0,0 +1,15 @@ +# Build results +/target + +# 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/x/ibc-rate-limit/contracts/rate-limiter/Cargo.toml b/x/ibc-rate-limit/contracts/rate-limiter/Cargo.toml new file mode 100644 index 00000000000..e166d606418 --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "rate-limiter" +version = "0.1.0" +authors = ["Nicolas Lara "] +edition = "2021" + +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"] + +[profile.release] +opt-level = 3 +debug = false +rpath = false +lto = true +debug-assertions = false +codegen-units = 1 +panic = 'abort' +incremental = false +overflow-checks = true + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] +# Use the verbose responses feature if you want to include information about +# the remaining quotas in the SendPacket/RecvPacket responses +verbose_responses = [] + +[package.metadata.scripts] +optimize = """docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/rust-optimizer:0.12.6 +""" + +[dependencies] +cosmwasm-std = "1.0.0" +cosmwasm-storage = "1.0.0" +cw-storage-plus = "0.13.2" +cw2 = "0.13.2" +schemars = "0.8.8" +serde = { version = "1.0.137", default-features = false, features = ["derive"] } +thiserror = { version = "1.0.31" } + +[dev-dependencies] +cosmwasm-schema = "1.0.0" +cw-multi-test = "0.13.2" diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/contract.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/contract.rs new file mode 100644 index 00000000000..c42c88e8cb0 --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/contract.rs @@ -0,0 +1,113 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, SudoMsg}; +use crate::state::{FlowType, Path, GOVMODULE, IBCMODULE}; +use crate::{execute, query, sudo}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:rate-limiter"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + IBCMODULE.save(deps.storage, &msg.ibc_module)?; + GOVMODULE.save(deps.storage, &msg.gov_module)?; + + execute::add_new_paths(deps, msg.paths, env.block.time)?; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("ibc_module", msg.ibc_module.to_string()) + .add_attribute("gov_module", msg.gov_module.to_string())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::AddPath { + channel_id, + denom, + quotas, + } => execute::try_add_path(deps, info.sender, channel_id, denom, quotas, env.block.time), + ExecuteMsg::RemovePath { channel_id, denom } => { + execute::try_remove_path(deps, info.sender, channel_id, denom) + } + ExecuteMsg::ResetPathQuota { + channel_id, + denom, + quota_id, + } => execute::try_reset_path_quota( + deps, + info.sender, + channel_id, + denom, + quota_id, + env.block.time, + ), + } +} + +#[entry_point] +pub fn sudo(deps: DepsMut, env: Env, msg: SudoMsg) -> Result { + match msg { + SudoMsg::SendPacket { + channel_id, + channel_value, + funds, + denom, + } => { + let path = Path::new(&channel_id, &denom); + sudo::try_transfer( + deps, + &path, + channel_value, + funds, + FlowType::Out, + env.block.time, + ) + } + SudoMsg::RecvPacket { + channel_id, + channel_value, + funds, + denom, + } => { + let path = Path::new(&channel_id, &denom); + sudo::try_transfer( + deps, + &path, + channel_value, + funds, + FlowType::In, + env.block.time, + ) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetQuotas { channel_id, denom } => query::get_quotas(deps, channel_id, denom), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + unimplemented!() +} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/contract_tests.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/contract_tests.rs new file mode 100644 index 00000000000..f35d41fc108 --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/contract_tests.rs @@ -0,0 +1,324 @@ +#![cfg(test)] + +use crate::{contract::*, ContractError}; +use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; +use cosmwasm_std::{from_binary, Addr, Attribute}; + +use crate::helpers::tests::verify_query_response; +use crate::msg::{InstantiateMsg, PathMsg, QueryMsg, QuotaMsg, SudoMsg}; +use crate::state::tests::RESET_TIME_WEEKLY; +use crate::state::{RateLimit, GOVMODULE, IBCMODULE}; + +const IBC_ADDR: &str = "IBC_MODULE"; +const GOV_ADDR: &str = "GOV_MODULE"; + +#[test] // Tests we ccan instantiate the contract and that the owners are set correctly +fn proper_instantiation() { + let mut deps = mock_dependencies(); + + let msg = InstantiateMsg { + gov_module: Addr::unchecked(GOV_ADDR), + ibc_module: Addr::unchecked(IBC_ADDR), + paths: vec![], + }; + let info = mock_info(IBC_ADDR, &vec![]); + + // we can just call .unwrap() to assert this was a success + let res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + assert_eq!(0, res.messages.len()); + + // The ibc and gov modules are properly stored + assert_eq!(IBCMODULE.load(deps.as_ref().storage).unwrap(), IBC_ADDR); + assert_eq!(GOVMODULE.load(deps.as_ref().storage).unwrap(), GOV_ADDR); +} + +#[test] // Tests that when a packet is transferred, the peropper allowance is consummed +fn consume_allowance() { + let mut deps = mock_dependencies(); + + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 10, 10); + let msg = InstantiateMsg { + gov_module: Addr::unchecked(GOV_ADDR), + ibc_module: Addr::unchecked(IBC_ADDR), + paths: vec![PathMsg { + channel_id: format!("channel"), + denom: format!("denom"), + quotas: vec![quota], + }], + }; + let info = mock_info(GOV_ADDR, &vec![]); + let _res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 300, + }; + let res = sudo(deps.as_mut(), mock_env(), msg).unwrap(); + + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "300"); + + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 300, + }; + let err = sudo(deps.as_mut(), mock_env(), msg).unwrap_err(); + assert!(matches!(err, ContractError::RateLimitExceded { .. })); +} + +#[test] // Tests that the balance of send and receive is maintained (i.e: recives are sustracted from the send allowance and sends from the receives) +fn symetric_flows_dont_consume_allowance() { + let mut deps = mock_dependencies(); + + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 10, 10); + let msg = InstantiateMsg { + gov_module: Addr::unchecked(GOV_ADDR), + ibc_module: Addr::unchecked(IBC_ADDR), + paths: vec![PathMsg { + channel_id: format!("channel"), + denom: format!("denom"), + quotas: vec![quota], + }], + }; + let info = mock_info(GOV_ADDR, &vec![]); + let _res = instantiate(deps.as_mut(), mock_env(), info.clone(), msg).unwrap(); + + let send_msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 300, + }; + let recv_msg = SudoMsg::RecvPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 300, + }; + + let res = sudo(deps.as_mut(), mock_env(), send_msg.clone()).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "300"); + + let res = sudo(deps.as_mut(), mock_env(), recv_msg.clone()).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "0"); + + // We can still use the path. Even if we have sent more than the + // allowance through the path (900 > 3000*.1), the current "balance" + // of inflow vs outflow is still lower than the path's capacity/quota + let res = sudo(deps.as_mut(), mock_env(), recv_msg.clone()).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "0"); + + let err = sudo(deps.as_mut(), mock_env(), recv_msg.clone()).unwrap_err(); + + assert!(matches!(err, ContractError::RateLimitExceded { .. })); +} + +#[test] // Tests that we can have different quotas for send and receive. In this test we use 4% send and 1% receive +fn asymetric_quotas() { + let mut deps = mock_dependencies(); + + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 4, 1); + let msg = InstantiateMsg { + gov_module: Addr::unchecked(GOV_ADDR), + ibc_module: Addr::unchecked(IBC_ADDR), + paths: vec![PathMsg { + channel_id: format!("channel"), + denom: format!("denom"), + quotas: vec![quota], + }], + }; + let info = mock_info(GOV_ADDR, &vec![]); + let _res = instantiate(deps.as_mut(), mock_env(), info.clone(), msg).unwrap(); + + // Sending 2% + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 60, + }; + let res = sudo(deps.as_mut(), mock_env(), msg).unwrap(); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "60"); + + // Sending 2% more. Allowed, as sending has a 4% allowance + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 60, + }; + + let res = sudo(deps.as_mut(), mock_env(), msg).unwrap(); + println!("{res:?}"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "120"); + + // Receiving 1% should still work. 4% *sent* through the path, but we can still receive. + let recv_msg = SudoMsg::RecvPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 30, + }; + let res = sudo(deps.as_mut(), mock_env(), recv_msg).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "90"); + + // Sending 2%. Should fail. In balance, we've sent 4% and received 1%, so only 1% left to send. + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 60, + }; + let err = sudo(deps.as_mut(), mock_env(), msg.clone()).unwrap_err(); + assert!(matches!(err, ContractError::RateLimitExceded { .. })); + + // Sending 1%: Allowed; because sending has a 4% allowance. We've sent 4% already, but received 1%, so there's send cappacity again + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 30, + }; + let res = sudo(deps.as_mut(), mock_env(), msg.clone()).unwrap(); + let Attribute { key, value } = &res.attributes[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.attributes[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "120"); +} + +#[test] // Tests we can get the current state of the trackers +fn query_state() { + let mut deps = mock_dependencies(); + + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 10, 10); + let msg = InstantiateMsg { + gov_module: Addr::unchecked(GOV_ADDR), + ibc_module: Addr::unchecked(IBC_ADDR), + paths: vec![PathMsg { + channel_id: format!("channel"), + denom: format!("denom"), + quotas: vec![quota], + }], + }; + let info = mock_info(GOV_ADDR, &vec![]); + let env = mock_env(); + let _res = instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + + let query_msg = QueryMsg::GetQuotas { + channel_id: format!("channel"), + denom: format!("denom"), + }; + + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_binary(&res).unwrap(); + assert_eq!(value[0].quota.name, "weekly"); + assert_eq!(value[0].quota.max_percentage_send, 10); + assert_eq!(value[0].quota.max_percentage_recv, 10); + assert_eq!(value[0].quota.duration, RESET_TIME_WEEKLY); + assert_eq!(value[0].flow.inflow, 0); + assert_eq!(value[0].flow.outflow, 0); + assert_eq!( + value[0].flow.period_end, + env.block.time.plus_seconds(RESET_TIME_WEEKLY) + ); + + let send_msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 300, + }; + sudo(deps.as_mut(), mock_env(), send_msg.clone()).unwrap(); + + let recv_msg = SudoMsg::RecvPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 30, + }; + sudo(deps.as_mut(), mock_env(), recv_msg.clone()).unwrap(); + + // Query + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_binary(&res).unwrap(); + verify_query_response( + &value[0], + "weekly", + (10, 10), + RESET_TIME_WEEKLY, + 30, + 300, + env.block.time.plus_seconds(RESET_TIME_WEEKLY), + ); +} + +#[test] // Tests quota percentages are between [0,100] +fn bad_quotas() { + let mut deps = mock_dependencies(); + + let msg = InstantiateMsg { + gov_module: Addr::unchecked(GOV_ADDR), + ibc_module: Addr::unchecked(IBC_ADDR), + paths: vec![PathMsg { + channel_id: format!("channel"), + denom: format!("denom"), + quotas: vec![QuotaMsg { + name: "bad_quota".to_string(), + duration: 200, + send_recv: (5000, 101), + }], + }], + }; + let info = mock_info(IBC_ADDR, &vec![]); + + let env = mock_env(); + instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // If a quota is higher than 100%, we set it to 100% + let query_msg = QueryMsg::GetQuotas { + channel_id: format!("channel"), + denom: format!("denom"), + }; + let res = query(deps.as_ref(), env.clone(), query_msg).unwrap(); + let value: Vec = from_binary(&res).unwrap(); + verify_query_response( + &value[0], + "bad_quota", + (100, 100), + 200, + 0, + 0, + env.block.time.plus_seconds(200), + ); +} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/error.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/error.rs new file mode 100644 index 00000000000..dc40f708d1c --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/error.rs @@ -0,0 +1,25 @@ +use cosmwasm_std::{StdError, Timestamp}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("IBC Rate Limit exceded for channel {channel:?} and denom {denom:?}. Try again after {reset:?}")] + RateLimitExceded { + channel: String, + denom: String, + reset: Timestamp, + }, + + #[error("Quota {quota_id} not found for channel {channel_id}")] + QuotaNotFound { + quota_id: String, + channel_id: String, + denom: String, + }, +} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/execute.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/execute.rs new file mode 100644 index 00000000000..4bac4c0d37f --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/execute.rs @@ -0,0 +1,249 @@ +use crate::msg::{PathMsg, QuotaMsg}; +use crate::state::{Flow, Path, RateLimit, GOVMODULE, IBCMODULE, RATE_LIMIT_TRACKERS}; +use crate::ContractError; +use cosmwasm_std::{Addr, DepsMut, Response, Timestamp}; + +pub fn add_new_paths( + deps: DepsMut, + path_msgs: Vec, + now: Timestamp, +) -> Result<(), ContractError> { + for path_msg in path_msgs { + let path = Path::new(path_msg.channel_id, path_msg.denom); + + RATE_LIMIT_TRACKERS.save( + deps.storage, + path.into(), + &path_msg + .quotas + .iter() + .map(|q| RateLimit { + quota: q.into(), + flow: Flow::new(0_u128, 0_u128, now, q.duration), + }) + .collect(), + )? + } + Ok(()) +} + +pub fn try_add_path( + deps: DepsMut, + sender: Addr, + channel_id: String, + denom: String, + quotas: Vec, + now: Timestamp, +) -> Result { + // codenit: should we make a function for checking this authorization? + let ibc_module = IBCMODULE.load(deps.storage)?; + let gov_module = GOVMODULE.load(deps.storage)?; + if sender != ibc_module && sender != gov_module { + return Err(ContractError::Unauthorized {}); + } + add_new_paths(deps, vec![PathMsg::new(&channel_id, &denom, quotas)], now)?; + + Ok(Response::new() + .add_attribute("method", "try_add_channel") + .add_attribute("channel_id", channel_id) + .add_attribute("denom", denom)) +} + +pub fn try_remove_path( + deps: DepsMut, + sender: Addr, + channel_id: String, + denom: String, +) -> Result { + let ibc_module = IBCMODULE.load(deps.storage)?; + let gov_module = GOVMODULE.load(deps.storage)?; + if sender != ibc_module && sender != gov_module { + return Err(ContractError::Unauthorized {}); + } + + let path = Path::new(&channel_id, &denom); + RATE_LIMIT_TRACKERS.remove(deps.storage, path.into()); + Ok(Response::new() + .add_attribute("method", "try_remove_channel") + .add_attribute("denom", denom) + .add_attribute("channel_id", channel_id)) +} + +// Reset specified quote_id for the given channel_id +pub fn try_reset_path_quota( + deps: DepsMut, + sender: Addr, + channel_id: String, + denom: String, + quota_id: String, + now: Timestamp, +) -> Result { + let gov_module = GOVMODULE.load(deps.storage)?; + if sender != gov_module { + return Err(ContractError::Unauthorized {}); + } + + let path = Path::new(&channel_id, &denom); + RATE_LIMIT_TRACKERS.update(deps.storage, path.into(), |maybe_rate_limit| { + match maybe_rate_limit { + None => Err(ContractError::QuotaNotFound { + quota_id, + channel_id: channel_id.clone(), + denom: denom.clone(), + }), + Some(mut limits) => { + // Q: What happens here if quote_id not found? seems like we return ok? + limits.iter_mut().for_each(|limit| { + if limit.quota.name == quota_id.as_ref() { + limit.flow.expire(now, limit.quota.duration) + } + }); + Ok(limits) + } + } + })?; + + Ok(Response::new() + .add_attribute("method", "try_reset_channel") + .add_attribute("channel_id", channel_id)) +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{from_binary, Addr, StdError}; + + use crate::contract::{execute, query}; + use crate::helpers::tests::verify_query_response; + use crate::msg::{ExecuteMsg, QueryMsg, QuotaMsg}; + use crate::state::{RateLimit, GOVMODULE, IBCMODULE}; + + const IBC_ADDR: &str = "IBC_MODULE"; + const GOV_ADDR: &str = "GOV_MODULE"; + + #[test] // Tests AddPath and RemovePath messages + fn management_add_and_remove_path() { + let mut deps = mock_dependencies(); + IBCMODULE + .save(deps.as_mut().storage, &Addr::unchecked(IBC_ADDR)) + .unwrap(); + GOVMODULE + .save(deps.as_mut().storage, &Addr::unchecked(GOV_ADDR)) + .unwrap(); + + let msg = ExecuteMsg::AddPath { + channel_id: format!("channel"), + denom: format!("denom"), + quotas: vec![QuotaMsg { + name: "daily".to_string(), + duration: 1600, + send_recv: (3, 5), + }], + }; + let info = mock_info(IBC_ADDR, &vec![]); + + let env = mock_env(); + let res = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + assert_eq!(0, res.messages.len()); + + let query_msg = QueryMsg::GetQuotas { + channel_id: format!("channel"), + denom: format!("denom"), + }; + + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + + let value: Vec = from_binary(&res).unwrap(); + verify_query_response( + &value[0], + "daily", + (3, 5), + 1600, + 0, + 0, + env.block.time.plus_seconds(1600), + ); + + assert_eq!(value.len(), 1); + + // Add another path + let msg = ExecuteMsg::AddPath { + channel_id: format!("channel2"), + denom: format!("denom"), + quotas: vec![QuotaMsg { + name: "daily".to_string(), + duration: 1600, + send_recv: (3, 5), + }], + }; + let info = mock_info(IBC_ADDR, &vec![]); + + let env = mock_env(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // remove the first one + let msg = ExecuteMsg::RemovePath { + channel_id: format!("channel"), + denom: format!("denom"), + }; + + let info = mock_info(IBC_ADDR, &vec![]); + let env = mock_env(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // The channel is not there anymore + let err = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap_err(); + assert!(matches!(err, StdError::NotFound { .. })); + + // The second channel is still there + let query_msg = QueryMsg::GetQuotas { + channel_id: format!("channel2"), + denom: format!("denom"), + }; + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_binary(&res).unwrap(); + assert_eq!(value.len(), 1); + verify_query_response( + &value[0], + "daily", + (3, 5), + 1600, + 0, + 0, + env.block.time.plus_seconds(1600), + ); + + // Paths are overriden if they share a name and denom + let msg = ExecuteMsg::AddPath { + channel_id: format!("channel2"), + denom: format!("denom"), + quotas: vec![QuotaMsg { + name: "different".to_string(), + duration: 5000, + send_recv: (50, 30), + }], + }; + let info = mock_info(IBC_ADDR, &vec![]); + + let env = mock_env(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + let query_msg = QueryMsg::GetQuotas { + channel_id: format!("channel2"), + denom: format!("denom"), + }; + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_binary(&res).unwrap(); + assert_eq!(value.len(), 1); + + verify_query_response( + &value[0], + "different", + (50, 30), + 5000, + 0, + 0, + env.block.time.plus_seconds(5000), + ); + } +} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/helpers.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/helpers.rs new file mode 100644 index 00000000000..6cfd60a65a8 --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/helpers.rs @@ -0,0 +1,61 @@ +#![cfg(test)] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::{to_binary, Addr, CosmosMsg, StdResult, WasmMsg}; + +use crate::msg::ExecuteMsg; +use crate::msg::SudoMsg; + +/// CwTemplateContract is a wrapper around Addr that provides a lot of helpers +/// for working with this. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct RateLimitingContract(pub Addr); + +impl RateLimitingContract { + pub fn addr(&self) -> Addr { + self.0.clone() + } + + pub fn call>(&self, msg: T) -> StdResult { + let msg = to_binary(&msg.into())?; + Ok(WasmMsg::Execute { + contract_addr: self.addr().into(), + msg, + funds: vec![], + } + .into()) + } + + pub fn sudo>(&self, msg: T) -> cw_multi_test::SudoMsg { + let msg = to_binary(&msg.into()).unwrap(); + cw_multi_test::SudoMsg::Wasm(cw_multi_test::WasmSudo { + contract_addr: self.addr().into(), + msg, + }) + } +} + +pub mod tests { + use cosmwasm_std::Timestamp; + + use crate::state::RateLimit; + + pub fn verify_query_response( + value: &RateLimit, + quota_name: &str, + send_recv: (u32, u32), + duration: u64, + inflow: u128, + outflow: u128, + period_end: Timestamp, + ) { + assert_eq!(value.quota.name, quota_name); + assert_eq!(value.quota.max_percentage_send, send_recv.0); + assert_eq!(value.quota.max_percentage_recv, send_recv.1); + assert_eq!(value.quota.duration, duration); + assert_eq!(value.flow.inflow, inflow); + assert_eq!(value.flow.outflow, outflow); + assert_eq!(value.flow.period_end, period_end); + } +} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/integration_tests.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/integration_tests.rs new file mode 100644 index 00000000000..8807028fcb9 --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/integration_tests.rs @@ -0,0 +1,405 @@ +#![cfg(test)] +use crate::{helpers::RateLimitingContract, msg::ExecuteMsg}; +use cosmwasm_std::{Addr, Coin, Empty, Uint128}; +use cw_multi_test::{App, AppBuilder, Contract, ContractWrapper, Executor}; + +use crate::{ + msg::{InstantiateMsg, PathMsg, QuotaMsg, SudoMsg}, + state::tests::{RESET_TIME_DAILY, RESET_TIME_MONTHLY, RESET_TIME_WEEKLY}, +}; + +pub fn contract_template() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_sudo(crate::contract::sudo); + Box::new(contract) +} + +const USER: &str = "USER"; +const IBC_ADDR: &str = "IBC_MODULE"; +const GOV_ADDR: &str = "GOV_MODULE"; +const NATIVE_DENOM: &str = "nosmo"; + +fn mock_app() -> App { + AppBuilder::new().build(|router, _, storage| { + router + .bank + .init_balance( + storage, + &Addr::unchecked(USER), + vec![Coin { + denom: NATIVE_DENOM.to_string(), + amount: Uint128::new(1_000), + }], + ) + .unwrap(); + }) +} + +// Instantiate the contract +fn proper_instantiate(paths: Vec) -> (App, RateLimitingContract) { + let mut app = mock_app(); + let cw_template_id = app.store_code(contract_template()); + + let msg = InstantiateMsg { + gov_module: Addr::unchecked(GOV_ADDR), + ibc_module: Addr::unchecked(IBC_ADDR), + paths, + }; + + let cw_rate_limit_contract_addr = app + .instantiate_contract( + cw_template_id, + Addr::unchecked(GOV_ADDR), + &msg, + &[], + "test", + None, + ) + .unwrap(); + + let cw_rate_limit_contract = RateLimitingContract(cw_rate_limit_contract_addr); + + (app, cw_rate_limit_contract) +} + +use cosmwasm_std::Attribute; + +#[test] // Checks that the RateLimit flows are expired properly when time passes +fn expiration() { + let quota = QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 10, 10); + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: format!("channel"), + denom: format!("denom"), + quotas: vec![quota], + }]); + + // Using all the allowance + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 300, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + let res = app.sudo(cosmos_msg).unwrap(); + + let Attribute { key, value } = &res.custom_attrs(1)[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.custom_attrs(1)[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.custom_attrs(1)[5]; + assert_eq!(key, "weekly_max_in"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.custom_attrs(1)[6]; + assert_eq!(key, "weekly_max_out"); + assert_eq!(value, "300"); + + // Another packet is rate limited + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 300, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + let _err = app.sudo(cosmos_msg).unwrap_err(); + + // TODO: how do we check the error type here? + + // ... Time passes + app.update_block(|b| { + b.height += 1000; + b.time = b.time.plus_seconds(RESET_TIME_WEEKLY + 1) + }); + + // Sending the packet should work now + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 300, + }; + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + let res = app.sudo(cosmos_msg).unwrap(); + + let Attribute { key, value } = &res.custom_attrs(1)[3]; + assert_eq!(key, "weekly_used_in"); + assert_eq!(value, "0"); + let Attribute { key, value } = &res.custom_attrs(1)[4]; + assert_eq!(key, "weekly_used_out"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.custom_attrs(1)[5]; + assert_eq!(key, "weekly_max_in"); + assert_eq!(value, "300"); + let Attribute { key, value } = &res.custom_attrs(1)[6]; + assert_eq!(key, "weekly_max_out"); + assert_eq!(value, "300"); +} + +#[test] // Tests we can have different maximums for different quotaas (daily, weekly, etc) and that they all are active at the same time +fn multiple_quotas() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 1, 1), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + QuotaMsg::new("monthly", RESET_TIME_MONTHLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: format!("channel"), + denom: format!("denom"), + quotas, + }]); + + // Sending 1% to use the daily allowance + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 1, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + + // Another packet is rate limited + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 1, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // ... One day passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_DAILY + 1) + }); + + // Sending the packet should work now + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 1, + }; + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + + // Do that for 4 more days + for _ in 1..4 { + // ... One day passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_DAILY + 1) + }); + + // Sending the packet should work now + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 1, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + } + + // ... One day passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_DAILY + 1) + }); + + // We now have exceeded the weekly limit! Even if the daily limit allows us, the weekly doesn't + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 1, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // ... One week passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_WEEKLY + 1) + }); + + // We can still can't send because the weekly and monthly limits are the same + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 1, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // Waiting a week again, doesn't help!! + // ... One week passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_WEEKLY + 1) + }); + + // We can still can't send because the monthly limit hasn't passed + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 1, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // Only after two more weeks we can send again + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds((RESET_TIME_WEEKLY * 2) + 1) // Two weeks + }); + + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 1, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); +} + +#[test] // Tests that the channel value is based on the value at the beginning of the period +fn channel_value_cached() { + let quotas = vec![ + QuotaMsg::new("daily", RESET_TIME_DAILY, 2, 2), + QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 5, 5), + ]; + + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![PathMsg { + channel_id: format!("channel"), + denom: format!("denom"), + quotas, + }]); + + // Sending 1% (half of the daily allowance) + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 1, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + + // Sending 3% is now rate limited + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100, + funds: 3, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // Even if the channel value increases, the percentage is calculated based on the value at period start + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 100000, + funds: 3, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // ... One day passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_DAILY + 1) + }); + + // New Channel Value world! + + // Sending 1% of a new value (10_000) passes the daily check, cause it + // has expired, but not the weekly check (The value for last week is + // sitll 100, as only 1 day has passed) + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 10_000, + funds: 100, + }; + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); + + // ... One week passes + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(RESET_TIME_WEEKLY + 1) + }); + + // Sending 1% of a new value should work and set the value for the day at 10_000 + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 10_000, + funds: 100, + }; + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); + + // If the value magically decreasses. We can still send up to 100 more (1% of 10k) + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 1, + funds: 75, + }; + + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap(); +} + +#[test] // Checks that RateLimits added after instantiation are respected +fn add_paths_later() { + let (mut app, cw_rate_limit_contract) = proper_instantiate(vec![]); + + // All sends are allowed + let msg = SudoMsg::SendPacket { + channel_id: format!("channel"), + denom: format!("denom"), + channel_value: 3_000, + funds: 300, + }; + let cosmos_msg = cw_rate_limit_contract.sudo(msg.clone()); + let res = app.sudo(cosmos_msg).unwrap(); + let Attribute { key, value } = &res.custom_attrs(1)[3]; + assert_eq!(key, "quota"); + assert_eq!(value, "none"); + + // Add a weekly limit of 1% + let management_msg = ExecuteMsg::AddPath { + channel_id: format!("channel"), + denom: format!("denom"), + quotas: vec![QuotaMsg::new("weekly", RESET_TIME_WEEKLY, 1, 1)], + }; + + let cosmos_msg = cw_rate_limit_contract.call(management_msg).unwrap(); + app.execute(Addr::unchecked(GOV_ADDR), cosmos_msg).unwrap(); + + // Executing the same message again should fail, as it is now rate limited + let cosmos_msg = cw_rate_limit_contract.sudo(msg); + app.sudo(cosmos_msg).unwrap_err(); +} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/lib.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/lib.rs new file mode 100644 index 00000000000..0b7ddb6b66f --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/lib.rs @@ -0,0 +1,17 @@ +// Contract +pub mod contract; +mod error; +pub mod msg; +mod state; + +// Functions +mod execute; +mod query; +mod sudo; + +// Tests +mod contract_tests; +mod helpers; +mod integration_tests; + +pub use crate::error::ContractError; diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/management.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/management.rs new file mode 100644 index 00000000000..04dc47df802 --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/management.rs @@ -0,0 +1,250 @@ +use crate::msg::{PathMsg, QuotaMsg}; +use crate::state::{Flow, Path, RateLimit, GOVMODULE, IBCMODULE, RATE_LIMIT_TRACKERS}; +use crate::ContractError; +use cosmwasm_std::{Addr, DepsMut, Response, Timestamp}; + +pub fn add_new_paths( + deps: DepsMut, + path_msgs: Vec, + now: Timestamp, +) -> Result<(), ContractError> { + for path_msg in path_msgs { + let path = Path::new(path_msg.channel_id, path_msg.denom); + + RATE_LIMIT_TRACKERS.save( + deps.storage, + path.into(), + &path_msg + .quotas + .iter() + .map(|q| RateLimit { + quota: q.into(), + flow: Flow::new(0_u128, 0_u128, now, q.duration), + }) + .collect(), + )? + } + Ok(()) +} + +pub fn try_add_path( + deps: DepsMut, + sender: Addr, + channel_id: String, + denom: String, + quotas: Vec, + now: Timestamp, +) -> Result { + // codenit: should we make a function for checking this authorization? + let ibc_module = IBCMODULE.load(deps.storage)?; + let gov_module = GOVMODULE.load(deps.storage)?; + if sender != ibc_module && sender != gov_module { + return Err(ContractError::Unauthorized {}); + } + add_new_paths(deps, vec![PathMsg::new(&channel_id, &denom, quotas)], now)?; + + Ok(Response::new() + .add_attribute("method", "try_add_channel") + .add_attribute("channel_id", channel_id) + .add_attribute("denom", denom)) +} + +pub fn try_remove_path( + deps: DepsMut, + sender: Addr, + channel_id: String, + denom: String, +) -> Result { + let ibc_module = IBCMODULE.load(deps.storage)?; + let gov_module = GOVMODULE.load(deps.storage)?; + if sender != ibc_module && sender != gov_module { + return Err(ContractError::Unauthorized {}); + } + + let path = Path::new(&channel_id, &denom); + RATE_LIMIT_TRACKERS.remove(deps.storage, path.into()); + Ok(Response::new() + .add_attribute("method", "try_remove_channel") + .add_attribute("denom", denom) + .add_attribute("channel_id", channel_id)) +} + +// Reset specified quote_id for the given channel_id +pub fn try_reset_path_quota( + deps: DepsMut, + sender: Addr, + channel_id: String, + denom: String, + quota_id: String, + now: Timestamp, +) -> Result { + let gov_module = GOVMODULE.load(deps.storage)?; + if sender != gov_module { + return Err(ContractError::Unauthorized {}); + } + + let path = Path::new(&channel_id, &denom); + RATE_LIMIT_TRACKERS.update(deps.storage, path.into(), |maybe_rate_limit| { + match maybe_rate_limit { + None => Err(ContractError::QuotaNotFound { + quota_id, + channel_id: channel_id.clone(), + denom: denom.clone(), + }), + Some(mut limits) => { + // Q: What happens here if quote_id not found? seems like we return ok? + limits.iter_mut().for_each(|limit| { + if limit.quota.name == quota_id.as_ref() { + limit.flow.expire(now, limit.quota.duration) + } + }); + Ok(limits) + } + } + })?; + + Ok(Response::new() + .add_attribute("method", "try_reset_channel") + .add_attribute("channel_id", channel_id)) +} + +#[cfg(test)] +mod tests { + + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{from_binary, Addr, StdError}; + + use crate::contract::{execute, query}; + use crate::helpers::tests::verify_query_response; + use crate::msg::{ExecuteMsg, QueryMsg, QuotaMsg}; + use crate::state::{RateLimit, GOVMODULE, IBCMODULE}; + + const IBC_ADDR: &str = "IBC_MODULE"; + const GOV_ADDR: &str = "GOV_MODULE"; + + #[test] // Tests AddPath and RemovePath messages + fn management_add_and_remove_path() { + let mut deps = mock_dependencies(); + IBCMODULE + .save(deps.as_mut().storage, &Addr::unchecked(IBC_ADDR)) + .unwrap(); + GOVMODULE + .save(deps.as_mut().storage, &Addr::unchecked(GOV_ADDR)) + .unwrap(); + + let msg = ExecuteMsg::AddPath { + channel_id: format!("channel"), + denom: format!("denom"), + quotas: vec![QuotaMsg { + name: "daily".to_string(), + duration: 1600, + send_recv: (3, 5), + }], + }; + let info = mock_info(IBC_ADDR, &vec![]); + + let env = mock_env(); + let res = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + assert_eq!(0, res.messages.len()); + + let query_msg = QueryMsg::GetQuotas { + channel_id: format!("channel"), + denom: format!("denom"), + }; + + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + + let value: Vec = from_binary(&res).unwrap(); + verify_query_response( + &value[0], + "daily", + (3, 5), + 1600, + 0, + 0, + env.block.time.plus_seconds(1600), + ); + + assert_eq!(value.len(), 1); + + // Add another path + let msg = ExecuteMsg::AddPath { + channel_id: format!("channel2"), + denom: format!("denom"), + quotas: vec![QuotaMsg { + name: "daily".to_string(), + duration: 1600, + send_recv: (3, 5), + }], + }; + let info = mock_info(IBC_ADDR, &vec![]); + + let env = mock_env(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // remove the first one + let msg = ExecuteMsg::RemovePath { + channel_id: format!("channel"), + denom: format!("denom"), + }; + + let info = mock_info(IBC_ADDR, &vec![]); + let env = mock_env(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // The channel is not there anymore + let err = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap_err(); + assert!(matches!(err, StdError::NotFound { .. })); + + // The second channel is still there + let query_msg = QueryMsg::GetQuotas { + channel_id: format!("channel2"), + denom: format!("denom"), + }; + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_binary(&res).unwrap(); + assert_eq!(value.len(), 1); + verify_query_response( + &value[0], + "daily", + (3, 5), + 1600, + 0, + 0, + env.block.time.plus_seconds(1600), + ); + + // Paths are overriden if they share a name and denom + let msg = ExecuteMsg::AddPath { + channel_id: format!("channel2"), + denom: format!("denom"), + quotas: vec![QuotaMsg { + name: "different".to_string(), + duration: 5000, + send_recv: (50, 30), + }], + }; + let info = mock_info(IBC_ADDR, &vec![]); + + let env = mock_env(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + let query_msg = QueryMsg::GetQuotas { + channel_id: format!("channel2"), + denom: format!("denom"), + }; + let res = query(deps.as_ref(), mock_env(), query_msg.clone()).unwrap(); + let value: Vec = from_binary(&res).unwrap(); + assert_eq!(value.len(), 1); + + verify_query_response( + &value[0], + "different", + (50, 30), + 5000, + 0, + 0, + env.block.time.plus_seconds(5000), + ); + } +} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/msg.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/msg.rs new file mode 100644 index 00000000000..b801537c23a --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/msg.rs @@ -0,0 +1,100 @@ +use cosmwasm_std::Addr; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// PathMsg contains a channel_id and denom to represent a unique identifier within ibc-go, and a list of rate limit quotas +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct PathMsg { + pub channel_id: String, + pub denom: String, + pub quotas: Vec, +} + +impl PathMsg { + pub fn new( + channel: impl Into, + denom: impl Into, + quotas: Vec, + ) -> Self { + PathMsg { + channel_id: channel.into(), + denom: denom.into(), + quotas, + } + } +} + +// QuotaMsg represents a rate limiting Quota when sent as a wasm msg +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct QuotaMsg { + pub name: String, + pub duration: u64, + pub send_recv: (u32, u32), +} + +impl QuotaMsg { + pub fn new(name: &str, seconds: u64, send_percentage: u32, recv_percentage: u32) -> Self { + QuotaMsg { + name: name.to_string(), + duration: seconds, + send_recv: (send_percentage, recv_percentage), + } + } +} + +/// Initialize the contract with the address of the IBC module and any existing channels. +/// Only the ibc module is allowed to execute actions on this contract +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct InstantiateMsg { + pub gov_module: Addr, + pub ibc_module: Addr, + pub paths: Vec, +} + +/// The caller (IBC module) is responsible for correctly calculating the funds +/// being sent through the channel +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + AddPath { + channel_id: String, + denom: String, + quotas: Vec, + }, + RemovePath { + channel_id: String, + denom: String, + }, + ResetPathQuota { + channel_id: String, + denom: String, + quota_id: String, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + GetQuotas { channel_id: String, denom: String }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SudoMsg { + SendPacket { + channel_id: String, + denom: String, + channel_value: u128, + funds: u128, + }, + RecvPacket { + channel_id: String, + denom: String, + channel_value: u128, + funds: u128, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MigrateMsg {} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/query.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/query.rs new file mode 100644 index 00000000000..6431a837d47 --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/query.rs @@ -0,0 +1,12 @@ +use cosmwasm_std::{to_binary, Binary, Deps, StdResult}; + +use crate::state::{Path, RATE_LIMIT_TRACKERS}; + +pub fn get_quotas( + deps: Deps, + channel_id: impl Into, + denom: impl Into, +) -> StdResult { + let path = Path::new(channel_id, denom); + to_binary(&RATE_LIMIT_TRACKERS.load(deps.storage, path.into())?) +} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/state.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/state.rs new file mode 100644 index 00000000000..73dba1d7f72 --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/state.rs @@ -0,0 +1,306 @@ +use cosmwasm_std::{Addr, Timestamp}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::cmp; + +use cw_storage_plus::{Item, Map}; + +use crate::{msg::QuotaMsg, ContractError}; + +/// This represents the key for our rate limiting tracker. A tuple of a denom and +/// a channel. When interactic with storage, it's preffered to use this struct +/// and call path.into() on it to convert it to the composite key of the +/// RATE_LIMIT_TRACKERS map +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Path { + pub denom: String, + pub channel: String, +} + +impl Path { + pub fn new(channel: impl Into, denom: impl Into) -> Self { + Path { + channel: channel.into(), + denom: denom.into(), + } + } +} + +impl From for (String, String) { + fn from(path: Path) -> (String, String) { + (path.channel, path.denom) + } +} + +impl From<&Path> for (String, String) { + fn from(path: &Path) -> (String, String) { + (path.channel.to_owned(), path.denom.to_owned()) + } +} + +#[derive(Debug, Clone)] +pub enum FlowType { + In, + Out, +} + +/// A Flow represents the transfer of value for a denom through an IBC channel +/// during a time window. +/// +/// It tracks inflows (transfers into osmosis) and outflows (transfers out of +/// osmosis). +/// +/// The period_end represents the last point in time for which this Flow is +/// tracking the value transfer. +/// +/// Periods are discrete repeating windows. A period only starts when a contract +/// call to update the Flow (SendPacket/RecvPackt) is made, and not right after +/// the period ends. This means that if no calls happen after a period expires, +/// the next period will begin at the time of the next call and be valid for the +/// specified duration for the quota. +/// +/// This is a design decision to avoid the period calculations and thus reduce gas consumption +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, Copy)] +pub struct Flow { + // Q: Do we have edge case issues with inflow/outflow being u128, e.g. what if a token has super high precision. + pub inflow: u128, + pub outflow: u128, + pub period_end: Timestamp, +} + +impl Flow { + pub fn new( + inflow: impl Into, + outflow: impl Into, + now: Timestamp, + duration: u64, + ) -> Self { + Self { + inflow: inflow.into(), + outflow: outflow.into(), + period_end: now.plus_seconds(duration), + } + } + + /// The balance of a flow is how much absolute value for the denom has moved + /// through the channel before period_end. It returns a tuple of + /// (balance_in, balance_out) where balance_in in is how much has been + /// transferred into the flow, and balance_out is how much value transferred + /// out. + pub fn balance(&self) -> (u128, u128) { + ( + self.inflow.saturating_sub(self.outflow), + self.outflow.saturating_sub(self.inflow), + ) + } + + /// checks if the flow, in the current state, has exceeded a max allowance + pub fn exceeds(&self, direction: &FlowType, max_inflow: u128, max_outflow: u128) -> bool { + let (balance_in, balance_out) = self.balance(); + match direction { + FlowType::In => balance_in > max_inflow, + FlowType::Out => balance_out > max_outflow, + } + } + + /// If now is greater than the period_end, the Flow is considered expired. + pub fn is_expired(&self, now: Timestamp) -> bool { + self.period_end < now + } + + // Mutating methods + + /// Expire resets the Flow to start tracking the value transfer from the + /// moment this method is called. + pub fn expire(&mut self, now: Timestamp, duration: u64) { + self.inflow = 0; + self.outflow = 0; + self.period_end = now.plus_seconds(duration); + } + + /// Updates the current flow with a transfer of value. + pub fn add_flow(&mut self, direction: FlowType, value: u128) { + match direction { + FlowType::In => self.inflow = self.inflow.saturating_add(value), + FlowType::Out => self.outflow = self.outflow.saturating_add(value), + } + } + + /// Applies a transfer. If the Flow is expired (now > period_end), it will + /// reset it before applying the transfer. + fn apply_transfer( + &mut self, + direction: &FlowType, + funds: u128, + now: Timestamp, + quota: &Quota, + ) -> bool { + let mut expired = false; + if self.is_expired(now) { + self.expire(now, quota.duration); + expired = true; + } + self.add_flow(direction.clone(), funds); + expired + } +} + +/// A Quota is the percentage of the denom's total value that can be transferred +/// through the channel in a given period of time (duration) +/// +/// Percentages can be different for send and recv +/// +/// The name of the quota is expected to be a human-readable representation of +/// the duration (i.e.: "weekly", "daily", "every-six-months", ...) +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Quota { + pub name: String, + pub max_percentage_send: u32, + pub max_percentage_recv: u32, + pub duration: u64, + pub channel_value: Option, +} + +impl Quota { + /// Calculates the max capacity (absolute value in the same unit as + /// total_value) in each direction based on the total value of the denom in + /// the channel. The result tuple represents the max capacity when the + /// transfer is in directions: (FlowType::In, FlowType::Out) + pub fn capacity(&self) -> (u128, u128) { + match self.channel_value { + Some(total_value) => ( + total_value * (self.max_percentage_recv as u128) / 100_u128, + total_value * (self.max_percentage_send as u128) / 100_u128, + ), + None => (0, 0), // This should never happen, but ig the channel value is not set, we disallow any transfer + } + } +} + +impl From<&QuotaMsg> for Quota { + fn from(msg: &QuotaMsg) -> Self { + let send_recv = ( + cmp::min(msg.send_recv.0, 100), + cmp::min(msg.send_recv.1, 100), + ); + Quota { + name: msg.name.clone(), + max_percentage_send: send_recv.0, + max_percentage_recv: send_recv.1, + duration: msg.duration, + channel_value: None, + } + } +} + +/// RateLimit is the main structure tracked for each channel/denom pair. Its quota +/// represents rate limit configuration, and the flow its +/// current state (i.e.: how much value has been transfered in the current period) +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct RateLimit { + pub quota: Quota, + pub flow: Flow, +} + +impl RateLimit { + /// Checks if a transfer is allowed and updates the data structures + /// accordingly. + /// + /// If the transfer is not allowed, it will return a RateLimitExceeded error. + /// + /// Otherwise it will return a RateLimitResponse with the updated data structures + pub fn allow_transfer( + &mut self, + path: &Path, + direction: &FlowType, + funds: u128, + channel_value: u128, + now: Timestamp, + ) -> Result { + let expired = self.flow.apply_transfer(direction, funds, now, &self.quota); + // Cache the channel value if it has never been set or it has expired. + if self.quota.channel_value.is_none() || expired { + self.quota.channel_value = Some(channel_value) + } + + let (max_in, max_out) = self.quota.capacity(); + // Return the effects of applying the transfer or an error. + match self.flow.exceeds(direction, max_in, max_out) { + true => Err(ContractError::RateLimitExceded { + channel: path.channel.to_string(), + denom: path.denom.to_string(), + reset: self.flow.period_end, + }), + false => Ok(RateLimit { + quota: self.quota.clone(), // Cloning here because self.quota.name (String) does not allow us to implement Copy + flow: self.flow, // We can Copy flow, so this is slightly more efficient than cloning the whole RateLimit + }), + } + } +} + +/// Only this address can manage the contract. This will likely be the +/// governance module, but could be set to something else if needed +pub const GOVMODULE: Item = Item::new("gov_module"); +/// Only this address can execute transfers. This will likely be the +/// IBC transfer module, but could be set to something else if needed +pub const IBCMODULE: Item = Item::new("ibc_module"); + +/// RATE_LIMIT_TRACKERS is the main state for this contract. It maps a path (IBC +/// Channel + denom) to a vector of `RateLimit`s. +/// +/// The `RateLimit` struct contains the information about how much value of a +/// denom has moved through the channel during the currently active time period +/// (channel_flow.flow) and what percentage of the denom's value we are +/// allowing to flow through that channel in a specific duration (quota) +/// +/// For simplicity, the channel in the map keys refers to the "host" channel on +/// the osmosis side. This means that on PacketSend it will refer to the source +/// channel while on PacketRecv it refers to the destination channel. +/// +/// It is the responsibility of the go module to pass the appropriate channel +/// when sending the messages +/// +/// The map key (String, String) represents (channel_id, denom). We use +/// composite keys instead of a struct to avoid having to implement the +/// PrimaryKey trait +pub const RATE_LIMIT_TRACKERS: Map<(String, String), Vec> = Map::new("flow"); + +#[cfg(test)] +pub mod tests { + use super::*; + + pub const RESET_TIME_DAILY: u64 = 60 * 60 * 24; + pub const RESET_TIME_WEEKLY: u64 = 60 * 60 * 24 * 7; + pub const RESET_TIME_MONTHLY: u64 = 60 * 60 * 24 * 30; + + #[test] + fn flow() { + let epoch = Timestamp::from_seconds(0); + let mut flow = Flow::new(0_u32, 0_u32, epoch, RESET_TIME_WEEKLY); + + assert!(!flow.is_expired(epoch)); + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_DAILY))); + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY))); + assert!(flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY).plus_nanos(1))); + + assert_eq!(flow.balance(), (0_u128, 0_u128)); + flow.add_flow(FlowType::In, 5); + assert_eq!(flow.balance(), (5_u128, 0_u128)); + flow.add_flow(FlowType::Out, 2); + assert_eq!(flow.balance(), (3_u128, 0_u128)); + // Adding flow doesn't affect expiration + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_DAILY))); + + flow.expire(epoch.plus_seconds(RESET_TIME_WEEKLY), RESET_TIME_WEEKLY); + assert_eq!(flow.balance(), (0_u128, 0_u128)); + assert_eq!(flow.inflow, 0_u128); + assert_eq!(flow.outflow, 0_u128); + assert_eq!(flow.period_end, epoch.plus_seconds(RESET_TIME_WEEKLY * 2)); + + // Expiration has moved + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY).plus_nanos(1))); + assert!(!flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY * 2))); + assert!(flow.is_expired(epoch.plus_seconds(RESET_TIME_WEEKLY * 2).plus_nanos(1))); + } +} diff --git a/x/ibc-rate-limit/contracts/rate-limiter/src/sudo.rs b/x/ibc-rate-limit/contracts/rate-limiter/src/sudo.rs new file mode 100644 index 00000000000..8df8398965c --- /dev/null +++ b/x/ibc-rate-limit/contracts/rate-limiter/src/sudo.rs @@ -0,0 +1,95 @@ +use cosmwasm_std::{DepsMut, Response, Timestamp}; + +use crate::{ + state::{FlowType, Path, RateLimit, RATE_LIMIT_TRACKERS}, + ContractError, +}; + +/// This function checks the rate limit and, if successful, stores the updated data about the value +/// that has been transfered through the channel for a specific denom. +/// If the period for a RateLimit has ended, the Flow information is reset. +/// +/// The channel_value is the current value of the denom for the the channel as +/// calculated by the caller. This should be the total supply of a denom +pub fn try_transfer( + deps: DepsMut, + path: &Path, + channel_value: u128, + funds: u128, + direction: FlowType, + now: Timestamp, +) -> Result { + // Sudo call. Only go modules should be allowed to access this + let trackers = RATE_LIMIT_TRACKERS.may_load(deps.storage, path.into())?; + + let configured = match trackers { + None => false, + Some(ref x) if x.is_empty() => false, + _ => true, + }; + + if !configured { + // No Quota configured for the current path. Allowing all messages. + return Ok(Response::new() + .add_attribute("method", "try_transfer") + .add_attribute("channel_id", path.channel.to_string()) + .add_attribute("denom", path.denom.to_string()) + .add_attribute("quota", "none")); + } + + let mut rate_limits = trackers.unwrap(); + + // If any of the RateLimits fails, allow_transfer() will return + // ContractError::RateLimitExceded, which we'll propagate out + let results: Vec = rate_limits + .iter_mut() + .map(|limit| limit.allow_transfer(path, &direction, funds, channel_value, now)) + .collect::>()?; + + RATE_LIMIT_TRACKERS.save(deps.storage, path.into(), &results)?; + + let response = Response::new() + .add_attribute("method", "try_transfer") + .add_attribute("channel_id", path.channel.to_string()) + .add_attribute("denom", path.denom.to_string()); + + // Adds the attributes for each path to the response. In prod, the + // addtribute add_rate_limit_attributes is a noop + results.iter().fold(Ok(response), |acc, result| { + Ok(add_rate_limit_attributes(acc?, result)) + }) +} + +// #[cfg(any(feature = "verbose_responses", test))] +fn add_rate_limit_attributes(response: Response, result: &RateLimit) -> Response { + let (used_in, used_out) = result.flow.balance(); + let (max_in, max_out) = result.quota.capacity(); + // These attributes are only added during testing. That way we avoid + // calculating these again on prod. + response + .add_attribute( + format!("{}_used_in", result.quota.name), + used_in.to_string(), + ) + .add_attribute( + format!("{}_used_out", result.quota.name), + used_out.to_string(), + ) + .add_attribute(format!("{}_max_in", result.quota.name), max_in.to_string()) + .add_attribute( + format!("{}_max_out", result.quota.name), + max_out.to_string(), + ) + .add_attribute( + format!("{}_period_end", result.quota.name), + result.flow.period_end.to_string(), + ) +} + +// Leaving the attributes in until we can conditionally compile the contract +// for the go tests in CI: https://github.com/mandrean/cw-optimizoor/issues/19 +// +// #[cfg(not(any(feature = "verbose_responses", test)))] +// fn add_rate_limit_attributes(response: Response, _result: &RateLimit) -> Response { +// response +// }