diff --git a/contracts/transmuter/src/contract.rs b/contracts/transmuter/src/contract.rs index 0113c42..e84336d 100644 --- a/contracts/transmuter/src/contract.rs +++ b/contracts/transmuter/src/contract.rs @@ -233,6 +233,7 @@ impl Transmuter { .map(|(denom, weight)| (Scope::denom(&denom).key(), weight)); let asset_group_weights_iter = pool .asset_group_weights()? + .unwrap_or_default() .into_iter() .map(|(label, weight)| (Scope::asset_group(&label).key(), weight)); diff --git a/contracts/transmuter/src/error.rs b/contracts/transmuter/src/error.rs index dbe932b..302ced1 100644 --- a/contracts/transmuter/src/error.rs +++ b/contracts/transmuter/src/error.rs @@ -1,6 +1,6 @@ use cosmwasm_std::{ CheckedFromRatioError, CheckedMultiplyRatioError, Coin, ConversionOverflowError, Decimal, - DivideByZeroError, OverflowError, StdError, Timestamp, Uint128, Uint64, + DecimalRangeExceeded, DivideByZeroError, OverflowError, StdError, Timestamp, Uint128, Uint64, }; use thiserror::Error; @@ -209,6 +209,8 @@ pub enum ContractError { #[error("{0}")] ConversionOverflowError(#[from] ConversionOverflowError), + #[error("{0}")] + DecimalRangeExceeded(#[from] DecimalRangeExceeded), #[error("{0}")] MathError(#[from] MathError), diff --git a/contracts/transmuter/src/limiter.rs b/contracts/transmuter/src/limiter.rs index dcd9041..fd14b89 100644 --- a/contracts/transmuter/src/limiter.rs +++ b/contracts/transmuter/src/limiter.rs @@ -627,7 +627,7 @@ macro_rules! assert_reset_change_limiters_by_scope { .into_iter() .collect::>(); - let asset_group_weights = pool.asset_group_weights().unwrap(); + let asset_group_weights = pool.asset_group_weights().unwrap().unwrap_or_default(); let limiters = $transmuter .limiters diff --git a/contracts/transmuter/src/swap.rs b/contracts/transmuter/src/swap.rs index 4928ae5..83d3c23 100644 --- a/contracts/transmuter/src/swap.rs +++ b/contracts/transmuter/src/swap.rs @@ -1,9 +1,9 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashSet}; use cosmwasm_schema::cw_serde; use cosmwasm_std::{ coin, ensure, ensure_eq, to_json_binary, Addr, BankMsg, Coin, Decimal, Deps, DepsMut, Env, - Response, StdError, Storage, Uint128, + Response, StdError, Storage, Timestamp, Uint128, }; use osmosis_std::types::osmosis::tokenfactory::v1beta1::{MsgBurn, MsgMint}; use serde::Serialize; @@ -13,7 +13,7 @@ use crate::{ contract::Transmuter, corruptable::Corruptable, scope::Scope, - transmuter_pool::{AmountConstraint, AssetGroup, TransmuterPool}, + transmuter_pool::{AmountConstraint, TransmuterPool}, ContractError, }; @@ -55,7 +55,7 @@ impl Transmuter { entrypoint: Entrypoint, constraint: SwapToAlloyedConstraint, mint_to_address: Addr, - deps: DepsMut, + mut deps: DepsMut, env: Env, ) -> Result { let mut pool: TransmuterPool = self.pool.load(deps.storage)?; @@ -126,24 +126,10 @@ impl Transmuter { ContractError::ZeroValueOperation {} ); - let prev_weights = pool.asset_weights()?.unwrap_or_default(); - - pool.join_pool(&tokens_in)?; - - // check and update limiters only if pool assets are not zero - if let Some(updated_weights) = pool.asset_weights()? { - let scope_value_pairs = construct_scope_value_pairs( - prev_weights, - updated_weights, - pool.asset_groups.clone(), - )?; - - self.limiters.check_limits_and_update( - deps.storage, - scope_value_pairs, - env.block.time, - )?; - } + (pool, _) = self.limiters_pass(deps.branch(), env.block.time, pool, |_, mut pool| { + pool.join_pool(&tokens_in)?; + Ok((pool, ())) + })?; // no need for cleaning up drained corrupted assets here // since this function will only adding more underlying assets @@ -171,7 +157,7 @@ impl Transmuter { constraint: SwapFromAlloyedConstraint, burn_target: BurnTarget, sender: Addr, - deps: DepsMut, + mut deps: DepsMut, env: Env, ) -> Result { let mut pool: TransmuterPool = self.pool.load(deps.storage)?; @@ -321,6 +307,7 @@ impl Transmuter { .map(|(denom, weight)| (Scope::denom(&denom).key(), weight)); let asset_group_weights_iter = pool .asset_group_weights()? + .unwrap_or_default() .into_iter() .map(|(label, weight)| (Scope::asset_group(&label).key(), weight)); @@ -330,24 +317,11 @@ impl Transmuter { asset_weights_iter.chain(asset_group_weights_iter), )?; } else { - let prev_weights = pool.asset_weights()?.unwrap_or_default(); - - pool.exit_pool(&tokens_out)?; - - // check and update limiters only if pool assets are not zero - if let Some(updated_weights) = pool.asset_weights()? { - let scope_value_pairs = construct_scope_value_pairs( - prev_weights, - updated_weights, - pool.asset_groups.clone(), - )?; - - self.limiters.check_limits_and_update( - deps.storage, - scope_value_pairs, - env.block.time, - )?; - } + (pool, _) = + self.limiters_pass(deps.branch(), env.block.time, pool, |_, mut pool| { + pool.exit_pool(&tokens_out)?; + Ok((pool, ())) + })?; } self.clean_up_drained_corrupted_assets(deps.storage, &mut pool)?; @@ -381,38 +355,27 @@ impl Transmuter { token_out_denom: &str, token_out_min_amount: Uint128, sender: Addr, - deps: DepsMut, + mut deps: DepsMut, env: Env, ) -> Result { let pool = self.pool.load(deps.storage)?; - let prev_weights = pool.asset_weights()?.unwrap_or_default(); let (mut pool, actual_token_out) = - self.out_amt_given_in(deps.as_ref(), pool, token_in, token_out_denom)?; - - // ensure token_out amount is greater than or equal to token_out_min_amount - ensure!( - actual_token_out.amount >= token_out_min_amount, - ContractError::InsufficientTokenOut { - min_required: token_out_min_amount, - amount_out: actual_token_out.amount - } - ); + self.limiters_pass(deps.branch(), env.block.time, pool, |deps, pool| { + let (pool, actual_token_out) = + self.out_amt_given_in(deps, pool, token_in, token_out_denom)?; - // check and update limiters only if pool assets are not zero - if let Some(updated_weights) = pool.asset_weights()? { - let scope_value_pairs = construct_scope_value_pairs( - prev_weights, - updated_weights, - pool.asset_groups.clone(), - )?; + // ensure token_out amount is greater than or equal to token_out_min_amount + ensure!( + actual_token_out.amount >= token_out_min_amount, + ContractError::InsufficientTokenOut { + min_required: token_out_min_amount, + amount_out: actual_token_out.amount + } + ); - self.limiters.check_limits_and_update( - deps.storage, - scope_value_pairs, - env.block.time, - )?; - } + Ok((pool, actual_token_out)) + })?; self.clean_up_drained_corrupted_assets(deps.storage, &mut pool)?; @@ -439,40 +402,30 @@ impl Transmuter { token_in_max_amount: Uint128, token_out: Coin, sender: Addr, - deps: DepsMut, + mut deps: DepsMut, env: Env, ) -> Result { let pool = self.pool.load(deps.storage)?; - let prev_weights = pool.asset_weights()?.unwrap_or_default(); - let (mut pool, actual_token_in) = self.in_amt_given_out( - deps.as_ref(), - pool, - token_out.clone(), - token_in_denom.to_string(), - )?; + let (mut pool, actual_token_in) = + self.limiters_pass(deps.branch(), env.block.time, pool, |deps, pool| { + let (pool, actual_token_in) = self.in_amt_given_out( + deps, + pool, + token_out.clone(), + token_in_denom.to_string(), + )?; - ensure!( - actual_token_in.amount <= token_in_max_amount, - ContractError::ExcessiveRequiredTokenIn { - limit: token_in_max_amount, - required: actual_token_in.amount, - } - ); + ensure!( + actual_token_in.amount <= token_in_max_amount, + ContractError::ExcessiveRequiredTokenIn { + limit: token_in_max_amount, + required: actual_token_in.amount, + } + ); - // check and update limiters only if pool assets are not zero - if let Some(updated_weights) = pool.asset_weights()? { - let scope_value_pairs = construct_scope_value_pairs( - prev_weights, - updated_weights, - pool.asset_groups.clone(), - )?; - self.limiters.check_limits_and_update( - deps.storage, - scope_value_pairs, - env.block.time, - )?; - } + Ok((pool, actual_token_in)) + })?; self.clean_up_drained_corrupted_assets(deps.storage, &mut pool)?; @@ -615,6 +568,42 @@ impl Transmuter { }) } + pub fn limiters_pass( + &self, + deps: DepsMut, + block_time: Timestamp, + pool: TransmuterPool, + run: F, + ) -> Result<(TransmuterPool, T), ContractError> + where + F: FnOnce(Deps, TransmuterPool) -> Result<(TransmuterPool, T), ContractError>, + { + let prev_asset_weights = pool.asset_weights()?.unwrap_or_default(); + let prev_asset_group_weights = pool.asset_group_weights()?.unwrap_or_default(); + + let (pool, payload) = run(deps.as_ref(), pool)?; + + // check and update limiters only if pool assets are not zero + if let Some(updated_asset_weights) = pool.asset_weights()? { + if let Some(updated_asset_group_weights) = pool.asset_group_weights()? { + let scope_value_pairs = construct_scope_value_pairs( + prev_asset_weights, + updated_asset_weights, + prev_asset_group_weights, + updated_asset_group_weights, + )?; + + self.limiters.check_limits_and_update( + deps.storage, + scope_value_pairs, + block_time, + )?; + } + } + + Ok((pool, payload)) + } + pub fn ensure_valid_swap_fee(&self, swap_fee: Decimal) -> Result<(), ContractError> { // ensure swap fee is the same as one from get_swap_fee which essentially is always 0 // in case where the swap fee mismatch, it can cause the pool to be imbalanced @@ -671,48 +660,51 @@ impl Transmuter { } fn construct_scope_value_pairs( - prev_weights: BTreeMap, - updated_weights: BTreeMap, - asset_group: BTreeMap, + prev_asset_weights: BTreeMap, + updated_asset_weights: BTreeMap, + prev_asset_group_weights: BTreeMap, + updated_asset_group_weights: BTreeMap, ) -> Result, StdError> { - let mut denom_weight_pairs: HashMap = HashMap::new(); - let mut asset_group_weight_pairs: HashMap = HashMap::new(); - - // Reverse index the asset groups - let mut asset_groups_of_denom = HashMap::new(); - for (group, asset_group) in asset_group { - for denom in asset_group.into_denoms() { - asset_groups_of_denom - .entry(denom) - .or_insert_with(Vec::new) - .push(group.clone()); - } + let mut scope_value_pairs: Vec<(Scope, (Decimal, Decimal))> = Vec::new(); + + let denoms = prev_asset_weights + .keys() + .chain(updated_asset_weights.keys()) + .collect::>(); + + let asset_groups = prev_asset_group_weights + .keys() + .chain(updated_asset_group_weights.keys()) + .collect::>(); + + for denom in denoms { + let prev_weight = prev_asset_weights + .get(denom) + .copied() + .unwrap_or(Decimal::zero()); + let updated_weight = updated_asset_weights + .get(denom) + .copied() + .unwrap_or(Decimal::zero()); + scope_value_pairs.push((Scope::denom(denom), (prev_weight, updated_weight))); } - for (denom, weight) in &updated_weights { - let prev_weight = prev_weights.get(denom.as_str()).unwrap_or(weight); - denom_weight_pairs.insert(Scope::denom(denom), (*prev_weight, *weight)); - - for group in asset_groups_of_denom.get(denom).unwrap_or(&vec![]) { - match asset_group_weight_pairs.get_mut(&Scope::asset_group(group)) { - Some((prev, curr)) => { - *prev = prev.checked_add(*prev_weight)?; - *curr = curr.checked_add(*weight)?; - } - None => { - asset_group_weight_pairs - .insert(Scope::asset_group(group), (*prev_weight, *weight)); - } - } - - // TODO: check for invalid cases like total weight is not 1, proptest it - } + for asset_group in asset_groups { + let prev_weight = prev_asset_group_weights + .get(asset_group) + .copied() + .unwrap_or(Decimal::zero()); + let updated_weight = updated_asset_group_weights + .get(asset_group) + .copied() + .unwrap_or(Decimal::zero()); + scope_value_pairs.push(( + Scope::asset_group(asset_group), + (prev_weight, updated_weight), + )); } - Ok(denom_weight_pairs - .into_iter() - .chain(asset_group_weight_pairs.into_iter()) - .collect()) + Ok(scope_value_pairs) } /// Possible variants of swap, depending on the input and output tokens @@ -799,7 +791,9 @@ pub enum BurnTarget { #[cfg(test)] mod tests { - use crate::{asset::Asset, corruptable::Corruptable, limiter::LimiterParams}; + use crate::{ + asset::Asset, corruptable::Corruptable, limiter::LimiterParams, transmuter_pool::AssetGroup, + }; use super::*; use cosmwasm_std::{ @@ -1716,79 +1710,87 @@ mod tests { #[rstest] #[case::empty( - HashMap::from([]), - vec![], + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), vec![], )] - #[case::no_asset_group( - HashMap::from([]), + #[case::no_prev_asset_weights( + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::from([ + ("eth.axl".to_string(), Decimal::percent(40)), + ("eth.wh".to_string(), Decimal::percent(40)), + ("wsteth.axl".to_string(), Decimal::percent(20)), + ]), + BTreeMap::new(), vec![ - ("eth.axl", (Decimal::percent(20), Decimal::percent(40))), - ("eth.wh", (Decimal::percent(60), Decimal::percent(40))), - ("wsteth.axl", (Decimal::percent(20), Decimal::percent(20))), + (Scope::denom("eth.axl"), (Decimal::zero(), Decimal::percent(40))), + (Scope::denom("eth.wh"), (Decimal::zero(), Decimal::percent(40))), + (Scope::denom("wsteth.axl"), (Decimal::zero(), Decimal::percent(20))), ], - vec![], )] - #[case( - HashMap::from([ - ("axelar", vec!["eth.axl", "wsteth.axl"]), - ("wormhole", vec!["eth.wh"]), + #[case::no_updated_asset_weights( + BTreeMap::from([ + ("eth.axl".to_string(), Decimal::percent(20)), + ("eth.wh".to_string(), Decimal::percent(60)), + ("wsteth.axl".to_string(), Decimal::percent(20)), ]), + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), vec![ - ("eth.axl", (Decimal::percent(20), Decimal::percent(40))), - ("wsteth.axl", (Decimal::percent(20), Decimal::percent(20))), - ("eth.wh", (Decimal::percent(60), Decimal::percent(40))), + (Scope::denom("eth.axl"), (Decimal::percent(20), Decimal::zero())), + (Scope::denom("eth.wh"), (Decimal::percent(60), Decimal::zero())), + (Scope::denom("wsteth.axl"), (Decimal::percent(20), Decimal::zero())), ], + )] + #[case( + BTreeMap::from([ + ("eth.axl".to_string(), Decimal::percent(20)), + ("eth.wh".to_string(), Decimal::percent(60)), + ("wsteth.axl".to_string(), Decimal::percent(20)), + ]), + BTreeMap::from([ + ("axelar".to_string(), Decimal::percent(40)), + ("wormhole".to_string(), Decimal::percent(60)), + ]), + BTreeMap::from([ + ("eth.axl".to_string(), Decimal::percent(40)), + ("eth.wh".to_string(), Decimal::percent(40)), + ("wsteth.axl".to_string(), Decimal::percent(20)), + ]), + BTreeMap::from([ + ("axelar".to_string(), Decimal::percent(60)), + ("wormhole".to_string(), Decimal::percent(40)), + ]), vec![ + (Scope::denom("eth.axl"), (Decimal::percent(20), Decimal::percent(40))), + (Scope::denom("eth.wh"), (Decimal::percent(60), Decimal::percent(40))), + (Scope::denom("wsteth.axl"), (Decimal::percent(20), Decimal::percent(20))), (Scope::asset_group("axelar"), (Decimal::percent(40), Decimal::percent(60))), (Scope::asset_group("wormhole"), (Decimal::percent(60), Decimal::percent(40))), ], )] fn test_construct_scope_value_pairs( - #[case] asset_groups: HashMap<&str, Vec<&str>>, - #[case] denom_weights: Vec<(&str, (Decimal, Decimal))>, - #[case] expected_asset_group_scopes: Vec<(Scope, (Decimal, Decimal))>, + #[case] prev_asset_weights: BTreeMap, + #[case] prev_asset_group_weights: BTreeMap, + #[case] updated_asset_weights: BTreeMap, + #[case] updated_asset_group_weights: BTreeMap, + #[case] expected_scope_value_pairs: Vec<(Scope, (Decimal, Decimal))>, ) { - let asset_groups = asset_groups - .into_iter() - .map(|(label, asset_group)| { - ( - label.to_string(), - AssetGroup::new( - asset_group - .into_iter() - .map(|asset| asset.to_string()) - .collect_vec(), - ), - ) - }) - .collect::>(); - - let prev_weights = denom_weights - .clone() - .into_iter() - .map(|(denom, (prev_weight, _))| (denom.to_string(), prev_weight)) - .collect(); - - let updated_weights = denom_weights - .clone() - .into_iter() - .map(|(denom, (_, updated_weight))| (denom.to_string(), updated_weight)) - .collect(); - - let mut scope_value_pairs = - construct_scope_value_pairs(prev_weights, updated_weights, asset_groups).unwrap(); - - let scope_denom_value_pairs = denom_weights - .into_iter() - .map(|(denom, weight_transition)| (Scope::denom(denom), weight_transition)) - .collect_vec(); - - let mut expected_scope_value_pairs = - vec![scope_denom_value_pairs, expected_asset_group_scopes].concat(); + let mut scope_value_pairs = construct_scope_value_pairs( + prev_asset_weights, + updated_asset_weights, + prev_asset_group_weights, + updated_asset_group_weights, + ) + .unwrap(); - // assert by disregrard order + // assert by disregard order scope_value_pairs.sort_by_key(|(scope, _)| scope.key()); + let mut expected_scope_value_pairs = expected_scope_value_pairs; expected_scope_value_pairs.sort_by_key(|(scope, _)| scope.key()); assert_eq!(scope_value_pairs, expected_scope_value_pairs); diff --git a/contracts/transmuter/src/test/cases/scenarios.rs b/contracts/transmuter/src/test/cases/scenarios.rs index 01593ca..7f02ef5 100644 --- a/contracts/transmuter/src/test/cases/scenarios.rs +++ b/contracts/transmuter/src/test/cases/scenarios.rs @@ -1426,3 +1426,190 @@ fn test_register_limiter_after_having_liquidity() { ) .unwrap(); } + +#[test] +fn test_register_limiter_after_having_liquidity_with_asset_group_scope() { + let app = OsmosisTestApp::new(); + let cp = CosmwasmPool::new(&app); + + let admin = app.init_account(&[coin(100_000u128, "uosmo")]).unwrap(); + + let t = TestEnvBuilder::new() + .with_account( + "alice", + vec![ + coin(1_000_000, AXL_DAI), + coin(1_000_000, AXL_USDC), + coin(1_000_000, COSMOS_USDC), + ], + ) + .with_account( + "bob", + vec![ + coin(1_000_000, AXL_DAI), + coin(1_000_000, AXL_USDC), + coin(1_000_000, COSMOS_USDC), + ], + ) + .with_account("admin", vec![]) + .with_account( + "provider", + vec![ + coin(1_000_000, AXL_DAI), + coin(1_000_000, AXL_USDC), + coin(1_000_000, COSMOS_USDC), + ], + ) + .with_instantiate_msg(InstantiateMsg { + pool_asset_configs: vec![ + AssetConfig::from_denom_str(AXL_DAI), + AssetConfig::from_denom_str(AXL_USDC), + AssetConfig::from_denom_str(COSMOS_USDC), + ], + alloyed_asset_subdenom: "usdc".to_string(), + alloyed_asset_normalization_factor: Uint128::one(), + admin: Some(admin.address()), + moderator: "osmo1cyyzpxplxdzkeea7kwsydadg87357qnahakaks".to_string(), + }) + .build(&app); + + // query share denom + let GetShareDenomResponse { share_denom } = + t.contract.query(&QueryMsg::GetShareDenom {}).unwrap(); + + let alloyed_denom = share_denom; + + cp.swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: t.accounts["provider"].address(), + token_in: Some(coin(200_000, AXL_USDC).into()), + routes: vec![SwapAmountInRoute { + pool_id: t.contract.pool_id, + token_out_denom: alloyed_denom.clone(), + }], + token_out_min_amount: Uint128::from(200_000u128).to_string(), + }, + &t.accounts["provider"], + ) + .unwrap(); + + // create axl asset group + t.contract + .execute( + &ExecMsg::CreateAssetGroup { + label: "axl".to_string(), + denoms: vec![AXL_DAI.to_string(), AXL_USDC.to_string()], + }, + &[], + &t.accounts["admin"], + ) + .unwrap(); + + t.contract + .execute( + &ExecMsg::RegisterLimiter { + scope: Scope::asset_group("axl"), + label: "static".to_string(), + limiter_params: LimiterParams::StaticLimiter { + upper_limit: Decimal::percent(60), + }, + }, + &[], + &t.accounts["admin"], + ) + .unwrap(); + + let err = cp + .swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: t.accounts["provider"].address(), + token_in: Some(coin(1_000_000, AXL_DAI).into()), + routes: vec![SwapAmountInRoute { + pool_id: t.contract.pool_id, + token_out_denom: alloyed_denom.clone(), + }], + token_out_min_amount: Uint128::from(1u128).to_string(), + }, + &t.accounts["provider"], + ) + .unwrap_err(); + + assert_contract_err( + ContractError::UpperLimitExceeded { + scope: Scope::asset_group("axl"), + upper_limit: Decimal::from_str("0.6").unwrap(), + value: Decimal::from_str("1").unwrap(), + }, + err, + ); + + // Swap COSMOS USDC to alloyed asset should be successful + cp.swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: t.accounts["provider"].address(), + token_in: Some(coin(1, COSMOS_USDC).into()), + routes: vec![SwapAmountInRoute { + pool_id: t.contract.pool_id, + token_out_denom: alloyed_denom.clone(), + }], + token_out_min_amount: Uint128::from(1u128).to_string(), + }, + &t.accounts["provider"], + ) + .unwrap(); + + // Swap alloyed asset to AXL USDC should be successful + cp.swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: t.accounts["provider"].address(), + token_in: Some(coin(1, &alloyed_denom).into()), + routes: vec![SwapAmountInRoute { + pool_id: t.contract.pool_id, + token_out_denom: AXL_USDC.to_string(), + }], + token_out_min_amount: Uint128::from(1u128).to_string(), + }, + &t.accounts["provider"], + ) + .unwrap(); + + // Swap COSMOS USDC to AXL USDC should reduce axl asset group composition + cp.swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: t.accounts["provider"].address(), + token_in: Some(coin(1, COSMOS_USDC).into()), + routes: vec![SwapAmountInRoute { + pool_id: t.contract.pool_id, + token_out_denom: AXL_USDC.to_string(), + }], + token_out_min_amount: Uint128::from(1u128).to_string(), + }, + &t.accounts["provider"], + ) + .unwrap(); + + // Swap the other way around should fail + let err = cp + .swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: t.accounts["provider"].address(), + token_in: Some(coin(1, AXL_USDC).into()), + routes: vec![SwapAmountInRoute { + pool_id: t.contract.pool_id, + token_out_denom: COSMOS_USDC.to_string(), + }], + token_out_min_amount: Uint128::from(1u128).to_string(), + }, + &t.accounts["provider"], + ) + .unwrap_err(); + + assert_contract_err( + ContractError::UpperLimitExceeded { + scope: Scope::asset_group("axl"), + upper_limit: Decimal::from_str("0.6").unwrap(), + value: Decimal::from_str("0.999995").unwrap(), + }, + err, + ); +} diff --git a/contracts/transmuter/src/transmuter_pool/asset_group.rs b/contracts/transmuter/src/transmuter_pool/asset_group.rs index bddb4ff..c7cdddf 100644 --- a/contracts/transmuter/src/transmuter_pool/asset_group.rs +++ b/contracts/transmuter/src/transmuter_pool/asset_group.rs @@ -1,9 +1,11 @@ use std::collections::{BTreeMap, HashSet}; use cosmwasm_schema::cw_serde; -use cosmwasm_std::{ensure, Decimal, Uint64}; +use cosmwasm_std::{ensure, Decimal, Decimal256, Uint128, Uint256, Uint64}; -use crate::{corruptable::Corruptable, transmuter_pool::MAX_ASSET_GROUPS, ContractError}; +use crate::{ + corruptable::Corruptable, math::lcm_from_iter, transmuter_pool::MAX_ASSET_GROUPS, ContractError, +}; use super::TransmuterPool; @@ -144,22 +146,54 @@ impl TransmuterPool { Ok(self) } - pub fn asset_group_weights(&self) -> Result, ContractError> { - let denom_weights = self.asset_weights()?.unwrap_or_default(); + pub fn asset_group_weights(&self) -> Result>, ContractError> { + let std_norm_factor = lcm_from_iter( + self.pool_assets + .iter() + .map(|pool_asset| pool_asset.normalization_factor()), + )?; + + let normalized_asset_values: BTreeMap = self + .normalized_asset_values(std_norm_factor)? + .into_iter() + .collect(); + + let total_normalized_pool_value = normalized_asset_values + .values() + .copied() + .map(Uint256::from) + .try_fold(Uint256::zero(), Uint256::checked_add)?; + + if total_normalized_pool_value.is_zero() { + return Ok(None); + } + let mut weights = BTreeMap::new(); for (label, asset_group) in &self.asset_groups { - let mut group_weight = Decimal::zero(); + let mut group_normalized_value = Uint256::zero(); for denom in &asset_group.denoms { - let denom_weight = denom_weights + let denom_normalized_value = normalized_asset_values .get(denom) .copied() - .unwrap_or_else(Decimal::zero); - group_weight = group_weight.checked_add(denom_weight)?; + .map(Uint256::from) + .unwrap_or_else(Uint256::zero); + + group_normalized_value = + group_normalized_value.checked_add(denom_normalized_value)?; } - weights.insert(label.to_string(), group_weight); + + weights.insert( + label.to_string(), + Decimal256::checked_from_ratio( + group_normalized_value, + total_normalized_pool_value, + )? + // This is safe since weights are always less than 1, downcasting from Decimal256 to Decimal should never fail + .try_into()?, + ); } - Ok(weights) + Ok(Some(weights)) } } @@ -230,7 +264,7 @@ mod tests { .unwrap(); // Test with empty pool - let weights = pool.asset_group_weights().unwrap(); + let weights = pool.asset_group_weights().unwrap().unwrap_or_default(); assert!(weights.is_empty()); pool.create_asset_group( @@ -242,7 +276,7 @@ mod tests { pool.create_asset_group("group2".to_string(), vec!["denom3".to_string()]) .unwrap(); - let weights = pool.asset_group_weights().unwrap(); + let weights = pool.asset_group_weights().unwrap().unwrap(); assert_eq!(weights.len(), 2); assert_eq!( weights.get("group1").unwrap(), @@ -254,6 +288,26 @@ mod tests { ); } + #[test] + fn test_asset_group_weights_with_potential_decimal_precision_loss() { + let mut pool = TransmuterPool::new(vec![ + Asset::new(Uint128::new(100), "denom1", Uint128::new(1)).unwrap(), + Asset::new(Uint128::new(200), "denom2", Uint128::new(1)).unwrap(), + Asset::new(Uint128::new(0), "denom3", Uint128::new(1)).unwrap(), + ]) + .unwrap(); + + pool.create_asset_group( + "group1".to_string(), + vec!["denom1".to_string(), "denom2".to_string()], + ) + .unwrap(); + + let weights = pool.asset_group_weights().unwrap().unwrap_or_default(); + + assert_eq!(weights.get("group1").unwrap(), &Decimal::percent(100)); + } + #[test] fn test_create_asset_group_with_empty_string() { let mut pool = TransmuterPool::new(vec![ diff --git a/contracts/transmuter/src/transmuter_pool/weight.rs b/contracts/transmuter/src/transmuter_pool/weight.rs index 8cb16d2..c742c24 100644 --- a/contracts/transmuter/src/transmuter_pool/weight.rs +++ b/contracts/transmuter/src/transmuter_pool/weight.rs @@ -51,7 +51,7 @@ impl TransmuterPool { Ok(Some(ratios)) } - fn normalized_asset_values( + pub(crate) fn normalized_asset_values( &self, std_norm_factor: Uint128, ) -> Result, ContractError> {