diff --git a/contracts/interchain-token-service/src/contract.rs b/contracts/interchain-token-service/src/contract.rs index 4fba02a7d..8168106ff 100644 --- a/contracts/interchain-token-service/src/contract.rs +++ b/contracts/interchain-token-service/src/contract.rs @@ -49,6 +49,8 @@ pub enum Error { QueryTokenConfig, #[error("failed to query the status of contract")] QueryContractStatus, + #[error("failed to query chain configs")] + QueryAllChainConfigs, } #[cfg_attr(not(feature = "library"), entry_point)] @@ -134,6 +136,12 @@ pub fn query(deps: Deps, _: Env, msg: QueryMsg) -> Result QueryMsg::AllItsContracts => { query::all_its_contracts(deps).change_context(Error::QueryAllItsContracts) } + QueryMsg::ItsChains { + filter, + start_after, + limit, + } => query::its_chains(deps, filter, start_after, limit) + .change_context(Error::QueryAllChainConfigs), QueryMsg::TokenInstance { chain, token_id } => { query::token_instance(deps, chain, token_id).change_context(Error::QueryTokenInstance) } diff --git a/contracts/interchain-token-service/src/contract/query.rs b/contracts/interchain-token-service/src/contract/query.rs index 59694d74a..1b3a65219 100644 --- a/contracts/interchain-token-service/src/contract/query.rs +++ b/contracts/interchain-token-service/src/contract/query.rs @@ -1,6 +1,7 @@ use axelar_wasm_std::{killswitch, IntoContractError}; use cosmwasm_std::{to_json_binary, Binary, Deps}; use error_stack::{Result, ResultExt}; +use itertools::Itertools; use router_api::ChainNameRaw; use crate::{msg, state, TokenId}; @@ -34,6 +35,54 @@ pub fn all_its_contracts(deps: Deps) -> Result { to_json_binary(&contract_addresses).change_context(Error::JsonSerialization) } +fn convert_chain_filter_option( + message_chain_filter: Option, +) -> impl Fn(&state::ChainConfig) -> bool + 'static { + move |config| match &message_chain_filter { + None => true, + Some(filter) => convert_chain_filter(filter)(config), + } +} + +fn convert_chain_filter( + message_chain_filter: &msg::ChainFilter, +) -> impl Fn(&state::ChainConfig) -> bool + '_ { + move |config| match &message_chain_filter.status { + Some(status) => match status { + msg::ChainStatusFilter::Frozen => config.frozen, + msg::ChainStatusFilter::Active => !config.frozen, + }, + None => true, + } +} + +pub fn its_chains( + deps: Deps, + filter: Option, + start_after: Option, + limit: u32, +) -> Result { + let filtered_chain_configs: Vec<_> = state::load_chain_configs( + deps.storage, + convert_chain_filter_option(filter), + start_after, + limit, + ) + .map(|r| r.change_context(Error::State)) + .map_ok(|(chain, config)| msg::ChainConfigResponse { + chain, + its_edge_contract: config.its_address, + truncation: msg::TruncationConfig { + max_uint: config.truncation.max_uint, + max_decimals_when_truncating: config.truncation.max_decimals_when_truncating, + }, + frozen: config.frozen, + }) + .try_collect()?; + + to_json_binary(&filtered_chain_configs).change_context(Error::JsonSerialization) +} + pub fn token_instance(deps: Deps, chain: ChainNameRaw, token_id: TokenId) -> Result { let token_instance = state::may_load_token_instance(deps.storage, chain, token_id) .change_context(Error::State)?; diff --git a/contracts/interchain-token-service/src/msg.rs b/contracts/interchain-token-service/src/msg.rs index 2015db409..656d166f1 100644 --- a/contracts/interchain-token-service/src/msg.rs +++ b/contracts/interchain-token-service/src/msg.rs @@ -9,6 +9,13 @@ use router_api::{Address, ChainNameRaw}; use crate::state::{TokenConfig, TokenInstance}; use crate::TokenId; +// Pagination limit +const DEFAULT_LIMIT: u32 = 30; + +const fn default_pagination_limit() -> u32 { + DEFAULT_LIMIT +} + #[cw_serde] pub struct InstantiateMsg { pub governance_address: String, @@ -51,6 +58,17 @@ pub enum ExecuteMsg { EnableExecution, } +#[cw_serde] +pub enum ChainStatusFilter { + Frozen, + Active, +} + +#[cw_serde] +pub struct ChainFilter { + pub status: Option, +} + #[cw_serde] pub struct ChainConfig { pub chain: ChainNameRaw, @@ -83,6 +101,18 @@ pub enum QueryMsg { #[returns(HashMap)] AllItsContracts, + /// Query all chain configs with optional frozen filter + // The list is paginated by: + // - start_after: the chain name to start after, which the next page of results should start. + // - limit: limit the number of chains returned, default is u32::MAX. + #[returns(Vec)] + ItsChains { + filter: Option, + start_after: Option, + #[serde(default = "default_pagination_limit")] + limit: u32, + }, + /// Query a token instance on a specific chain #[returns(Option)] TokenInstance { diff --git a/contracts/interchain-token-service/src/state.rs b/contracts/interchain-token-service/src/state.rs index c6694901f..ee8134e4a 100644 --- a/contracts/interchain-token-service/src/state.rs +++ b/contracts/interchain-token-service/src/state.rs @@ -2,9 +2,10 @@ use std::collections::HashMap; use axelar_wasm_std::{nonempty, FnExt, IntoContractError}; use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Addr, OverflowError, StdError, Storage, Uint256}; -use cw_storage_plus::{Item, Map}; +use cosmwasm_std::{Addr, Order, OverflowError, StdError, Storage, Uint256}; +use cw_storage_plus::{Bound, Item, Map}; use error_stack::{report, Result, ResultExt}; +use itertools::Itertools; use router_api::{Address, ChainNameRaw}; use crate::{msg, RegisterTokenMetadata, TokenId}; @@ -170,6 +171,21 @@ pub fn load_chain_config( .ok_or_else(|| report!(Error::ChainNotFound(chain.to_owned()))) } +pub fn load_chain_configs<'a>( + storage: &'a dyn Storage, + filter: impl Fn(&ChainConfig) -> bool + 'a, + start_after: Option, + limit: u32, +) -> impl Iterator> + 'a { + let start = start_after.as_ref().map(Bound::exclusive); + + CHAIN_CONFIGS + .range(storage, start, None, Order::Ascending) + .map(|r| r.change_context(Error::Storage)) + .filter_ok(move |(_, config)| filter(config)) + .take(limit as usize) +} + pub fn save_chain_config( storage: &mut dyn Storage, chain: &ChainNameRaw, diff --git a/contracts/interchain-token-service/tests/query.rs b/contracts/interchain-token-service/tests/query.rs index 44c85c84d..778695520 100644 --- a/contracts/interchain-token-service/tests/query.rs +++ b/contracts/interchain-token-service/tests/query.rs @@ -1,49 +1,94 @@ use std::collections::HashMap; use assert_ok::assert_ok; -use cosmwasm_std::testing::mock_dependencies; -use cosmwasm_std::{Uint128, Uint256}; -use interchain_token_service::msg::{ChainConfigResponse, TruncationConfig}; +use cosmwasm_std::testing::{mock_dependencies, MockApi, MockQuerier, MockStorage}; +use cosmwasm_std::{Empty, OwnedDeps, Uint256}; +use interchain_token_service::msg::{ + ChainConfigResponse, ChainFilter, ChainStatusFilter, TruncationConfig, +}; use interchain_token_service::TokenId; use router_api::{Address, ChainNameRaw}; mod utils; -#[test] -fn query_chain_config() { - let mut deps = mock_dependencies(); - utils::instantiate_contract(deps.as_mut()).unwrap(); +struct ChainConfigTest { + deps: OwnedDeps, + eth: utils::ChainData, + polygon: utils::ChainData, +} - let chain: ChainNameRaw = "Ethereum".parse().unwrap(); - let address: Address = "0x1234567890123456789012345678901234567890" - .parse() +impl ChainConfigTest { + fn setup() -> Self { + let mut deps = mock_dependencies(); + utils::instantiate_contract(deps.as_mut()).unwrap(); + + let eth = utils::ChainData { + chain: "Ethereum".parse().unwrap(), + address: "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(), + max_uint: Uint256::MAX.try_into().unwrap(), + max_decimals: u8::MAX, + }; + + let polygon = utils::ChainData { + chain: "Polygon".parse().unwrap(), + address: "0x1234567890123456789012345678901234567890" + .parse() + .unwrap(), + max_uint: Uint256::MAX.try_into().unwrap(), + max_decimals: u8::MAX, + }; + + let mut test_config = Self { deps, eth, polygon }; + test_config.register_test_chains(); + test_config + } + + fn register_test_chains(&mut self) { + utils::register_chain( + self.deps.as_mut(), + self.eth.chain.clone(), + self.eth.address.clone(), + self.eth.max_uint, + self.eth.max_decimals, + ) .unwrap(); - utils::register_chain( - deps.as_mut(), - chain.clone(), - address.clone(), - Uint256::MAX.try_into().unwrap(), - u8::MAX, - ) - .unwrap(); + utils::register_chain( + self.deps.as_mut(), + self.polygon.chain.clone(), + self.polygon.address.clone(), + self.polygon.max_uint, + self.polygon.max_decimals, + ) + .unwrap(); + } +} - let original_chain_config = ChainConfigResponse { - chain: chain.clone(), - its_edge_contract: address.clone(), +#[test] +fn query_chain_config() { + let mut test_config = ChainConfigTest::setup(); + + let eth_expected_config_response = ChainConfigResponse { + chain: test_config.eth.chain.clone(), + its_edge_contract: test_config.eth.address.clone(), truncation: TruncationConfig { - max_uint: Uint256::MAX.try_into().unwrap(), - max_decimals_when_truncating: u8::MAX, + max_uint: test_config.eth.max_uint, + max_decimals_when_truncating: test_config.eth.max_decimals, }, frozen: false, }; - let chain_config = assert_ok!(utils::query_its_chain(deps.as_ref(), chain.clone())); - assert_eq!(chain_config.unwrap(), original_chain_config); + let eth_chain_config = assert_ok!(utils::query_its_chain( + test_config.deps.as_ref(), + test_config.eth.chain.clone() + )); + assert_eq!(eth_chain_config.unwrap(), eth_expected_config_response); // case sensitive query let chain_config = assert_ok!(utils::query_its_chain( - deps.as_ref(), + test_config.deps.as_ref(), "ethereum".parse().unwrap() )); assert_eq!(chain_config, None); @@ -52,18 +97,24 @@ fn query_chain_config() { .parse() .unwrap(); assert_ok!(utils::update_chain( - deps.as_mut(), - chain.clone(), + test_config.deps.as_mut(), + test_config.eth.chain.clone(), new_address.clone(), - Uint128::MAX.try_into().unwrap(), + Uint256::MAX.try_into().unwrap(), 18, )); - let chain_config = assert_ok!(utils::query_its_chain(deps.as_ref(), chain.clone())); + let chain_config = assert_ok!(utils::query_its_chain( + test_config.deps.as_ref(), + test_config.eth.chain.clone() + )); assert_eq!(chain_config.unwrap().its_edge_contract, new_address); let non_existent_chain: ChainNameRaw = "non-existent-chain".parse().unwrap(); - let chain_config = assert_ok!(utils::query_its_chain(deps.as_ref(), non_existent_chain)); + let chain_config = assert_ok!(utils::query_its_chain( + test_config.deps.as_ref(), + non_existent_chain + )); assert_eq!(chain_config, None); } @@ -129,3 +180,142 @@ fn query_contract_enable_disable_lifecycle() { let enabled = utils::query_is_contract_enabled(deps.as_ref()).unwrap(); assert!(!enabled); } + +#[test] +fn query_chains_config() { + let mut test_config = ChainConfigTest::setup(); + + // Test all chains + let all_chains = utils::query_its_chains(test_config.deps.as_ref(), None, None, 2).unwrap(); + let expected = vec![ + utils::create_config_response(&test_config.eth, false), + utils::create_config_response(&test_config.polygon, false), + ]; + utils::assert_configs_equal(&all_chains, &expected); + + // Test active chains + let active_chains = utils::query_its_chains( + test_config.deps.as_ref(), + Some(ChainFilter { + status: Some(ChainStatusFilter::Active), + }), + None, + 2, + ) + .unwrap(); + utils::assert_configs_equal(&active_chains, &expected); + + // Test frozen chains (empty) + let frozen_chains = utils::query_its_chains( + test_config.deps.as_ref(), + Some(ChainFilter { + status: Some(ChainStatusFilter::Frozen), + }), + None, + 1, + ) + .unwrap(); + assert!(frozen_chains.is_empty()); + + // Test after freezing eth chain + utils::freeze_chain(test_config.deps.as_mut(), test_config.eth.chain.clone()).unwrap(); + + let frozen_chains = utils::query_its_chains( + test_config.deps.as_ref(), + Some(ChainFilter { + status: Some(ChainStatusFilter::Frozen), + }), + None, + 1, + ) + .unwrap(); + utils::assert_configs_equal( + &frozen_chains, + &[utils::create_config_response(&test_config.eth, true)], + ); + + let active_chains = utils::query_its_chains( + test_config.deps.as_ref(), + Some(ChainFilter { + status: Some(ChainStatusFilter::Active), + }), + None, + 1, + ) + .unwrap(); + utils::assert_configs_equal( + &active_chains, + &[utils::create_config_response(&test_config.polygon, false)], + ); +} + +#[test] +fn query_chains_pagination() { + let mut test_config = ChainConfigTest::setup(); + + let chains = vec![ + ("Chain1", "0x1234567890123456789012345678901234567890"), + ("Chain2", "0x1234567890123456789012345678901234567891"), + ("Chain3", "0x1234567890123456789012345678901234567892"), + ("Chain4", "0x1234567890123456789012345678901234567893"), + ("Chain5", "0x1234567890123456789012345678901234567894"), + ]; + + for (chain_name, address) in chains { + utils::register_chain( + test_config.deps.as_mut(), + chain_name.parse().unwrap(), + address.parse().unwrap(), + test_config.eth.max_uint, + test_config.eth.max_decimals, + ) + .unwrap(); + } + + let first_page = utils::query_its_chains(test_config.deps.as_ref(), None, None, 2).unwrap(); + + assert_eq!(first_page.len(), 2); + let last_chain_name = first_page.last().unwrap().chain.clone(); + + let second_page = + utils::query_its_chains(test_config.deps.as_ref(), None, Some(last_chain_name), 2).unwrap(); + + assert_eq!(second_page.len(), 2); + assert_ne!( + first_page.last().unwrap().chain, + second_page.first().unwrap().chain + ); + + utils::freeze_chain(test_config.deps.as_mut(), "Chain1".parse().unwrap()).unwrap(); + utils::freeze_chain(test_config.deps.as_mut(), "Chain3".parse().unwrap()).unwrap(); + utils::freeze_chain(test_config.deps.as_mut(), "Chain5".parse().unwrap()).unwrap(); + + let frozen_first_page = utils::query_its_chains( + test_config.deps.as_ref(), + Some(ChainFilter { + status: Some(ChainStatusFilter::Frozen), + }), + None, + 2, + ) + .unwrap(); + + assert_eq!(frozen_first_page.len(), 2); + let last_frozen_chain = frozen_first_page.last().unwrap().chain.clone(); + + let frozen_second_page = utils::query_its_chains( + test_config.deps.as_ref(), + Some(ChainFilter { + status: Some(ChainStatusFilter::Frozen), + }), + Some(last_frozen_chain), + 2, + ) + .unwrap(); + + assert_ne!( + frozen_first_page.last().unwrap().chain, + frozen_second_page.first().unwrap().chain + ); + assert_eq!(frozen_second_page.len(), 1); +} diff --git a/contracts/interchain-token-service/tests/utils/execute.rs b/contracts/interchain-token-service/tests/utils/execute.rs index 42d047106..da5954928 100644 --- a/contracts/interchain-token-service/tests/utils/execute.rs +++ b/contracts/interchain-token-service/tests/utils/execute.rs @@ -147,6 +147,15 @@ pub fn update_chains( ) } +pub fn freeze_chain(deps: DepsMut, chain: ChainNameRaw) -> Result { + contract::execute( + deps, + mock_env(), + message_info(&MockApi::default().addr_make(params::GOVERNANCE), &[]), + ExecuteMsg::FreezeChain { chain }, + ) +} + pub fn disable_contract_execution(deps: DepsMut) -> Result { contract::execute( deps, diff --git a/contracts/interchain-token-service/tests/utils/query.rs b/contracts/interchain-token-service/tests/utils/query.rs index c4cff5040..aa283e258 100644 --- a/contracts/interchain-token-service/tests/utils/query.rs +++ b/contracts/interchain-token-service/tests/utils/query.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; use axelar_wasm_std::error::ContractError; +use axelar_wasm_std::nonempty; use cosmwasm_std::testing::mock_env; use cosmwasm_std::{from_json, Deps}; use interchain_token_service::contract::query; -use interchain_token_service::msg::{ChainConfigResponse, QueryMsg}; +use interchain_token_service::msg::{ChainConfigResponse, ChainFilter, QueryMsg, TruncationConfig}; use interchain_token_service::{TokenConfig, TokenId, TokenInstance}; use router_api::{Address, ChainNameRaw}; @@ -48,3 +49,47 @@ pub fn query_is_contract_enabled(deps: Deps) -> Result { let bin = query(deps, mock_env(), QueryMsg::IsEnabled {})?; Ok(from_json(bin)?) } + +pub fn query_its_chains( + deps: Deps, + filter: Option, + start_after: Option, + limit: u32, +) -> Result, ContractError> { + let bin = query( + deps, + mock_env(), + QueryMsg::ItsChains { + filter, + start_after, + limit, + }, + )?; + Ok(from_json(bin)?) +} + +pub struct ChainData { + pub chain: ChainNameRaw, + pub address: Address, + pub max_uint: nonempty::Uint256, + pub max_decimals: u8, +} + +pub fn create_config_response(chain_data: &ChainData, frozen: bool) -> ChainConfigResponse { + ChainConfigResponse { + chain: chain_data.chain.clone(), + its_edge_contract: chain_data.address.clone(), + truncation: TruncationConfig { + max_uint: chain_data.max_uint, + max_decimals_when_truncating: chain_data.max_decimals, + }, + frozen, + } +} + +pub fn assert_configs_equal(actual: &[ChainConfigResponse], expected: &[ChainConfigResponse]) { + assert_eq!(actual.len(), expected.len(), "Different number of configs"); + for (a, e) in actual.iter().zip(expected.iter()) { + assert_eq!(a, e, "Config mismatch for chain {}", e.chain); + } +}