diff --git a/Cargo.lock b/Cargo.lock index b4a46027..f3c90cd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2656,7 +2656,7 @@ dependencies = [ [[package]] name = "mars-address-provider" -version = "2.3.2" +version = "2.4.0" dependencies = [ "bech32 0.11.0", "cosmwasm-schema 1.5.7", @@ -2672,7 +2672,7 @@ dependencies = [ [[package]] name = "mars-credit-manager" -version = "2.3.1" +version = "2.4.0" dependencies = [ "anyhow", "cosmwasm-schema 1.5.7", @@ -2806,6 +2806,17 @@ dependencies = [ "mars-types", ] +[[package]] +name = "mars-mock-governance" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema 1.5.7", + "cosmwasm-std 1.5.7", + "cw-storage-plus 1.2.0", + "mars-types", + "thiserror", +] + [[package]] name = "mars-mock-incentives" version = "2.2.0" @@ -2997,7 +3008,7 @@ dependencies = [ [[package]] name = "mars-perps" -version = "2.3.0" +version = "2.4.0" dependencies = [ "anyhow", "cosmwasm-schema 1.5.7", @@ -3256,6 +3267,7 @@ dependencies = [ "mars-credit-manager", "mars-incentives", "mars-mock-astroport-incentives", + "mars-mock-governance", "mars-mock-incentives", "mars-mock-lst-oracle", "mars-mock-oracle", diff --git a/Cargo.toml b/Cargo.toml index 2a0b5897..993fa0ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "contracts/mock-pyth", "contracts/mock-red-bank", "contracts/mock-vault", + "contracts/mock-governance", "contracts/mock-lst-oracle", # packages @@ -153,6 +154,7 @@ mars-mock-red-bank = { path = "./contracts/mock-red-bank" } mars-mock-vault = { path = "./contracts/mock-vault" } mars-mock-lst-oracle = { path = "./contracts/mock-lst-oracle" } mars-mock-rover-health = { path = "./contracts/mock-health" } +mars-mock-governance = { path = "./contracts/mock-governance" } mars-swapper-mock = { path = "./contracts/swapper/mock" } mars-zapper-mock = { path = "./contracts/v2-zapper/mock" } diff --git a/contracts/account-nft/tests/tests/helpers/mock_env_builder.rs b/contracts/account-nft/tests/tests/helpers/mock_env_builder.rs index 23d87265..054b98b1 100644 --- a/contracts/account-nft/tests/tests/helpers/mock_env_builder.rs +++ b/contracts/account-nft/tests/tests/helpers/mock_env_builder.rs @@ -161,6 +161,7 @@ impl MockEnvBuilder { perps: "n/a".to_string(), keeper_fee_config: Default::default(), perps_liquidation_bonus_ratio: Decimal::percent(60), + governance: "n/a".to_string(), }, }, &[], diff --git a/contracts/address-provider/Cargo.toml b/contracts/address-provider/Cargo.toml index 32b624d6..8cfbb3b3 100644 --- a/contracts/address-provider/Cargo.toml +++ b/contracts/address-provider/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "mars-address-provider" description = "A smart contract that holds addresses of Mars Red Bank contracts" -version = "2.3.2" +version = "2.4.0" authors = { workspace = true } edition = { workspace = true } license = { workspace = true } diff --git a/contracts/address-provider/src/contract.rs b/contracts/address-provider/src/contract.rs index 70fc79f6..b655b9d6 100644 --- a/contracts/address-provider/src/contract.rs +++ b/contracts/address-provider/src/contract.rs @@ -176,5 +176,6 @@ pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result migrations::v2_2_2::migrate(deps), MigrateMsg::V2_2_2ToV2_3_2 {} => migrations::v2_3_2::migrate(deps), + MigrateMsg::V2_3_2ToV2_4_0 {} => migrations::v2_4_0::migrate(deps), } } diff --git a/contracts/address-provider/src/migrations/mod.rs b/contracts/address-provider/src/migrations/mod.rs index e7bee920..a4ad49c9 100644 --- a/contracts/address-provider/src/migrations/mod.rs +++ b/contracts/address-provider/src/migrations/mod.rs @@ -1,2 +1,3 @@ pub mod v2_2_2; pub mod v2_3_2; +pub mod v2_4_0; diff --git a/contracts/address-provider/src/migrations/v2_4_0.rs b/contracts/address-provider/src/migrations/v2_4_0.rs new file mode 100644 index 00000000..62369466 --- /dev/null +++ b/contracts/address-provider/src/migrations/v2_4_0.rs @@ -0,0 +1,22 @@ +use cosmwasm_std::{DepsMut, Response}; +use cw2::{assert_contract_version, set_contract_version}; + +use crate::{ + contract::{CONTRACT_NAME, CONTRACT_VERSION}, + error::ContractError, +}; + +pub const FROM_VERSION: &str = "2.3.2"; + +pub fn migrate(deps: DepsMut) -> Result { + // make sure we're migrating the correct contract and from the correct version + assert_contract_version(deps.storage, &format!("crates.io:{CONTRACT_NAME}"), FROM_VERSION)?; + + // this is a standard migration with no state changes + set_contract_version(deps.storage, format!("crates.io:{CONTRACT_NAME}"), CONTRACT_VERSION)?; + + Ok(Response::new() + .add_attribute("action", "migrate") + .add_attribute("from_version", FROM_VERSION) + .add_attribute("to_version", CONTRACT_VERSION)) +} diff --git a/contracts/address-provider/tests/tests/mod.rs b/contracts/address-provider/tests/tests/mod.rs index edb731f6..619c3141 100644 --- a/contracts/address-provider/tests/tests/mod.rs +++ b/contracts/address-provider/tests/tests/mod.rs @@ -4,4 +4,5 @@ mod test_addresses; mod test_instantiate; mod test_migration_v2; mod test_migration_v2_3_2; +mod test_migration_v2_4_0; mod test_update_owner; diff --git a/contracts/address-provider/tests/tests/test_migration_v2_4_0.rs b/contracts/address-provider/tests/tests/test_migration_v2_4_0.rs new file mode 100644 index 00000000..eb031b85 --- /dev/null +++ b/contracts/address-provider/tests/tests/test_migration_v2_4_0.rs @@ -0,0 +1,73 @@ +use cosmwasm_std::{attr, testing::mock_env, Event}; +use cw2::{ContractVersion, VersionError}; +use mars_address_provider::{ + contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}, + error::ContractError, + migrations::v2_4_0::FROM_VERSION, +}; +use mars_testing::mock_dependencies; +use mars_types::address_provider::MigrateMsg; + +#[test] +fn wrong_contract_name() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version(deps.as_mut().storage, "contract_xyz", FROM_VERSION).unwrap(); + + let err = migrate(deps.as_mut(), mock_env(), MigrateMsg::V2_3_2ToV2_4_0 {}).unwrap_err(); + + assert_eq!( + err, + ContractError::Version(VersionError::WrongContract { + expected: format!("crates.io:{CONTRACT_NAME}"), + found: "contract_xyz".to_string() + }) + ); +} + +#[test] +fn wrong_contract_version() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version(deps.as_mut().storage, format!("crates.io:{CONTRACT_NAME}"), "4.1.0") + .unwrap(); + + let err = migrate(deps.as_mut(), mock_env(), MigrateMsg::V2_3_2ToV2_4_0 {}).unwrap_err(); + + assert_eq!( + err, + ContractError::Version(VersionError::WrongVersion { + expected: FROM_VERSION.to_string(), + found: "4.1.0".to_string() + }) + ); +} + +#[test] +fn successful_migration() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version( + deps.as_mut().storage, + format!("crates.io:{CONTRACT_NAME}"), + FROM_VERSION, + ) + .unwrap(); + + let res = migrate(deps.as_mut(), mock_env(), MigrateMsg::V2_3_2ToV2_4_0 {}).unwrap(); + + assert_eq!(res.messages, vec![]); + assert_eq!(res.events, vec![] as Vec); + assert!(res.data.is_none()); + assert_eq!( + res.attributes, + vec![ + attr("action", "migrate"), + attr("from_version", FROM_VERSION), + attr("to_version", CONTRACT_VERSION) + ] + ); + + let new_contract_version = ContractVersion { + contract: format!("crates.io:{CONTRACT_NAME}"), + version: CONTRACT_VERSION.to_string(), + }; + assert_eq!(cw2::get_contract_version(deps.as_ref().storage).unwrap(), new_contract_version); +} diff --git a/contracts/credit-manager/Cargo.toml b/contracts/credit-manager/Cargo.toml index cabfc4ba..2bb1497c 100644 --- a/contracts/credit-manager/Cargo.toml +++ b/contracts/credit-manager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mars-credit-manager" -version = "2.3.1" +version = "2.4.0" authors = { workspace = true } license = { workspace = true } edition = { workspace = true } diff --git a/contracts/credit-manager/src/contract.rs b/contracts/credit-manager/src/contract.rs index 7b1c4062..efb66c25 100644 --- a/contracts/credit-manager/src/contract.rs +++ b/contracts/credit-manager/src/contract.rs @@ -15,12 +15,12 @@ use crate::{ migrations, perp::update_balance_after_deleverage, query::{ - query_accounts, query_all_coin_balances, query_all_debt_shares, - query_all_total_debt_shares, query_all_trigger_orders, + query_account_tier_and_discount, query_accounts, query_all_coin_balances, + query_all_debt_shares, query_all_total_debt_shares, query_all_trigger_orders, query_all_trigger_orders_for_account, query_all_vault_positions, - query_all_vault_utilizations, query_config, query_positions, query_swap_fee, - query_total_debt_shares, query_vault_bindings, query_vault_position_value, - query_vault_utilization, + query_all_vault_utilizations, query_config, query_fee_tier_config, query_positions, + query_swap_fee, query_total_debt_shares, query_trading_fee, query_vault_bindings, + query_vault_position_value, query_vault_utilization, }, repay::repay_from_wallet, state::NEXT_TRIGGER_ID, @@ -173,7 +173,15 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> ContractResult { start_after, limit, } => to_json_binary(&query_vault_bindings(deps, start_after, limit)?), + QueryMsg::GetAccountTierAndDiscount { + account_id, + } => to_json_binary(&query_account_tier_and_discount(deps, &account_id)?), + QueryMsg::TradingFee { + account_id, + market_type, + } => to_json_binary(&query_trading_fee(deps, &account_id, &market_type)?), QueryMsg::SwapFeeRate {} => to_json_binary(&query_swap_fee(deps)?), + QueryMsg::FeeTierConfig {} => to_json_binary(&query_fee_tier_config(deps)?), }; res.map_err(Into::into) } @@ -188,5 +196,9 @@ pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result migrations::v2_3_0::migrate(deps, max_trigger_orders), MigrateMsg::V2_2_0ToV2_2_3 {} => migrations::v2_2_3::migrate(deps), + MigrateMsg::V2_3_1ToV2_4_0 { + fee_tier_config, + governance_address, + } => migrations::v2_4_0::migrate(deps, fee_tier_config, governance_address), } } diff --git a/contracts/credit-manager/src/error.rs b/contracts/credit-manager/src/error.rs index 6ce499cb..aa2b2902 100644 --- a/contracts/credit-manager/src/error.rs +++ b/contracts/credit-manager/src/error.rs @@ -118,6 +118,37 @@ pub enum ContractError { reason: String, }, + #[error("Fee tier config cannot be empty")] + EmptyFeeTierConfig, + + #[error("No tiers present")] + NoTiersPresent, + + #[error("Invalid min_voting_power in tier: {voting_power:?}")] + InvalidVotingPower { + voting_power: String, + }, + + #[error("Tiers must be sorted in descending order")] + TiersNotSortedDescending, + + #[error("Duplicate voting power thresholds")] + DuplicateVotingPowerThresholds, + + #[error("Discount percentage must be less than or equal to 100%")] + InvalidDiscountPercentage, + + #[error("Failed to load governance address from storage")] + FailedToLoadGovernanceAddress, + + #[error("Failed to load fee tier config from storage")] + FailedToLoadFeeTierConfig, + + #[error("Failed to query voting power: {error:?}")] + FailedToQueryVotingPower { + error: String, + }, + #[error("Paying down {debt_coin:?} for {request_coin:?} does not result in a profit for the liquidator")] LiquidationNotProfitable { debt_coin: Coin, @@ -297,4 +328,10 @@ pub enum ContractError { }, #[error("Vault has an admin; vaults cannot be managed with an admin set.")] VaultHasAdmin {}, + + #[error("Too many tiers. Maximum allowed: {max_tiers:?}, provided: {provided_tiers:?}")] + TooManyTiers { + max_tiers: usize, + provided_tiers: usize, + }, } diff --git a/contracts/credit-manager/src/instantiate.rs b/contracts/credit-manager/src/instantiate.rs index 041a17bf..ea8f3cad 100644 --- a/contracts/credit-manager/src/instantiate.rs +++ b/contracts/credit-manager/src/instantiate.rs @@ -5,9 +5,9 @@ use mars_types::credit_manager::InstantiateMsg; use crate::{ error::ContractResult, state::{ - DUALITY_SWAPPER, HEALTH_CONTRACT, INCENTIVES, KEEPER_FEE_CONFIG, MAX_SLIPPAGE, - MAX_TRIGGER_ORDERS, MAX_UNLOCKING_POSITIONS, ORACLE, OWNER, PARAMS, PERPS_LB_RATIO, - RED_BANK, SWAPPER, SWAP_FEE, ZAPPER, + DUALITY_SWAPPER, FEE_TIER_CONFIG, GOVERNANCE, HEALTH_CONTRACT, INCENTIVES, + KEEPER_FEE_CONFIG, MAX_SLIPPAGE, MAX_TRIGGER_ORDERS, MAX_UNLOCKING_POSITIONS, ORACLE, + OWNER, PARAMS, PERPS_LB_RATIO, RED_BANK, SWAPPER, SWAP_FEE, ZAPPER, }, utils::{assert_max_slippage, assert_perps_lb_ratio}, }; @@ -40,6 +40,8 @@ pub fn store_config(deps: DepsMut, env: Env, msg: &InstantiateMsg) -> ContractRe INCENTIVES.save(deps.storage, &msg.incentives.check(deps.api, env.contract.address)?)?; KEEPER_FEE_CONFIG.save(deps.storage, &msg.keeper_fee_config)?; SWAP_FEE.save(deps.storage, &msg.swap_fee)?; + FEE_TIER_CONFIG.save(deps.storage, &msg.fee_tier_config)?; + GOVERNANCE.save(deps.storage, msg.governance_address.check(deps.api)?.address())?; Ok(()) } diff --git a/contracts/credit-manager/src/lib.rs b/contracts/credit-manager/src/lib.rs index ca5634de..29d76dff 100644 --- a/contracts/credit-manager/src/lib.rs +++ b/contracts/credit-manager/src/lib.rs @@ -21,6 +21,7 @@ pub mod reclaim; pub mod refund; pub mod repay; pub mod stake_astro_lp; +pub mod staking; pub mod state; pub mod swap; pub mod trigger; diff --git a/contracts/credit-manager/src/migrations/mod.rs b/contracts/credit-manager/src/migrations/mod.rs index 22d21463..56fc13f9 100644 --- a/contracts/credit-manager/src/migrations/mod.rs +++ b/contracts/credit-manager/src/migrations/mod.rs @@ -2,3 +2,4 @@ pub mod v2_2_0; pub mod v2_2_3; pub mod v2_3_0; pub mod v2_3_1; +pub mod v2_4_0; diff --git a/contracts/credit-manager/src/migrations/v2_4_0.rs b/contracts/credit-manager/src/migrations/v2_4_0.rs new file mode 100644 index 00000000..7ab24b1e --- /dev/null +++ b/contracts/credit-manager/src/migrations/v2_4_0.rs @@ -0,0 +1,37 @@ +use cosmwasm_std::{Addr, DepsMut, Response}; +use cw2::{assert_contract_version, set_contract_version}; +use mars_types::fee_tiers::FeeTierConfig; + +use crate::{ + contract::CONTRACT_NAME, + error::ContractError, + staking::StakingTierManager, + state::{FEE_TIER_CONFIG, GOVERNANCE}, +}; + +const FROM_VERSION: &str = "2.3.1"; +const TO_VERSION: &str = "2.4.0"; + +pub fn migrate( + deps: DepsMut, + fee_tier_config: FeeTierConfig, + governance_address: Addr, +) -> Result { + // make sure we're migrating the correct contract and from the correct version + assert_contract_version(deps.storage, &format!("crates.io:{CONTRACT_NAME}"), FROM_VERSION)?; + + let manager = StakingTierManager::new(fee_tier_config.clone()); + manager.validate()?; + // Set the new state items + FEE_TIER_CONFIG.save(deps.storage, &fee_tier_config)?; + GOVERNANCE.save(deps.storage, &governance_address)?; + + set_contract_version(deps.storage, format!("crates.io:{CONTRACT_NAME}"), TO_VERSION)?; + + Ok(Response::new() + .add_attribute("action", "migrate") + .add_attribute("from_version", FROM_VERSION) + .add_attribute("to_version", TO_VERSION) + .add_attribute("fee_tier_config", "set") + .add_attribute("governance", governance_address)) +} diff --git a/contracts/credit-manager/src/perp.rs b/contracts/credit-manager/src/perp.rs index 346f0172..e23e37bc 100644 --- a/contracts/credit-manager/src/perp.rs +++ b/contracts/credit-manager/src/perp.rs @@ -1,7 +1,8 @@ use std::cmp::min; use cosmwasm_std::{ - coin, ensure_eq, BankMsg, Coin, CosmosMsg, DepsMut, Env, Int128, MessageInfo, Response, Uint128, + coin, ensure_eq, BankMsg, Coin, CosmosMsg, Decimal, DepsMut, Env, Int128, MessageInfo, + Response, Uint128, }; use mars_types::{ adapters::perps::Perps, @@ -12,6 +13,7 @@ use mars_types::{ use crate::{ borrow, error::{ContractError, ContractResult}, + staking::get_account_tier_and_discount, state::{COIN_BALANCES, PERPS, RED_BANK}, trigger::remove_invalid_trigger_orders, utils::{decrement_coin_balance, increment_coin_balance}, @@ -94,6 +96,10 @@ pub fn execute_perp_order( let mut response = Response::new(); + // Get staking tier discount for this account + let (tier, discount_pct, voting_power) = + get_account_tier_and_discount(deps.as_ref(), account_id)?; + // Query the perp position PnL so that we know whether funds needs to be // sent to the perps contract // @@ -114,10 +120,13 @@ pub fn execute_perp_order( position, order_size, reduce_only, + discount_pct, + &tier.id, )?, None => { // Open new position - let opening_fee = perps.query_opening_fee(&deps.querier, denom, order_size)?; + let opening_fee = + perps.query_opening_fee(&deps.querier, denom, order_size, Some(discount_pct))?; let fee = opening_fee.fee; let funds = if !fee.amount.is_zero() { @@ -127,8 +136,14 @@ pub fn execute_perp_order( vec![] }; - let msg = - perps.execute_perp_order(account_id, denom, order_size, reduce_only, funds)?; + let msg = perps.execute_perp_order( + account_id, + denom, + order_size, + reduce_only, + funds, + Some(discount_pct), + )?; response .add_message(msg) @@ -138,6 +153,9 @@ pub fn execute_perp_order( .add_attribute("reduce_only", reduce_only.unwrap_or(false).to_string()) .add_attribute("new_size", order_size.to_string()) .add_attribute("opening_fee", fee.to_string()) + .add_attribute("voting_power", voting_power.to_string()) + .add_attribute("tier_id", tier.id) + .add_attribute("discount_pct", discount_pct.to_string()) } }) } @@ -149,6 +167,9 @@ pub fn close_perp_position( ) -> ContractResult { let perps = PERPS.load(deps.storage)?; + // Get staking tier discount for this account + let (tier, discount_pct, _) = get_account_tier_and_discount(deps.as_ref(), account_id)?; + // Query the perp position PnL so that we know whether funds needs to be // sent to the perps contract // @@ -174,6 +195,8 @@ pub fn close_perp_position( position, order_size, Some(true), + discount_pct, + &tier.id, )?) } None => Err(ContractError::NoPerpPosition { @@ -215,14 +238,21 @@ pub fn close_all_perps( update_state_based_on_pnl(&mut deps, account_id, pnl, Some(action.clone()), response)?; let funds = funds.map_or_else(Vec::new, |c| vec![c]); + // Get staking tier discount for this account + let (tier, discount_pct, voting_power) = + get_account_tier_and_discount(deps.as_ref(), account_id)?; + // Close all perp positions at once - let close_msg = perps.close_all_msg(account_id, funds, action)?; + let close_msg = perps.close_all_msg(account_id, funds, action, Some(discount_pct))?; Ok(response .add_message(close_msg) .add_attribute("action", "close_all_perps") .add_attribute("account_id", account_id) - .add_attribute("number_of_positions", perp_positions.len().to_string())) + .add_attribute("number_of_positions", perp_positions.len().to_string()) + .add_attribute("voting_power", voting_power.to_string()) + .add_attribute("tier_id", tier.id) + .add_attribute("discount_pct", discount_pct.to_string())) } fn modify_existing_position( @@ -234,13 +264,22 @@ fn modify_existing_position( position: PerpPosition, order_size: Int128, reduce_only: Option, + discount_pct: Decimal, + tier: &str, ) -> ContractResult { let pnl = position.unrealized_pnl.to_coins(&position.base_denom).pnl; let pnl_string = position.unrealized_pnl.pnl.to_string(); let (funds, response) = update_state_based_on_pnl(&mut deps, account_id, pnl, None, response)?; let funds = funds.map_or_else(Vec::new, |c| vec![c]); - let msg = perps.execute_perp_order(account_id, denom, order_size, reduce_only, funds)?; + let msg = perps.execute_perp_order( + account_id, + denom, + order_size, + reduce_only, + funds, + Some(discount_pct), + )?; let new_size = position.size.checked_add(order_size)?; @@ -258,7 +297,9 @@ fn modify_existing_position( .add_attribute("realized_pnl", pnl_string) .add_attribute("reduce_only", reduce_only.unwrap_or(false).to_string()) .add_attribute("order_size", order_size.to_string()) - .add_attribute("new_size", new_size.to_string())) + .add_attribute("new_size", new_size.to_string()) + .add_attribute("discount_pct", discount_pct.to_string()) + .add_attribute("tier_id", tier)) } /// Prepare the necessary messages and funds to be sent to the perps contract based on the PnL. diff --git a/contracts/credit-manager/src/query.rs b/contracts/credit-manager/src/query.rs index cf67f18f..5ed9ea8a 100644 --- a/contracts/credit-manager/src/query.rs +++ b/contracts/credit-manager/src/query.rs @@ -7,21 +7,24 @@ use cw_storage_plus::Bound; use mars_types::{ adapters::vault::{Vault, VaultBase, VaultPosition, VaultPositionValue, VaultUnchecked}, credit_manager::{ - Account, CoinBalanceResponseItem, ConfigResponse, DebtAmount, DebtShares, Positions, - SharesResponseItem, TriggerOrderResponse, VaultBinding, VaultPositionResponseItem, - VaultUtilizationResponse, + Account, AccountTierAndDiscountResponse, CoinBalanceResponseItem, ConfigResponse, + DebtAmount, DebtShares, FeeTierConfigResponse, PerpTradingFeeResponse, Positions, + SharesResponseItem, SpotTradingFeeResponse, TradingFeeResponse, TriggerOrderResponse, + VaultBinding, VaultPositionResponseItem, VaultUtilizationResponse, }, health::AccountKind, oracle::ActionKind, + perps::MarketType, }; use crate::{ error::ContractResult, + staking::get_account_tier_and_discount, state::{ - ACCOUNT_KINDS, ACCOUNT_NFT, COIN_BALANCES, DEBT_SHARES, HEALTH_CONTRACT, INCENTIVES, - KEEPER_FEE_CONFIG, MAX_SLIPPAGE, MAX_UNLOCKING_POSITIONS, ORACLE, OWNER, PARAMS, PERPS, - PERPS_LB_RATIO, RED_BANK, REWARDS_COLLECTOR, SWAPPER, SWAP_FEE, TOTAL_DEBT_SHARES, - TRIGGER_ORDERS, VAULTS, VAULT_POSITIONS, ZAPPER, + ACCOUNT_KINDS, ACCOUNT_NFT, COIN_BALANCES, DEBT_SHARES, FEE_TIER_CONFIG, GOVERNANCE, + HEALTH_CONTRACT, INCENTIVES, KEEPER_FEE_CONFIG, MAX_SLIPPAGE, MAX_UNLOCKING_POSITIONS, + ORACLE, OWNER, PARAMS, PERPS, PERPS_LB_RATIO, RED_BANK, REWARDS_COLLECTOR, SWAPPER, + SWAP_FEE, TOTAL_DEBT_SHARES, TRIGGER_ORDERS, VAULTS, VAULT_POSITIONS, ZAPPER, }, utils::debt_shares_to_amount, vault::vault_utilization_in_deposit_cap_denom, @@ -67,6 +70,7 @@ pub fn query_config(deps: Deps) -> ContractResult { rewards_collector: REWARDS_COLLECTOR.may_load(deps.storage)?, keeper_fee_config: KEEPER_FEE_CONFIG.load(deps.storage)?, perps_liquidation_bonus_ratio: PERPS_LB_RATIO.load(deps.storage)?, + governance: GOVERNANCE.load(deps.storage)?.into(), }) } @@ -348,3 +352,70 @@ pub fn query_vault_bindings( }) }) } + +pub fn query_account_tier_and_discount( + deps: Deps, + account_id: &str, +) -> ContractResult { + use crate::staking::get_account_tier_and_discount; + + let (tier, discount_pct, voting_power) = get_account_tier_and_discount(deps, account_id)?; + + Ok(AccountTierAndDiscountResponse { + tier_id: tier.id, + discount_pct, + voting_power, + }) +} + +pub fn query_trading_fee( + deps: Deps, + account_id: &str, + market_type: &MarketType, +) -> ContractResult { + let (tier, discount_pct, _) = get_account_tier_and_discount(deps, account_id)?; + + match market_type { + MarketType::Spot => { + let base_fee_pct = SWAP_FEE.load(deps.storage)?; + let effective_fee_pct = + base_fee_pct.checked_mul(cosmwasm_std::Decimal::one() - discount_pct)?; + + Ok(TradingFeeResponse::Spot(SpotTradingFeeResponse { + base_fee_pct, + discount_pct, + effective_fee_pct, + tier_id: tier.id, + })) + } + MarketType::Perp { + denom, + } => { + let params = PARAMS.load(deps.storage)?; + let perp_params = params.query_perp_params(&deps.querier, denom)?; + + let opening_fee_pct = perp_params.opening_fee_rate; + let closing_fee_pct = perp_params.closing_fee_rate; + + let effective_opening_fee_pct = + opening_fee_pct.checked_mul(cosmwasm_std::Decimal::one() - discount_pct)?; + let effective_closing_fee_pct = + closing_fee_pct.checked_mul(cosmwasm_std::Decimal::one() - discount_pct)?; + + Ok(TradingFeeResponse::Perp(PerpTradingFeeResponse { + opening_fee_pct, + closing_fee_pct, + discount_pct, + effective_opening_fee_pct, + effective_closing_fee_pct, + tier_id: tier.id, + })) + } + } +} + +pub fn query_fee_tier_config(deps: Deps) -> ContractResult { + Ok(FeeTierConfigResponse { + fee_tier_config: FEE_TIER_CONFIG.load(deps.storage)?, + }) +} diff --git a/contracts/credit-manager/src/staking.rs b/contracts/credit-manager/src/staking.rs new file mode 100644 index 00000000..4b2ac3b4 --- /dev/null +++ b/contracts/credit-manager/src/staking.rs @@ -0,0 +1,123 @@ +use cosmwasm_std::{Decimal, Deps, Uint128}; +use mars_types::{ + adapters::governance::Governance, + fee_tiers::{FeeTier, FeeTierConfig}, +}; + +const MAX_TIER_SIZE: usize = 20; + +use crate::{ + error::{ContractError, ContractResult}, + state::{FEE_TIER_CONFIG, GOVERNANCE}, + utils::{ + assert_discount_pct, assert_tiers_max_size, assert_tiers_not_empty, + assert_tiers_sorted_descending, query_nft_token_owner, + }, +}; + +pub struct StakingTierManager { + pub config: FeeTierConfig, +} + +impl StakingTierManager { + pub fn new(config: FeeTierConfig) -> Self { + Self { + config, + } + } + + /// Find the applicable tier for a given voting power + /// Returns the tier with the highest min_voting_power that the user qualifies for + pub fn find_applicable_tier(&self, voting_power: Uint128) -> ContractResult<&FeeTier> { + // Ensure tiers are sorted in descending order of min_voting_power + assert_tiers_not_empty(&self.config.tiers)?; + + // Binary search for the applicable tier + let mut left = 0; + let mut right = self.config.tiers.len() - 1; + let mut result = 0; // Default to first tier (highest threshold) + + while left <= right { + let mid = left + (right - left) / 2; + let tier = &self.config.tiers[mid]; + + let min_power = tier.min_voting_power; + + if voting_power >= min_power { + // User qualifies for this tier, but there might be a better one + result = mid; + // Look for higher tiers (lower indices) but don't go below 0 + if mid == 0 { + break; + } + right = mid - 1; + } else { + // User doesn't qualify for this tier, look at lower tiers + left = mid + 1; + } + } + + Ok(&self.config.tiers[result]) + } + + /// Validate that tiers are properly ordered by min_voting_power (descending) + pub fn validate(&self) -> ContractResult<()> { + assert_tiers_not_empty(&self.config.tiers)?; + assert_tiers_max_size(&self.config.tiers, MAX_TIER_SIZE)?; + + // Check duplicates, descending order, and discount percentages + let mut voting_powers = Vec::new(); + for (i, tier) in self.config.tiers.iter().enumerate() { + // Validate discount percentage + assert_discount_pct(tier.discount_pct)?; + + // Collect voting power for later validation + voting_powers.push(tier.min_voting_power); + + // Check for duplicates (compare with previous tier) + if i > 0 && voting_powers[i] == voting_powers[i - 1] { + return Err(ContractError::DuplicateVotingPowerThresholds); + } + } + + // Check for descending order + assert_tiers_sorted_descending(&voting_powers)?; + + Ok(()) + } + + /// Get the default tier (tier with lowest min_voting_power) + /// the default tier (lowest voting power requirement) is the last element. + pub fn get_default_tier(&self) -> ContractResult<&FeeTier> { + self.config.tiers.last().ok_or(ContractError::NoTiersPresent) + } +} + +/// Get tier, discount percentage, and voting power for an account based on their staked MARS balance +pub fn get_account_tier_and_discount( + deps: Deps, + account_id: &str, +) -> ContractResult<(FeeTier, Decimal, Uint128)> { + // Get account owner from account_id + let account_owner = query_nft_token_owner(deps, account_id)?; + + // Get governance contract address from state + let governance_addr = + GOVERNANCE.load(deps.storage).map_err(|_| ContractError::FailedToLoadGovernanceAddress)?; + let governance = Governance::new(governance_addr); + + // Query voting power for the account owner + let voting_power_response = governance + .query_voting_power_at_height(&deps.querier, &account_owner) + .map_err(|e| ContractError::FailedToQueryVotingPower { + error: e.to_string(), + })?; + + // Get fee tier config and find applicable tier + let fee_tier_config = + FEE_TIER_CONFIG.load(deps.storage).map_err(|_| ContractError::FailedToLoadFeeTierConfig)?; + let manager = StakingTierManager::new(fee_tier_config); + let tier = manager.find_applicable_tier(voting_power_response.power)?; + + Ok((tier.clone(), tier.discount_pct, voting_power_response.power)) +} diff --git a/contracts/credit-manager/src/state.rs b/contracts/credit-manager/src/state.rs index 964b0a7a..0097efd4 100644 --- a/contracts/credit-manager/src/state.rs +++ b/contracts/credit-manager/src/state.rs @@ -8,6 +8,7 @@ use mars_types::{ swapper::Swapper, vault::VaultPositionAmount, zapper::Zapper, }, credit_manager::{KeeperFeeConfig, TriggerOrder}, + fee_tiers::FeeTierConfig, health::AccountKind, }; use mars_utils::guard::Guard; @@ -67,3 +68,9 @@ pub const VAULTS: Map<&str, Addr> = Map::new("vaults"); pub const PERPS_LB_RATIO: Item = Item::new("perps_lb_ratio"); pub const SWAP_FEE: Item = Item::new("swap_fee"); + +// Fee tier discount configuration +pub const FEE_TIER_CONFIG: Item = Item::new("fee_tier_config"); + +// Governance contract address +pub const GOVERNANCE: Item = Item::new("governance"); diff --git a/contracts/credit-manager/src/swap.rs b/contracts/credit-manager/src/swap.rs index 5edba3c9..843ceb83 100644 --- a/contracts/credit-manager/src/swap.rs +++ b/contracts/credit-manager/src/swap.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::{Coin, DepsMut, Env, Response, Uint128}; +use cosmwasm_std::{Coin, Decimal, DepsMut, Env, Response, Uint128}; use mars_types::{ credit_manager::{ActionAmount, ActionCoin, ChangeExpected}, swapper::SwapperRoute, @@ -6,6 +6,7 @@ use mars_types::{ use crate::{ error::{ContractError, ContractResult}, + staking::get_account_tier_and_discount, state::{COIN_BALANCES, DUALITY_SWAPPER, REWARDS_COLLECTOR, SWAPPER, SWAP_FEE}, utils::{ assert_withdraw_enabled, decrement_coin_balance, increment_coin_balance, update_balance_msg, @@ -40,9 +41,15 @@ pub fn swap_exact_in( decrement_coin_balance(deps.storage, account_id, &coin_in_to_trade)?; - // Deduct the swap fee - let swap_fee = SWAP_FEE.load(deps.storage)?; - let swap_fee_amount = coin_in_to_trade.amount.checked_mul_floor(swap_fee)?; + // Get staking tier discount for this account + let (tier, discount_pct, voting_power) = + get_account_tier_and_discount(deps.as_ref(), account_id)?; + + // Apply discount to swap fee + let base_swap_fee = SWAP_FEE.load(deps.storage)?; + let effective_swap_fee = + base_swap_fee.checked_mul(Decimal::one().checked_sub(discount_pct)?)?; + let swap_fee_amount = coin_in_to_trade.amount.checked_mul_floor(effective_swap_fee)?; coin_in_to_trade.amount = coin_in_to_trade.amount.checked_sub(swap_fee_amount)?; // Send to Rewards collector @@ -74,5 +81,10 @@ pub fn swap_exact_in( .add_attribute("action", "swapper") .add_attribute("account_id", account_id) .add_attribute("coin_in", coin_in_to_trade.to_string()) - .add_attribute("denom_out", denom_out)) + .add_attribute("denom_out", denom_out) + .add_attribute("voting_power", voting_power.to_string()) + .add_attribute("tier_id", tier.id) + .add_attribute("discount_pct", discount_pct.to_string()) + .add_attribute("base_swap_fee", base_swap_fee.to_string()) + .add_attribute("effective_swap_fee", effective_swap_fee.to_string())) } diff --git a/contracts/credit-manager/src/update_config.rs b/contracts/credit-manager/src/update_config.rs index cf364aa6..54bc43be 100644 --- a/contracts/credit-manager/src/update_config.rs +++ b/contracts/credit-manager/src/update_config.rs @@ -11,10 +11,12 @@ use mars_types::{ use crate::{ error::ContractResult, execute::create_credit_account, + staking::StakingTierManager, state::{ - ACCOUNT_NFT, DUALITY_SWAPPER, HEALTH_CONTRACT, INCENTIVES, KEEPER_FEE_CONFIG, MAX_SLIPPAGE, - MAX_TRIGGER_ORDERS, MAX_UNLOCKING_POSITIONS, ORACLE, OWNER, PARAMS, PERPS, PERPS_LB_RATIO, - RED_BANK, REWARDS_COLLECTOR, SWAPPER, SWAP_FEE, ZAPPER, + ACCOUNT_NFT, DUALITY_SWAPPER, FEE_TIER_CONFIG, GOVERNANCE, HEALTH_CONTRACT, INCENTIVES, + KEEPER_FEE_CONFIG, MAX_SLIPPAGE, MAX_TRIGGER_ORDERS, MAX_UNLOCKING_POSITIONS, ORACLE, + OWNER, PARAMS, PERPS, PERPS_LB_RATIO, RED_BANK, REWARDS_COLLECTOR, SWAPPER, SWAP_FEE, + ZAPPER, }, utils::{assert_max_slippage, assert_perps_lb_ratio, assert_swap_fee}, }; @@ -137,6 +139,23 @@ pub fn update_config( response.add_attribute("key", "perps").add_attribute("value", unchecked.address()); } + // Staking tier config and DAO staking address + if let Some(cfg) = updates.fee_tier_config { + // Validate the staking tier configuration before saving + let manager = StakingTierManager::new(cfg.clone()); + manager.validate()?; + + FEE_TIER_CONFIG.save(deps.storage, &cfg)?; + response = response.add_attribute("key", "fee_tier_config"); + } + + if let Some(addr) = updates.governance_address { + let checked = addr.check(deps.api)?; + GOVERNANCE.save(deps.storage, checked.address())?; + response = + response.add_attribute("key", "governance").add_attribute("value", checked.address()); + } + if let Some(kfc) = updates.keeper_fee_config { KEEPER_FEE_CONFIG.save(deps.storage, &kfc)?; response = response.add_attributes(vec![ diff --git a/contracts/credit-manager/src/utils.rs b/contracts/credit-manager/src/utils.rs index 8724341e..3ac64e06 100644 --- a/contracts/credit-manager/src/utils.rs +++ b/contracts/credit-manager/src/utils.rs @@ -110,6 +110,42 @@ pub fn assert_swap_fee(swap_fee: Decimal) -> ContractResult<()> { Ok(()) } +pub fn assert_discount_pct(discount_pct: Decimal) -> ContractResult<()> { + if discount_pct > Decimal::one() { + return Err(ContractError::InvalidDiscountPercentage); + } + Ok(()) +} + +pub fn assert_tiers_not_empty(tiers: &[impl std::fmt::Debug]) -> ContractResult<()> { + if tiers.is_empty() { + return Err(ContractError::EmptyFeeTierConfig); + } + Ok(()) +} + +pub fn assert_tiers_sorted_descending(voting_powers: &[Uint128]) -> ContractResult<()> { + for i in 1..voting_powers.len() { + if voting_powers[i] >= voting_powers[i - 1] { + return Err(ContractError::TiersNotSortedDescending); + } + } + Ok(()) +} + +pub fn assert_tiers_max_size( + tiers: &[impl std::fmt::Debug], + max_tiers: usize, +) -> ContractResult<()> { + if tiers.len() > max_tiers { + return Err(ContractError::TooManyTiers { + max_tiers, + provided_tiers: tiers.len(), + }); + } + Ok(()) +} + pub fn assert_withdraw_enabled( storage: &dyn Storage, querier: &QuerierWrapper, diff --git a/contracts/credit-manager/tests/tests/mod.rs b/contracts/credit-manager/tests/tests/mod.rs index 5c688679..04e95247 100644 --- a/contracts/credit-manager/tests/tests/mod.rs +++ b/contracts/credit-manager/tests/tests/mod.rs @@ -29,11 +29,13 @@ mod test_migration_v2; mod test_migration_v2_2_3; mod test_migration_v2_3_0; mod test_migration_v2_3_1; +mod test_migration_v2_4_0; mod test_no_health_check; mod test_order_relations; mod test_perp; mod test_perp_vault; mod test_perps_deleverage; +mod test_perps_with_discount; mod test_reclaim; mod test_reentrancy_guard; mod test_refund_balances; @@ -41,7 +43,10 @@ mod test_repay; mod test_repay_for_recipient; mod test_repay_from_wallet; mod test_stake_astro_lp; +mod test_staking_tiers; mod test_swap; +mod test_swap_with_discount; +mod test_trading_fee; mod test_trigger; mod test_unstake_astro_lp; mod test_update_admin; diff --git a/contracts/credit-manager/tests/tests/test_liquidate_vault.rs b/contracts/credit-manager/tests/tests/test_liquidate_vault.rs index 58c27f1f..5a199594 100644 --- a/contracts/credit-manager/tests/tests/test_liquidate_vault.rs +++ b/contracts/credit-manager/tests/tests/test_liquidate_vault.rs @@ -74,7 +74,7 @@ fn liquidatee_must_have_the_request_vault_position() { assert_err( res, ContractError::Std(NotFound { - kind: "type: mars_types::adapters::vault::amount::VaultPositionAmount; key: [00, 0F, 76, 61, 75, 6C, 74, 5F, 70, 6F, 73, 69, 74, 69, 6F, 6E, 73, 00, 01, 32, 63, 6F, 6E, 74, 72, 61, 63, 74, 31, 34]".to_string(), + kind: "type: mars_types::adapters::vault::amount::VaultPositionAmount; key: [00, 0F, 76, 61, 75, 6C, 74, 5F, 70, 6F, 73, 69, 74, 69, 6F, 6E, 73, 00, 01, 32, 63, 6F, 6E, 74, 72, 61, 63, 74, 31, 35]".to_string(), }), ) } diff --git a/contracts/credit-manager/tests/tests/test_migration_v2_4_0.rs b/contracts/credit-manager/tests/tests/test_migration_v2_4_0.rs new file mode 100644 index 00000000..4253cd68 --- /dev/null +++ b/contracts/credit-manager/tests/tests/test_migration_v2_4_0.rs @@ -0,0 +1,172 @@ +use cosmwasm_std::{attr, testing::mock_env, Addr, Decimal, Uint128}; +use cw2::{ContractVersion, VersionError}; +use mars_credit_manager::{ + contract::migrate, + error::ContractError, + state::{FEE_TIER_CONFIG, GOVERNANCE}, +}; +use mars_testing::mock_dependencies; +use mars_types::{ + credit_manager::MigrateMsg, + fee_tiers::{FeeTier, FeeTierConfig}, +}; + +const CONTRACT_NAME: &str = "crates.io:mars-credit-manager"; +const CONTRACT_VERSION: &str = "2.4.0"; + +fn create_test_fee_tier_config() -> FeeTierConfig { + FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_3".to_string(), + min_voting_power: Uint128::new(100000000000), // 100,000 MARS + discount_pct: Decimal::percent(30), + }, + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::new(10000000000), // 10,000 MARS + discount_pct: Decimal::percent(10), + }, + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::zero(), + discount_pct: Decimal::percent(0), + }, + ], + } +} + +#[test] +fn wrong_contract_name() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version(deps.as_mut().storage, "contract_xyz", "2.3.1").unwrap(); + + let fee_tier_config = create_test_fee_tier_config(); + let governance_address = Addr::unchecked("governance"); + + let err = migrate( + deps.as_mut(), + mock_env(), + MigrateMsg::V2_3_1ToV2_4_0 { + fee_tier_config, + governance_address, + }, + ) + .unwrap_err(); + + assert_eq!( + err, + ContractError::Version(VersionError::WrongContract { + expected: CONTRACT_NAME.to_string(), + found: "contract_xyz".to_string() + }) + ); +} + +#[test] +fn wrong_contract_version() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version(deps.as_mut().storage, CONTRACT_NAME, "2.1.0").unwrap(); + + let fee_tier_config = create_test_fee_tier_config(); + let governance_address = Addr::unchecked("governance"); + + let err = migrate( + deps.as_mut(), + mock_env(), + MigrateMsg::V2_3_1ToV2_4_0 { + fee_tier_config, + governance_address, + }, + ) + .unwrap_err(); + + assert_eq!( + err, + ContractError::Version(VersionError::WrongVersion { + expected: "2.3.1".to_string(), + found: "2.1.0".to_string() + }) + ); +} + +#[test] +fn successful_migration() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version(deps.as_mut().storage, CONTRACT_NAME, "2.3.1").unwrap(); + + let fee_tier_config = create_test_fee_tier_config(); + let governance_address = Addr::unchecked("governance"); + + let res = migrate( + deps.as_mut(), + mock_env(), + MigrateMsg::V2_3_1ToV2_4_0 { + fee_tier_config: fee_tier_config.clone(), + governance_address: governance_address.clone(), + }, + ) + .unwrap(); + + // Verify that the state was set correctly + let stored_fee_tier_config = FEE_TIER_CONFIG.load(deps.as_ref().storage).unwrap(); + let stored_governance = GOVERNANCE.load(deps.as_ref().storage).unwrap(); + + assert_eq!(stored_fee_tier_config, fee_tier_config); + assert_eq!(stored_governance, governance_address); + + // Verify response attributes + assert_eq!( + res.attributes, + vec![ + attr("action", "migrate"), + attr("from_version", "2.3.1"), + attr("to_version", "2.4.0"), + attr("fee_tier_config", "set"), + attr("governance", governance_address) + ] + ); + + // Verify contract version was updated + let new_contract_version = ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string(), + }; + assert_eq!(cw2::get_contract_version(deps.as_ref().storage).unwrap(), new_contract_version); +} + +#[test] +fn migration_with_invalid_fee_tier_config() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version(deps.as_mut().storage, CONTRACT_NAME, "2.3.1").unwrap(); + + // Create invalid fee tier config (tiers not sorted by min_voting_power descending) + let invalid_fee_tier_config = FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(10000000000), // Lower threshold first + discount_pct: Decimal::percent(10), + }, + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::new(100000000000), // Higher threshold second + discount_pct: Decimal::percent(30), + }, + ], + }; + let governance_address = Addr::unchecked("governance"); + + let err = migrate( + deps.as_mut(), + mock_env(), + MigrateMsg::V2_3_1ToV2_4_0 { + fee_tier_config: invalid_fee_tier_config, + governance_address, + }, + ) + .unwrap_err(); + + // Should fail validation due to incorrect tier ordering + assert!(matches!(err, ContractError::TiersNotSortedDescending)); +} diff --git a/contracts/credit-manager/tests/tests/test_perp.rs b/contracts/credit-manager/tests/tests/test_perp.rs index e9380a60..e9fd4da6 100644 --- a/contracts/credit-manager/tests/tests/test_perp.rs +++ b/contracts/credit-manager/tests/tests/test_perp.rs @@ -96,7 +96,7 @@ fn open_position_with_correct_payment( // Check perp data before any action let vault_usdc_balance = mock.query_balance(mock.perps.address(), &usdc_info.denom); - let opening_fee = mock.query_perp_opening_fee(&atom_info.denom, perp_size); + let opening_fee = mock.query_perp_opening_fee(&atom_info.denom, perp_size, None); assert_eq!(opening_fee.fee.amount.u128(), 49); // Open perp position @@ -313,7 +313,7 @@ fn close_perp_position_with_profit() { // check perp data before any action let vault_usdc_balance = mock.query_balance(mock.perps.address(), &usdc_info.denom); - let opening_fee = mock.query_perp_opening_fee(&atom_info.denom, perp_size); + let opening_fee = mock.query_perp_opening_fee(&atom_info.denom, perp_size, None); // open perp position mock.update_credit_account( @@ -1018,7 +1018,7 @@ fn close_perp_position() { // check perp data before any action let vault_usdc_balance = mock.query_balance(mock.perps.address(), &usdc_info.denom); - let opening_fee = mock.query_perp_opening_fee(&atom_info.denom, perp_size); + let opening_fee = mock.query_perp_opening_fee(&atom_info.denom, perp_size, None); // wrongly try to close a not existing position let res = mock.update_credit_account( diff --git a/contracts/credit-manager/tests/tests/test_perps_with_discount.rs b/contracts/credit-manager/tests/tests/test_perps_with_discount.rs new file mode 100644 index 00000000..c7567fa2 --- /dev/null +++ b/contracts/credit-manager/tests/tests/test_perps_with_discount.rs @@ -0,0 +1,943 @@ +use std::str::FromStr; + +use cosmwasm_std::{Addr, Coin, Decimal, Int128, Uint128}; +use cw_multi_test::AppResponse; +use mars_types::{ + credit_manager::{ + Action::{ClosePerpPosition, Deposit, ExecutePerpOrder}, + ExecutePerpOrderType, + }, + params::PerpParamsUpdate, +}; +use test_case::test_case; + +use super::helpers::{coin_info, uatom_info, AccountToFund, MockEnv}; +use crate::tests::helpers::{default_perp_params, get_coin}; + +fn setup_env() -> (MockEnv, Addr, String) { + let atom = uatom_info(); + let usdc = coin_info("uusdc"); + let user = Addr::unchecked("user"); + + let mut mock = MockEnv::new() + .set_params(&[atom.clone(), usdc.clone()]) + .fund_account(AccountToFund { + addr: user.clone(), + funds: vec![Coin::new(100_000, usdc.denom.clone())], + }) + .build() + .unwrap(); + + let account_id = mock.create_credit_account(&user).unwrap(); + + // Setup perps params for the market and seed vault liquidity via CM + mock.update_perp_params(PerpParamsUpdate::AddOrUpdate { + params: default_perp_params(&atom.denom), + }); + // Fund the CM (rover) with USDC and deposit into perps vault so opening fees can be handled + let rover_addr = mock.rover.clone(); + let usdc_denom = usdc.denom.clone(); + mock.fund_addr(&rover_addr, vec![Coin::new(100_0000, usdc_denom.clone())]); + mock.deposit_to_perp_vault(&account_id, &Coin::new(50_0000, usdc_denom), None).unwrap(); + (mock, user, account_id) +} + +fn open_perp( + mock: &mut MockEnv, + account_id: &str, + user: &Addr, + denom: &str, + size: Int128, + usdc_to_deposit: u128, +) -> AppResponse { + // Ensure some USDC deposit to pay opening fee + mock.update_credit_account( + account_id, + user, + vec![ + Deposit(Coin::new(usdc_to_deposit, "uusdc")), + ExecutePerpOrder { + denom: denom.to_string(), + order_size: size, + reduce_only: None, + order_type: Some(ExecutePerpOrderType::Default), + }, + ], + &[Coin::new(usdc_to_deposit, "uusdc")], + ) + .unwrap() +} + +// Test fee discount across different voting power tiers and scenarios +#[test_case( + 0, + "tier_1", + Decimal::percent(0), + 200; + "tier 1: 0 power -> 0% discount, size 200" +)] +#[test_case( + 250_000_000_000, + "tier_5", + Decimal::percent(45), + 200; + "tier 5: >= 250_000 MARS power -> 45% discount, size 200" +)] +#[test_case( + 1_000_000_000_000, + "tier_7", + Decimal::percent(70), + 200; + "tier 7: >= 1_000_000 MARS power -> 70% discount, size 200" +)] +#[test_case( + 100_000_000_000, + "tier_4", + Decimal::percent(30), + 100; + "tier 4: >= 100_000 MARS power -> 30% discount, size 100" +)] +#[test_case( + 500_000_000_000, + "tier_6", + Decimal::percent(60), + 500; + "tier 6: >= 500_000 MARS power -> 60% discount, size 500" +)] +#[test_case( + 10_000_000_000, + "tier_2", + Decimal::percent(10), + 150; + "tier 2: >= 10_000 MARS power -> 10% discount, size 150" +)] +#[test_case( + 50_000_000_000, + "tier_3", + Decimal::percent(20), + 300; + "tier 3: >= 50_000 MARS power -> 20% discount, size 300" +)] +#[test_case( + 1_500_000_000_000, + "tier_8", + Decimal::percent(80), + 400; + "tier 8: >= 1_500_000 MARS power -> 80% discount, size 400" +)] +fn test_perps_with_discount_events( + voting_power: u128, + expected_tier: &str, + expected_discount: Decimal, + position_size: i128, +) { + let (mut mock, user, account_id) = setup_env(); + let atom = uatom_info(); + + // Set voting power for this test case + mock.set_voting_power(&user, Uint128::new(voting_power)); + + let find_attrs = |res: &AppResponse| { + let evt = res + .events + .iter() + .find(|e| e.attributes.iter().any(|a| a.key == "discount_pct")) + .or_else(|| { + res.events.iter().find(|e| { + e.attributes.iter().any(|a| { + a.key == "action" + && (a.value == "open_perp_position" || a.value == "execute_perp_order") + }) + }) + }) + .expect("expected perps event with discount attributes"); + evt.attributes + .iter() + .map(|a| (a.key.clone(), a.value.clone())) + .collect::>() + }; + + // Open perp position and verify discount attributes + let res = + open_perp(&mut mock, &account_id, &user, &atom.denom, Int128::new(position_size), 10_000); + let attrs = find_attrs(&res); + + assert_eq!(attrs.get("voting_power").unwrap(), &voting_power.to_string()); + assert_eq!(attrs.get("tier_id").unwrap(), expected_tier); + assert_eq!(attrs.get("discount_pct").unwrap(), &expected_discount.to_string()); + assert_eq!(attrs.get("new_size").unwrap(), &position_size.to_string()); +} + +// Test close_perp_position with discount functionality +#[test_case( + 0, + "tier_1", + Decimal::percent(0); + "close_perp_position tier 1: 0 power -> 0% discount" +)] +#[test_case( + 250_000_000_000, + "tier_5", + Decimal::percent(45); + "close_perp_position tier 5: >= 250_000 MARS power -> 45% discount" +)] +#[test_case( + 1_000_000_000_000, + "tier_7", + Decimal::percent(70); + "close_perp_position tier 7: >= 1_000_000 MARS power -> 70% discount" +)] +#[test_case( + 100_000_000_000, + "tier_4", + Decimal::percent(30); + "close_perp_position tier 4: >= 100_000 MARS power -> 30% discount" +)] +#[test_case( + 500_000_000_000, + "tier_6", + Decimal::percent(60); + "close_perp_position tier 6: >= 500_000 MARS power -> 60% discount" +)] +#[test_case( + 1_500_000_000_000, + "tier_8", + Decimal::percent(80); + "close_perp_position tier 8: >= 1_500_000 MARS power -> 80% discount" +)] +fn test_close_perp_position_with_discount( + voting_power: u128, + _expected_tier: &str, + expected_discount: Decimal, +) { + let (mut mock, user, account_id) = setup_env(); + let atom = uatom_info(); + + // Set voting power for this test case + mock.set_voting_power(&user, Uint128::new(voting_power)); + + // Open a position first + let res = open_perp(&mut mock, &account_id, &user, &atom.denom, Int128::new(200), 10_000); + let attrs = find_attrs(&res); + assert_eq!(attrs.get("action").unwrap(), "open_perp_position"); + assert_eq!(attrs.get("discount_pct").unwrap(), &expected_discount.to_string()); + + // Now close the position and verify discount is applied + let close_res = mock + .update_credit_account( + &account_id, + &user, + vec![mars_types::credit_manager::Action::ClosePerpPosition { + denom: atom.denom.clone(), + }], + &[], + ) + .unwrap(); + + // Find the execute_perp_order event (which is what close_perp_position actually emits) + let close_attrs = close_res + .events + .iter() + .find(|e| e.attributes.iter().any(|a| a.key == "action" && a.value == "execute_perp_order")) + .expect("expected execute_perp_order event from close_perp_position") + .attributes + .iter() + .map(|a| (a.key.clone(), a.value.clone())) + .collect::>(); + + // Verify discount attributes are present in the execute_perp_order event + assert_eq!(close_attrs.get("discount_pct").unwrap(), &expected_discount.to_string()); + assert_eq!(close_attrs.get("reduce_only").unwrap(), "true"); // close_perp_position sets reduce_only=true + assert_eq!(close_attrs.get("order_size").unwrap(), "-200"); // negative size to close position + assert_eq!(close_attrs.get("new_size").unwrap(), "0"); // position should be closed (size 0) +} + +// Test multiple perp positions with discount functionality +#[test_case( + 0, + "tier_1", + Decimal::percent(0); + "multiple positions tier 1: 0 power -> 0% discount" +)] +#[test_case( + 250_000_000_000, + "tier_5", + Decimal::percent(45); + "multiple positions tier 5: >= 250_000 MARS power -> 45% discount" +)] +#[test_case( + 1_000_000_000_000, + "tier_7", + Decimal::percent(70); + "multiple positions tier 7: >= 1_000_000 MARS power -> 70% discount" +)] +#[test_case( + 100_000_000_000, + "tier_4", + Decimal::percent(30); + "multiple positions tier 4: >= 100_000 MARS power -> 30% discount" +)] +#[test_case( + 500_000_000_000, + "tier_6", + Decimal::percent(60); + "multiple positions tier 6: >= 500_000 MARS power -> 60% discount" +)] +#[test_case( + 1_500_000_000_000, + "tier_8", + Decimal::percent(80); + "multiple positions tier 8: >= 1_500_000 MARS power -> 80% discount" +)] +fn test_multiple_perp_positions_with_discount( + voting_power: u128, + expected_tier: &str, + expected_discount: Decimal, +) { + let (mut mock, user, account_id) = setup_env(); + let atom = uatom_info(); + + // Set voting power for this test case + mock.set_voting_power(&user, Uint128::new(voting_power)); + + // Create additional credit accounts for multiple positions + let account_id_2 = mock.create_credit_account(&user).unwrap(); + let account_id_3 = mock.create_credit_account(&user).unwrap(); + + // Fund additional accounts with USDC for perps vault + let rover_addr = mock.rover.clone(); + let usdc_denom = "uusdc".to_string(); + mock.fund_addr(&rover_addr, vec![Coin::new(100_0000, usdc_denom.clone())]); + mock.deposit_to_perp_vault(&account_id_2, &Coin::new(50_0000, usdc_denom.clone()), None) + .unwrap(); + mock.deposit_to_perp_vault(&account_id_3, &Coin::new(50_0000, usdc_denom), None).unwrap(); + + // Open first position on account_id + let res1 = open_perp(&mut mock, &account_id, &user, &atom.denom, Int128::new(200), 10_000); + let attrs1 = find_attrs(&res1); + assert_eq!(attrs1.get("action").unwrap(), "open_perp_position"); + assert_eq!(attrs1.get("discount_pct").unwrap(), &expected_discount.to_string()); + assert_eq!(attrs1.get("tier_id").unwrap(), expected_tier); + assert_eq!(attrs1.get("voting_power").unwrap(), &voting_power.to_string()); + + // Open a second position on account_id_2 + let res2 = open_perp(&mut mock, &account_id_2, &user, &atom.denom, Int128::new(100), 10_000); + let attrs2 = find_attrs(&res2); + assert_eq!(attrs2.get("action").unwrap(), "open_perp_position"); + assert_eq!(attrs2.get("discount_pct").unwrap(), &expected_discount.to_string()); + assert_eq!(attrs2.get("tier_id").unwrap(), expected_tier); + assert_eq!(attrs2.get("voting_power").unwrap(), &voting_power.to_string()); + + // Close first position and verify discount is applied + let close_res1 = mock + .update_credit_account( + &account_id, + &user, + vec![mars_types::credit_manager::Action::ClosePerpPosition { + denom: atom.denom.clone(), + }], + &[], + ) + .unwrap(); + + // Find the execute_perp_order event from closing the first position + let close_attrs1 = close_res1 + .events + .iter() + .find(|e| e.attributes.iter().any(|a| a.key == "action" && a.value == "execute_perp_order")) + .expect("expected execute_perp_order event from close_perp_position") + .attributes + .iter() + .map(|a| (a.key.clone(), a.value.clone())) + .collect::>(); + + // Verify discount attributes are present in the close event + assert_eq!(close_attrs1.get("discount_pct").unwrap(), &expected_discount.to_string()); + assert_eq!(close_attrs1.get("reduce_only").unwrap(), "true"); + assert_eq!(close_attrs1.get("order_size").unwrap(), "-200"); // negative to close 200 size + + // Close second position and verify discount is applied + let close_res2 = mock + .update_credit_account( + &account_id_2, + &user, + vec![mars_types::credit_manager::Action::ClosePerpPosition { + denom: atom.denom.clone(), + }], + &[], + ) + .unwrap(); + + // Find the execute_perp_order event from closing the second position + let close_attrs2 = close_res2 + .events + .iter() + .find(|e| e.attributes.iter().any(|a| a.key == "action" && a.value == "execute_perp_order")) + .expect("expected execute_perp_order event from close_perp_position") + .attributes + .iter() + .map(|a| (a.key.clone(), a.value.clone())) + .collect::>(); + + // Verify discount attributes are present in the second close event + assert_eq!(close_attrs2.get("discount_pct").unwrap(), &expected_discount.to_string()); + assert_eq!(close_attrs2.get("reduce_only").unwrap(), "true"); + assert_eq!(close_attrs2.get("order_size").unwrap(), "-100"); // negative to close 100 size +} + +// Helper function to find attributes (extracted to avoid duplication) +fn find_attrs(res: &AppResponse) -> std::collections::HashMap { + // First try to find any perps event with discount_pct (including "0") + let evt = res + .events + .iter() + .find(|e| e.attributes.iter().any(|a| a.key == "discount_pct")) + .or_else(|| { + // Fallback: look for perps events by action type + res.events.iter().find(|e| { + e.attributes.iter().any(|a| { + a.key == "action" + && (a.value == "open_perp_position" || a.value == "execute_perp_order") + }) + }) + }) + .expect("expected perps event with discount attributes"); + + evt.attributes + .iter() + .map(|a| (a.key.clone(), a.value.clone())) + .collect::>() +} + +// Helper function to execute perp order with tier validation +fn execute_perp_order_with_tier_validation( + mock: &mut MockEnv, + account_id: &str, + user: &Addr, + denom: &str, + size: Int128, + expected_tier: &str, + expected_discount: Decimal, + expected_action: &str, +) -> AppResponse { + let res = mock + .update_credit_account( + account_id, + user, + vec![ExecutePerpOrder { + denom: denom.to_string(), + order_size: size, + reduce_only: None, + order_type: Some(ExecutePerpOrderType::Default), + }], + &[], + ) + .unwrap(); + + let attrs = find_attrs(&res); + assert_eq!(attrs.get("action").unwrap(), expected_action); + assert_eq!(attrs.get("discount_pct").unwrap(), &expected_discount.to_string()); + assert_eq!(attrs.get("tier_id").unwrap(), expected_tier); + + res +} + +// Helper function to assert vault balance increase +fn assert_vault_balance_increase( + mock: &MockEnv, + previous_balance: Uint128, + expected_fee: Uint128, +) -> Uint128 { + let current_balance = mock.query_balance(mock.perps.address(), "uusdc"); + let expected_balance = previous_balance + expected_fee; + assert_eq!(current_balance.amount, expected_balance); + current_balance.amount +} + +// Helper function to assert position size +fn assert_position_size(mock: &MockEnv, account_id: &str, denom: &str, expected_size: Int128) { + let position = mock.query_perp_position(account_id, denom); + assert!(position.position.is_some()); + assert_eq!(position.position.unwrap().size, expected_size); +} + +// Helper function to query and validate opening fee +fn query_and_validate_opening_fee( + mock: &MockEnv, + denom: &str, + size: Int128, + discount_pct: Decimal, + expected_rate: Decimal, + expected_fee: Uint128, +) -> Uint128 { + let opening_fee = mock.query_perp_opening_fee(denom, size, Some(discount_pct)); + assert_eq!(opening_fee.rate, expected_rate); + assert_eq!(opening_fee.fee.amount, expected_fee); + opening_fee.fee.amount +} + +#[test] +fn test_perp_position_modification_with_tier_changes() { + // Test scenario: Open position with no discount, then modify with different discount tiers + // This validates that discount tiers are correctly applied during position lifecycle + + let (mut mock, user, account_id) = setup_env(); + let atom = uatom_info(); + + // Setup: Deposit USDC for position health + mock.update_credit_account( + &account_id, + &user, + vec![Deposit(Coin::new(50_000, "uusdc"))], + &[Coin::new(50_000, "uusdc")], + ) + .unwrap(); + + let initial_vault_balance = mock.query_balance(mock.perps.address(), "uusdc").amount; + let query_market_acct = |mock: &MockEnv| -> mars_types::perps::AccountingResponse { + mock.app + .wrap() + .query_wasm_smart( + mock.perps.address(), + &mars_types::perps::QueryMsg::MarketAccounting { + denom: atom.denom.clone(), + }, + ) + .unwrap() + }; + + // Step 1: Open initial position with Tier 1 (0% discount) + mock.set_voting_power(&user, Uint128::new(0)); + + // Check user balance before opening position + let position_before_1 = mock.query_positions(&account_id); + let user_usdc_balance_before_1 = get_coin("uusdc", &position_before_1.deposits).amount; + + let opening_fee_1 = query_and_validate_opening_fee( + &mock, + &atom.denom, + Int128::new(200), + Decimal::percent(0), + Decimal::percent(1), + Uint128::new(9), + ); + + execute_perp_order_with_tier_validation( + &mut mock, + &account_id, + &user, + &atom.denom, + Int128::new(200), + "tier_1", + Decimal::percent(0), + "open_perp_position", + ); + + // Verify user paid the correct fee (balance should decrease by opening fee) + let position_after_1 = mock.query_positions(&account_id); + let user_usdc_balance_after_1 = get_coin("uusdc", &position_after_1.deposits).amount; + assert_eq!(user_usdc_balance_after_1, user_usdc_balance_before_1 - opening_fee_1); + + assert_position_size(&mock, &account_id, &atom.denom, Int128::new(200)); + let vault_balance_1 = + assert_vault_balance_increase(&mock, initial_vault_balance, opening_fee_1); + + // Market accounting after step 1 + let acct_after_1 = query_market_acct(&mock); + assert_eq!( + acct_after_1.accounting.cash_flow.opening_fee, + Int128::try_from(opening_fee_1).unwrap() + ); + assert_eq!(acct_after_1.accounting.cash_flow.price_pnl, Int128::zero()); + assert_eq!(acct_after_1.accounting.cash_flow.accrued_funding, Int128::zero()); + + // Step 2: Increase position with Tier 2 (10% discount) + mock.set_voting_power(&user, Uint128::new(10_000_000_000)); // 10,000 MARS + + // Check user balance before increasing position + let position_before_2 = mock.query_positions(&account_id); + let user_usdc_balance_before_2 = get_coin("uusdc", &position_before_2.deposits).amount; + + let opening_fee_2 = query_and_validate_opening_fee( + &mock, + &atom.denom, + Int128::new(200), + Decimal::percent(10), + Decimal::from_str("0.009").unwrap(), + Uint128::new(8), + ); + + execute_perp_order_with_tier_validation( + &mut mock, + &account_id, + &user, + &atom.denom, + Int128::new(200), + "tier_2", + Decimal::percent(10), + "execute_perp_order", + ); + + // Verify user paid the correct discounted fee (balance should decrease by discounted opening fee) + let position_after_2 = mock.query_positions(&account_id); + let user_usdc_balance_after_2 = get_coin("uusdc", &position_after_2.deposits).amount; + assert_eq!(user_usdc_balance_after_2, user_usdc_balance_before_2 - opening_fee_2); + + let expected_total_size = Int128::new(200 + 200); + assert_position_size(&mock, &account_id, &atom.denom, expected_total_size); + let vault_balance_2 = assert_vault_balance_increase(&mock, vault_balance_1, opening_fee_2); + + // Market accounting after step 2 (opening fees accumulate) + let acct_after_2 = query_market_acct(&mock); + let expected_opening_fees_1_2 = opening_fee_1 + opening_fee_2; + assert_eq!( + acct_after_2.accounting.cash_flow.opening_fee, + Int128::try_from(expected_opening_fees_1_2).unwrap() + ); + assert_eq!(acct_after_2.accounting.cash_flow.price_pnl, Int128::zero()); + assert_eq!(acct_after_2.accounting.cash_flow.accrued_funding, Int128::zero()); + + // Step 3: Increase position with Tier 5 (45% discount) + mock.set_voting_power(&user, Uint128::new(250_000_000_000)); // 250,000 MARS + + // Check user balance before increasing position + let position_before_3 = mock.query_positions(&account_id); + let user_usdc_balance_before_3 = get_coin("uusdc", &position_before_3.deposits).amount; + + let opening_fee_3 = query_and_validate_opening_fee( + &mock, + &atom.denom, + Int128::new(200), + Decimal::percent(45), + Decimal::from_str("0.0055").unwrap(), + Uint128::new(5), + ); + + execute_perp_order_with_tier_validation( + &mut mock, + &account_id, + &user, + &atom.denom, + Int128::new(200), + "tier_5", + Decimal::percent(45), + "execute_perp_order", + ); + + // Verify user paid the correct discounted fee (balance should decrease by discounted opening fee) + let position_after_3 = mock.query_positions(&account_id); + let user_usdc_balance_after_3 = get_coin("uusdc", &position_after_3.deposits).amount; + assert_eq!(user_usdc_balance_after_3, user_usdc_balance_before_3 - opening_fee_3); + + let final_expected_size = Int128::new(200 + 200 + 200); + assert_position_size(&mock, &account_id, &atom.denom, final_expected_size); + let vault_balance_3 = assert_vault_balance_increase(&mock, vault_balance_2, opening_fee_3); + + // Market accounting after step 3 (opening fees accumulate) + let acct_after_3 = query_market_acct(&mock); + let expected_opening_fees_total = opening_fee_1 + opening_fee_2 + opening_fee_3; + assert_eq!( + acct_after_3.accounting.cash_flow.opening_fee, + Int128::try_from(expected_opening_fees_total).unwrap() + ); + assert_eq!(acct_after_3.accounting.cash_flow.price_pnl, Int128::zero()); + assert_eq!(acct_after_3.accounting.cash_flow.accrued_funding, Int128::zero()); + + // Step 4: Validate total fees collected and user balance changes + let total_fees_collected = vault_balance_3 - initial_vault_balance; + let expected_total_fees = opening_fee_1 + opening_fee_2 + opening_fee_3; + assert_eq!(total_fees_collected, expected_total_fees); + + // Verify total user balance decrease equals total fees paid + let total_user_balance_decrease = user_usdc_balance_before_1 - user_usdc_balance_after_3; + assert_eq!(total_user_balance_decrease, expected_total_fees); + + // Step 5: Close position with current tier (Tier 5) + // Pre-calc expected closing fee from query + let closing_fee_estimate: mars_types::perps::PositionFeesResponse = mock + .app + .wrap() + .query_wasm_smart( + mock.perps.address(), + &mars_types::perps::QueryMsg::PositionFees { + account_id: account_id.clone(), + denom: atom.denom.clone(), + new_size: Int128::zero(), + }, + ) + .unwrap(); + let close_res = mock + .update_credit_account( + &account_id, + &user, + vec![ClosePerpPosition { + denom: atom.denom.clone(), + }], + &[], + ) + .unwrap(); + + let close_attrs = find_attrs(&close_res); + assert_eq!(close_attrs.get("action").unwrap(), "execute_perp_order"); + assert_eq!(close_attrs.get("discount_pct").unwrap(), &Decimal::percent(45).to_string()); + assert_eq!(close_attrs.get("tier_id").unwrap(), "tier_5"); + + // Verify position is closed + let position_after_close = mock.query_perp_position(&account_id, &atom.denom); + assert!(position_after_close.position.is_none()); + + // Market accounting after close: opening fees + closing fee must be realized, no unrealized pnl + let acct_after_close = query_market_acct(&mock); + let expected_opening_total_i = Int128::try_from(expected_total_fees).unwrap(); + assert_eq!(acct_after_close.accounting.cash_flow.opening_fee, expected_opening_total_i); + assert_eq!(acct_after_close.accounting.cash_flow.price_pnl, Int128::zero()); + assert_eq!(acct_after_close.accounting.cash_flow.accrued_funding, Int128::zero()); + // closing fee equals estimate + assert_eq!( + acct_after_close.accounting.cash_flow.closing_fee, + Int128::try_from(closing_fee_estimate.closing_fee).unwrap() + ); +} + +#[test] +fn test_perp_fee_discount_comparison_two_users() { + // Test scenario: Compare fee discounts between two users with different voting power tiers + // User 1: 0 voting power (tier 1, 0% discount) + // User 2: 250,000 MARS voting power (tier 5, 45% discount) + // Both users will: create position, increase position, reduce position, close position + // After each operation, we'll query their balances to verify fee changes + + let atom = uatom_info(); + let usdc = coin_info("uusdc"); + + // Setup two users + let user1 = Addr::unchecked("user1"); + let user2 = Addr::unchecked("user2"); + + let mut mock = MockEnv::new() + .set_params(&[atom.clone(), usdc.clone()]) + .fund_account(AccountToFund { + addr: user1.clone(), + funds: vec![Coin::new(10_000_000, usdc.denom.clone())], + }) + .fund_account(AccountToFund { + addr: user2.clone(), + funds: vec![Coin::new(10_000_000, usdc.denom.clone())], + }) + .build() + .unwrap(); + + // Create credit accounts for both users + let account_id_1 = mock.create_credit_account(&user1).unwrap(); + let account_id_2 = mock.create_credit_account(&user2).unwrap(); + + // Setup perps params and fund vault + mock.update_perp_params(PerpParamsUpdate::AddOrUpdate { + params: default_perp_params(&atom.denom), + }); + let rover_addr = mock.rover.clone(); + let usdc_denom = usdc.denom.clone(); + mock.fund_addr(&rover_addr, vec![Coin::new(200_000_000, usdc_denom.clone())]); + mock.deposit_to_perp_vault(&account_id_1, &Coin::new(50_000_000, usdc_denom.clone()), None) + .unwrap(); + mock.deposit_to_perp_vault(&account_id_2, &Coin::new(50_000_000, usdc_denom), None).unwrap(); + + // Set voting power for both users + mock.set_voting_power(&user1, Uint128::new(0)); // Tier 1: 0% discount + mock.set_voting_power(&user2, Uint128::new(250_000_000_000)); // Tier 5: 45% discount + + // Deposit USDC into both users' credit accounts for position health + mock.update_credit_account( + &account_id_1, + &user1, + vec![Deposit(Coin::new(5_000_000, usdc.denom.clone()))], + &[Coin::new(5_000_000, usdc.denom.clone())], + ) + .unwrap(); + + mock.update_credit_account( + &account_id_2, + &user2, + vec![Deposit(Coin::new(5_000_000, usdc.denom.clone()))], + &[Coin::new(5_000_000, usdc.denom.clone())], + ) + .unwrap(); + + let initial_vault_balance = mock.query_balance(mock.perps.address(), "uusdc").amount; + + // Helper function to get user USDC balance + let get_user_usdc_balance = |mock: &MockEnv, account_id: &str| -> Uint128 { + let position = mock.query_positions(account_id); + get_coin("uusdc", &position.deposits).amount + }; + + // Helper function to execute perp order and return fee paid + let execute_perp_and_track_fee = |mock: &mut MockEnv, + account_id: &str, + user: &Addr, + size: i128| + -> (AppResponse, Uint128, Uint128) { + let balance_before = get_user_usdc_balance(mock, account_id); + let res = mock + .update_credit_account( + account_id, + user, + vec![ExecutePerpOrder { + denom: atom.denom.clone(), + order_size: Int128::new(size), + reduce_only: None, + order_type: Some(ExecutePerpOrderType::Default), + }], + &[], + ) + .unwrap(); + let balance_after = get_user_usdc_balance(mock, account_id); + let fee_paid = balance_before - balance_after; + (res, balance_before, fee_paid) + }; + + // Helper function to close position and return fee paid + let close_position_and_track_fee = + |mock: &mut MockEnv, account_id: &str, user: &Addr| -> (AppResponse, Uint128, Uint128) { + let balance_before = get_user_usdc_balance(mock, account_id); + let res = mock + .update_credit_account( + account_id, + user, + vec![ClosePerpPosition { + denom: atom.denom.clone(), + }], + &[], + ) + .unwrap(); + let balance_after = get_user_usdc_balance(mock, account_id); + let fee_paid = balance_before - balance_after; + (res, balance_before, fee_paid) + }; + + // Helper function to extract discount from response + let extract_discount = |res: &AppResponse| -> Decimal { + let attrs = find_attrs(res); + Decimal::from_str(attrs.get("discount_pct").unwrap()).unwrap() + }; + + // OPERATION 1: Create initial positions (size: 200,000) + // User 1 creates position with 0% discount (Tier 1) + let (res1_create, _, fee_1_create) = + execute_perp_and_track_fee(&mut mock, &account_id_1, &user1, 200000); + let discount_1_create = extract_discount(&res1_create); + assert_eq!(discount_1_create, Decimal::percent(0)); // Verify 0% discount for Tier 1 + + // User 2 creates position with 45% discount (Tier 5) + let (res2_create, _, fee_2_create) = + execute_perp_and_track_fee(&mut mock, &account_id_2, &user2, 200000); + let discount_2_create = extract_discount(&res2_create); + assert_eq!(discount_2_create, Decimal::percent(45)); // Verify 45% discount for Tier 5 + + // Verify User 2 paid significantly less fee due to discount + assert!(fee_2_create < fee_1_create, "User 2 should pay less fee due to 45% discount"); + + // Verify positions were created with correct sizes + assert_position_size(&mock, &account_id_1, &atom.denom, Int128::new(200000)); + assert_position_size(&mock, &account_id_2, &atom.denom, Int128::new(200000)); + + // OPERATION 2: Increase positions (size: +100,000) + // User 1 increases position with 0% discount (Tier 1) + let (res1_increase, _, fee_1_increase) = + execute_perp_and_track_fee(&mut mock, &account_id_1, &user1, 100000); + let discount_1_increase = extract_discount(&res1_increase); + assert_eq!(discount_1_increase, Decimal::percent(0)); // Verify 0% discount maintained + + // User 2 increases position with 45% discount (Tier 5) + let (res2_increase, _, fee_2_increase) = + execute_perp_and_track_fee(&mut mock, &account_id_2, &user2, 100000); + let discount_2_increase = extract_discount(&res2_increase); + assert_eq!(discount_2_increase, Decimal::percent(45)); // Verify 45% discount maintained + + // Verify User 2 continues to pay less fee due to discount + assert!(fee_2_increase < fee_1_increase, "User 2 should pay less fee due to 45% discount"); + + // Verify positions were increased to correct total sizes (200k + 100k = 300k) + assert_position_size(&mock, &account_id_1, &atom.denom, Int128::new(300000)); + assert_position_size(&mock, &account_id_2, &atom.denom, Int128::new(300000)); + + // OPERATION 3: Reduce positions (size: -50,000) + // User 1 reduces position with 0% discount (Tier 1) + let (res1_reduce, _, fee_1_reduce) = + execute_perp_and_track_fee(&mut mock, &account_id_1, &user1, -50000); + let discount_1_reduce = extract_discount(&res1_reduce); + assert_eq!(discount_1_reduce, Decimal::percent(0)); // Verify 0% discount maintained + + // User 2 reduces position with 45% discount (Tier 5) + let (res2_reduce, _, fee_2_reduce) = + execute_perp_and_track_fee(&mut mock, &account_id_2, &user2, -50000); + let discount_2_reduce = extract_discount(&res2_reduce); + assert_eq!(discount_2_reduce, Decimal::percent(45)); // Verify 45% discount maintained + + // Verify User 2 continues to pay less fee due to discount + assert!(fee_2_reduce < fee_1_reduce, "User 2 should pay less fee due to 45% discount"); + + // Verify positions were reduced to correct total sizes (300k - 50k = 250k) + assert_position_size(&mock, &account_id_1, &atom.denom, Int128::new(250000)); + assert_position_size(&mock, &account_id_2, &atom.denom, Int128::new(250000)); + + // OPERATION 4: Close positions (remaining 250,000) + // User 1 closes position with 0% discount (Tier 1) + let (res1_close, _, fee_1_close) = + close_position_and_track_fee(&mut mock, &account_id_1, &user1); + let discount_1_close = extract_discount(&res1_close); + assert_eq!(discount_1_close, Decimal::percent(0)); // Verify 0% discount maintained + + // User 2 closes position with 45% discount (Tier 5) + let (res2_close, _, fee_2_close) = + close_position_and_track_fee(&mut mock, &account_id_2, &user2); + let discount_2_close = extract_discount(&res2_close); + assert_eq!(discount_2_close, Decimal::percent(45)); // Verify 45% discount maintained + + // Verify User 2 continues to pay less fee due to discount + assert!(fee_2_close < fee_1_close, "User 2 should pay less fee due to 45% discount"); + + // Verify positions were completely closed (size 0) + let position_1_after_close = mock.query_perp_position(&account_id_1, &atom.denom); + let position_2_after_close = mock.query_perp_position(&account_id_2, &atom.denom); + assert!(position_1_after_close.position.is_none()); // User 1 position closed + assert!(position_2_after_close.position.is_none()); // User 2 position closed + + // SUMMARY: Calculate and verify total fee differences + // Calculate total fees paid by each user across all operations + let total_fees_user1 = fee_1_create + fee_1_increase + fee_1_reduce + fee_1_close; + let total_fees_user2 = fee_2_create + fee_2_increase + fee_2_reduce + fee_2_close; + + // Verify total fees collected by vault matches sum of both users' fees + let final_vault_balance = mock.query_balance(mock.perps.address(), "uusdc").amount; + let total_fees_collected = final_vault_balance - initial_vault_balance; + let expected_total_fees = total_fees_user1 + total_fees_user2; + assert_eq!( + total_fees_collected, expected_total_fees, + "Vault should have collected the sum of both users' fees" + ); + + // Verify that User 2's total fees are approximately 45% less than User 1's + // (allowing for rounding differences due to fee calculations) + let expected_user2_fees = total_fees_user1 * Decimal::percent(55); // 100% - 45% = 55% + let fee_difference = if total_fees_user2 > expected_user2_fees { + total_fees_user2 - expected_user2_fees + } else { + expected_user2_fees - total_fees_user2 + }; + + // Allow for small rounding differences (within 2 units due to larger discount) + assert!(fee_difference <= Uint128::new(2), + "User 2's total fees should be approximately 45% less than User 1's. Expected: {}, Actual: {}, Difference: {}", + expected_user2_fees, total_fees_user2, fee_difference); + + // Verify the discount is working correctly by checking the percentage saved + let actual_discount_percentage = + ((total_fees_user1 - total_fees_user2) * Uint128::new(100)) / total_fees_user1; + assert!( + actual_discount_percentage >= Uint128::new(40) + && actual_discount_percentage <= Uint128::new(50), + "Actual discount should be close to 45%. Got: {}%", + actual_discount_percentage + ); +} diff --git a/contracts/credit-manager/tests/tests/test_staking_tiers.rs b/contracts/credit-manager/tests/tests/test_staking_tiers.rs new file mode 100644 index 00000000..7cea0fad --- /dev/null +++ b/contracts/credit-manager/tests/tests/test_staking_tiers.rs @@ -0,0 +1,726 @@ +use cosmwasm_std::{Decimal, Uint128}; +use mars_credit_manager::{error::ContractError, staking::StakingTierManager}; +use mars_types::fee_tiers::{FeeTier, FeeTierConfig}; +use test_case::test_case; + +// Test data based on the tier breakdown provided +fn create_test_fee_tier_config() -> FeeTierConfig { + FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_8".to_string(), + min_voting_power: Uint128::new(1500000000000), // 1,500,000 MARS + discount_pct: Decimal::percent(80), + }, + FeeTier { + id: "tier_7".to_string(), + min_voting_power: Uint128::new(1000000000000), // 1,000,000 MARS + discount_pct: Decimal::percent(70), + }, + FeeTier { + id: "tier_6".to_string(), + min_voting_power: Uint128::new(500000000000), // 500,000 MARS + discount_pct: Decimal::percent(60), + }, + FeeTier { + id: "tier_5".to_string(), + min_voting_power: Uint128::new(250000000000), // 250,000 MARS + discount_pct: Decimal::percent(45), + }, + FeeTier { + id: "tier_4".to_string(), + min_voting_power: Uint128::new(100000000000), // 100,000 MARS + discount_pct: Decimal::percent(30), + }, + FeeTier { + id: "tier_3".to_string(), + min_voting_power: Uint128::new(50000000000), // 50,000 MARS + discount_pct: Decimal::percent(20), + }, + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::new(10000000000), // 10,000 MARS + discount_pct: Decimal::percent(10), + }, + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::zero(), + discount_pct: Decimal::percent(0), + }, + ], + } +} + +#[test] +fn test_staking_tier_manager_creation() { + let config = create_test_fee_tier_config(); + let manager = StakingTierManager::new(config); + + assert_eq!(manager.config.tiers.len(), 8); + assert_eq!(manager.config.tiers[0].id, "tier_8"); + assert_eq!(manager.config.tiers[7].id, "tier_1"); +} + +#[test_case( + Uint128::new(1500000000000), + "tier_8", + Decimal::percent(80); + "exact match tier 8" +)] +#[test_case( + Uint128::new(1000000000000), + "tier_7", + Decimal::percent(70); + "exact match tier 7" +)] +#[test_case( + Uint128::new(500000000000), + "tier_6", + Decimal::percent(60); + "exact match tier 6" +)] +#[test_case( + Uint128::new(250000000000), + "tier_5", + Decimal::percent(45); + "exact match tier 5" +)] +#[test_case( + Uint128::new(100000000000), + "tier_4", + Decimal::percent(30); + "exact match tier 4" +)] +#[test_case( + Uint128::new(50000000000), + "tier_3", + Decimal::percent(20); + "exact match tier 3" +)] +#[test_case( + Uint128::new(10000000000), + "tier_2", + Decimal::percent(10); + "exact match tier 2" +)] +#[test_case( + Uint128::new(0), + "tier_1", + Decimal::percent(0); + "exact match tier 1" +)] +fn test_find_applicable_tier_exact_matches( + voting_power: Uint128, + expected_tier_id: &str, + expected_discount: Decimal, +) { + let config = create_test_fee_tier_config(); + let manager = StakingTierManager::new(config); + + let tier = manager.find_applicable_tier(voting_power).unwrap(); + assert_eq!(tier.id, expected_tier_id); + assert_eq!(tier.discount_pct, expected_discount); +} + +#[test_case( + Uint128::new(1200000000000), + "tier_7", + Decimal::percent(70); + "between tier 6 and tier 7" +)] +#[test_case( + Uint128::new(750000000000), + "tier_6", + Decimal::percent(60); + "between tier 5 and tier 6" +)] +#[test_case( + Uint128::new(300000000000), + "tier_5", + Decimal::percent(45); + "between tier 4 and tier 5" +)] +#[test_case( + Uint128::new(150000000000), + "tier_4", + Decimal::percent(30); + "between tier 3 and tier 4" +)] +#[test_case( + Uint128::new(75000000000), + "tier_3", + Decimal::percent(20); + "between tier 2 and tier 3" +)] +#[test_case( + Uint128::new(15000000000), + "tier_2", + Decimal::percent(10); + "between tier 1 and tier 2" +)] +#[test_case( + Uint128::new(5000000000), + "tier_1", + Decimal::percent(0); + "between tier 0 and tier 1" +)] +fn test_find_applicable_tier_between_thresholds( + voting_power: Uint128, + expected_tier_id: &str, + expected_discount: Decimal, +) { + let config = create_test_fee_tier_config(); + let manager = StakingTierManager::new(config); + + let tier = manager.find_applicable_tier(voting_power).unwrap(); + assert_eq!(tier.id, expected_tier_id); + assert_eq!(tier.discount_pct, expected_discount); +} + +#[test_case( + Uint128::new(2000000000000), + "tier_8", + Decimal::percent(80); + "above highest tier threshold" +)] +#[test_case( + Uint128::new(3000000000000), + "tier_8", + Decimal::percent(80); + "well above highest tier threshold" +)] +fn test_find_applicable_tier_above_highest( + voting_power: Uint128, + expected_tier_id: &str, + expected_discount: Decimal, +) { + let config = create_test_fee_tier_config(); + let manager = StakingTierManager::new(config); + + let tier = manager.find_applicable_tier(voting_power).unwrap(); + assert_eq!(tier.id, expected_tier_id); + assert_eq!(tier.discount_pct, expected_discount); +} + +#[test_case( + Uint128::new(1), + "tier_1", + Decimal::percent(0); + "edge case: minimal voting power" +)] +#[test_case( + Uint128::new(9999000000), + "tier_1", + Decimal::percent(0); + "edge case: just below tier 2" +)] +#[test_case( + Uint128::new(10001000000), + "tier_2", + Decimal::percent(10); + "edge case: just above tier 1" +)] +fn test_find_applicable_tier_edge_cases( + voting_power: Uint128, + expected_tier_id: &str, + expected_discount: Decimal, +) { + let config = create_test_fee_tier_config(); + let manager = StakingTierManager::new(config); + + let tier = manager.find_applicable_tier(voting_power).unwrap(); + assert_eq!(tier.id, expected_tier_id); + assert_eq!(tier.discount_pct, expected_discount); +} + +#[test] +fn test_validate_fee_tier_config_valid() { + let config = create_test_fee_tier_config(); + let manager = StakingTierManager::new(config); + + // Should not panic for valid config + let result = manager.validate(); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_fee_tier_config_empty() { + let config = FeeTierConfig { + tiers: vec![], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ContractError::EmptyFeeTierConfig); +} + +#[test] +fn test_validate_fee_tier_config_unsorted() { + let config = FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::new(200000), + discount_pct: Decimal::percent(60), + }, + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(350000), + discount_pct: Decimal::percent(75), + }, + ], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ContractError::TiersNotSortedDescending); +} + +#[test] +fn test_validate_fee_tier_config_duplicate_thresholds() { + let config = FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(350000), + discount_pct: Decimal::percent(75), + }, + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::new(350000), + discount_pct: Decimal::percent(60), + }, + ], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ContractError::DuplicateVotingPowerThresholds); +} + +#[test] +fn test_validate_fee_tier_config_invalid_discount() { + let config = FeeTierConfig { + tiers: vec![FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(350000), + discount_pct: Decimal::percent(150), // 150% discount (invalid - over 100%) + }], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ContractError::InvalidDiscountPercentage); +} + +#[test] +fn test_get_default_tier() { + let config = create_test_fee_tier_config(); + let manager = StakingTierManager::new(config); + + let default_tier = manager.get_default_tier().unwrap(); + assert_eq!(default_tier.id, "tier_1"); + assert_eq!(default_tier.discount_pct, Decimal::percent(0)); +} + +#[test_case( + Uint128::new(1500000000000), + Decimal::percent(80); + "tier 8: highest discount" +)] +#[test_case( + Uint128::new(1000000000000), + Decimal::percent(70); + "tier 7: high discount" +)] +#[test_case( + Uint128::new(500000000000), + Decimal::percent(60); + "tier 6: medium-high discount" +)] +#[test_case( + Uint128::new(250000000000), + Decimal::percent(45); + "tier 5: medium discount" +)] +#[test_case( + Uint128::new(100000000000), + Decimal::percent(30); + "tier 4: medium-low discount" +)] +#[test_case( + Uint128::new(50000000000), + Decimal::percent(20); + "tier 3: low discount" +)] +#[test_case( + Uint128::new(10000000000), + Decimal::percent(10); + "tier 2: very low discount" +)] +#[test_case( + Uint128::new(0), + Decimal::percent(0); + "tier 1: no discount" +)] +fn test_discount_calculation_examples(voting_power: Uint128, expected_discount: Decimal) { + let config = create_test_fee_tier_config(); + let manager = StakingTierManager::new(config); + + let tier = manager.find_applicable_tier(voting_power).unwrap(); + assert_eq!(tier.discount_pct, expected_discount, "Failed for voting power: {}", voting_power); +} + +#[test] +fn test_fee_tier_config_with_single_tier() { + let config = FeeTierConfig { + tiers: vec![FeeTier { + id: "single_tier".to_string(), + min_voting_power: Uint128::zero(), + discount_pct: Decimal::percent(25), + }], + }; + let manager = StakingTierManager::new(config); + + // Should always return the single tier + let tier = manager.find_applicable_tier(Uint128::new(1000)).unwrap(); + assert_eq!(tier.id, "single_tier"); + assert_eq!(tier.discount_pct, Decimal::percent(25)); + + let tier = manager.find_applicable_tier(Uint128::new(0)).unwrap(); + assert_eq!(tier.id, "single_tier"); + assert_eq!(tier.discount_pct, Decimal::percent(25)); +} + +#[test] +fn test_fee_tier_config_with_two_tiers() { + let config = FeeTierConfig { + tiers: vec![ + FeeTier { + id: "high_tier".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::percent(50), + }, + FeeTier { + id: "low_tier".to_string(), + min_voting_power: Uint128::zero(), + discount_pct: Decimal::percent(10), + }, + ], + }; + let manager = StakingTierManager::new(config); + + // Test high tier + let tier = manager.find_applicable_tier(Uint128::new(1500)).unwrap(); + assert_eq!(tier.id, "high_tier"); + assert_eq!(tier.discount_pct, Decimal::percent(50)); + + // Test low tier + let tier = manager.find_applicable_tier(Uint128::new(500)).unwrap(); + assert_eq!(tier.id, "low_tier"); + assert_eq!(tier.discount_pct, Decimal::percent(10)); + + // Test boundary + let tier = manager.find_applicable_tier(Uint128::new(1000)).unwrap(); + assert_eq!(tier.id, "high_tier"); + assert_eq!(tier.discount_pct, Decimal::percent(50)); +} + +// ===== VALIDATION TEST CASES ===== + +#[test] +fn test_validation_empty_tiers() { + let config = FeeTierConfig { + tiers: vec![], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ContractError::EmptyFeeTierConfig); +} + +#[test] +fn test_validation_single_tier_valid() { + let config = FeeTierConfig { + tiers: vec![FeeTier { + id: "single".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::percent(25), + }], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_ok()); +} + +#[test] +fn test_validation_multiple_tiers_valid() { + let config = create_test_fee_tier_config(); + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_ok()); +} + +#[test] +fn test_validation_duplicate_voting_power() { + let config = FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::percent(50), + }, + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::new(1000), // Duplicate! + discount_pct: Decimal::percent(25), + }, + ], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ContractError::DuplicateVotingPowerThresholds); +} + +#[test] +fn test_validation_not_descending_order() { + let config = FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::percent(50), + }, + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::new(2000), // Higher than previous! + discount_pct: Decimal::percent(25), + }, + ], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ContractError::TiersNotSortedDescending); +} + +#[test] +fn test_validation_equal_voting_power() { + let config = FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::percent(50), + }, + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::new(1000), // Equal to previous! + discount_pct: Decimal::percent(25), + }, + ], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ContractError::DuplicateVotingPowerThresholds); +} + +#[test] +fn test_validation_valid_voting_power_format() { + // This test is no longer needed since Uint128 type ensures valid format + // But we can test that valid Uint128 values work correctly + let config = FeeTierConfig { + tiers: vec![FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::percent(50), + }], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_ok()); +} + +#[test] +fn test_validation_discount_100_percent() { + let config = FeeTierConfig { + tiers: vec![FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::one(), // 100% discount - now valid! + }], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_ok()); // 100% discount should now be valid +} + +#[test] +fn test_validation_discount_over_100_percent() { + let config = FeeTierConfig { + tiers: vec![FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::percent(150), // 150% discount! + }], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ContractError::InvalidDiscountPercentage); +} + +#[test] +fn test_validation_discount_99_percent_valid() { + let config = FeeTierConfig { + tiers: vec![FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::percent(99), // 99% discount - valid! + }], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_ok()); +} + +#[test] +fn test_validation_zero_discount_valid() { + let config = FeeTierConfig { + tiers: vec![FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(1000), + discount_pct: Decimal::zero(), // 0% discount - valid! + }], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_ok()); +} + +#[test] +fn test_validation_complex_scenario() { + let config = FeeTierConfig { + tiers: vec![ + FeeTier { + id: "platinum".to_string(), + min_voting_power: Uint128::new(1000000), + discount_pct: Decimal::percent(90), + }, + FeeTier { + id: "gold".to_string(), + min_voting_power: Uint128::new(500000), + discount_pct: Decimal::percent(75), + }, + FeeTier { + id: "silver".to_string(), + min_voting_power: Uint128::new(100000), + discount_pct: Decimal::percent(50), + }, + FeeTier { + id: "bronze".to_string(), + min_voting_power: Uint128::new(10000), + discount_pct: Decimal::percent(25), + }, + FeeTier { + id: "basic".to_string(), + min_voting_power: Uint128::zero(), + discount_pct: Decimal::zero(), + }, + ], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_ok()); +} + +#[test] +fn test_validation_edge_case_single_digit() { + let config = FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::new(1), + discount_pct: Decimal::percent(10), + }, + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::zero(), + discount_pct: Decimal::zero(), + }, + ], + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + assert!(result.is_ok()); +} + +#[test_case( + 20, true, None; + "exactly 20 tiers (max allowed)" +)] +#[test_case( + 21, false, Some(ContractError::TooManyTiers { max_tiers: 20, provided_tiers: 21 }); + "21 tiers (exceeds maximum)" +)] +#[test_case( + 19, true, None; + "19 tiers (just under maximum)" +)] +#[test_case( + 1, true, None; + "single tier (well under maximum)" +)] +#[test_case( + 8, true, None; + "original test config (8 tiers)" +)] +fn test_validation_max_tier_size( + tier_count: usize, + should_pass: bool, + expected_error: Option, +) { + let mut tiers = Vec::new(); + for i in 0..tier_count { + tiers.push(FeeTier { + id: format!("tier_{}", i), + min_voting_power: Uint128::new(((tier_count - i) * 1000) as u128), // Descending order + discount_pct: Decimal::percent((i * 5) as u64), // 0% to max + }); + } + + let config = FeeTierConfig { + tiers, + }; + let manager = StakingTierManager::new(config); + + let result = manager.validate(); + + if should_pass { + assert!(result.is_ok()); + } else { + assert!(result.is_err()); + if let Some(expected_err) = expected_error { + assert_eq!(result.unwrap_err(), expected_err); + } + } +} diff --git a/contracts/credit-manager/tests/tests/test_swap_with_discount.rs b/contracts/credit-manager/tests/tests/test_swap_with_discount.rs new file mode 100644 index 00000000..57ce0c07 --- /dev/null +++ b/contracts/credit-manager/tests/tests/test_swap_with_discount.rs @@ -0,0 +1,204 @@ +use cosmwasm_std::{Addr, Coin, Decimal, Uint128}; +use cw_multi_test::AppResponse; +use mars_types::{ + credit_manager::Action::{Deposit, SwapExactIn}, + swapper::{OsmoRoute, OsmoSwap, SwapperRoute}, +}; + +use super::helpers::{uatom_info, uosmo_info, AccountToFund, MockEnv}; + +fn setup_env_with_swap_fee() -> (MockEnv, Addr, String) { + let atom = uatom_info(); + let osmo = uosmo_info(); + let user = Addr::unchecked("user"); + + let mut mock = MockEnv::new() + .set_params(&[osmo.clone(), atom.clone()]) + .fund_account(AccountToFund { + addr: user.clone(), + funds: vec![Coin::new(30_000, atom.denom.clone())], + }) + .swap_fee(Decimal::percent(1)) + .build() + .unwrap(); + + let account_id = mock.create_credit_account(&user).unwrap(); + (mock, user, account_id) +} + +fn do_swap( + mock: &mut MockEnv, + account_id: &str, + user: &Addr, + amount: u128, + denom_in: &str, + denom_out: &str, +) -> AppResponse { + let route = SwapperRoute::Osmo(OsmoRoute { + swaps: vec![OsmoSwap { + pool_id: 101, + to: denom_out.to_string(), + }], + }); + let estimate = mock.query_swap_estimate(&Coin::new(amount, denom_in), denom_out, route.clone()); + let min_receive = estimate.amount - Uint128::one(); + let atom = uatom_info(); + mock.update_credit_account( + account_id, + user, + vec![ + Deposit(Coin::new(amount, denom_in)), + SwapExactIn { + coin_in: atom.to_action_coin(amount), + denom_out: denom_out.to_string(), + min_receive, + route: Some(route), + }, + ], + &[Coin::new(amount, denom_in)], + ) + .unwrap() +} + +#[test] +fn test_swap_with_discount() { + let (mut mock, user, account_id) = setup_env_with_swap_fee(); + + // Helper to extract attributes for the swapper event + let extract = |res: &AppResponse| { + res.events + .iter() + .find(|e| e.attributes.iter().any(|a| a.key == "action" && a.value == "swapper")) + .unwrap() + .attributes + .iter() + .map(|a| (a.key.clone(), a.value.clone())) + .collect::>() + }; + + // Helper to extract and parse fee values + let extract_fees = |res: &AppResponse| { + let attrs = extract(res); + let base_fee = attrs.get("base_swap_fee").unwrap().parse::().unwrap(); + let effective_fee = attrs.get("effective_swap_fee").unwrap().parse::().unwrap(); + (base_fee, effective_fee) + }; + + // Tier 1 (min power 0) → 0% discount + mock.set_voting_power(&user, Uint128::new(0)); + let res = do_swap(&mut mock, &account_id, &user, 10_000, "uatom", "uosmo"); + let attrs = extract(&res); + assert_eq!(attrs.get("voting_power").unwrap(), "0"); + assert_eq!(attrs.get("tier_id").unwrap(), "tier_1"); + assert_eq!(attrs.get("discount_pct").unwrap(), &Decimal::percent(0).to_string()); + + // Verify fees: no discount means base_fee == effective_fee + let (base_fee, effective_fee) = extract_fees(&res); + assert_eq!(base_fee, Decimal::percent(1)); // 1% base fee + assert_eq!(effective_fee, Decimal::percent(1)); // No discount applied + + // Verify account balances for tier 1 (no discount) + let positions = mock.query_positions(&account_id); + assert_eq!(positions.deposits.len(), 1); + let osmo_deposit = positions.deposits.iter().find(|d| d.denom == "uosmo").unwrap(); + assert!(osmo_deposit.amount > Uint128::zero()); + + let atom_deposit = positions.deposits.iter().find(|d| d.denom == "uatom"); + assert!(atom_deposit.is_none()); + + // Verify Credit Manager contract balances + let rover_atom_balance = mock.query_balance(&mock.rover, "uatom"); + // Rover retains the effective swap fee in input denom (1% of 10,000 = 100) + assert_eq!(rover_atom_balance.amount, Uint128::new(100)); + + let rover_osmo_balance = mock.query_balance(&mock.rover, "uosmo"); + assert_eq!(rover_osmo_balance.amount, osmo_deposit.amount); + + // Verify user wallet balances - check that swap amount is correctly debited + let user_atom_balance = mock.query_balance(&user, "uatom"); + assert_eq!(user_atom_balance.amount, Uint128::new(30_000 - 10_000)); // 20,000 ATOM remaining (10,000 debited for swap) + + let user_osmo_balance = mock.query_balance(&user, "uosmo"); + assert_eq!(user_osmo_balance.amount, Uint128::zero()); + + // Tier 2 (>= 10_000 MARS) → 10% discount + mock.set_voting_power(&user, Uint128::new(10_000_000_000)); + let res = do_swap(&mut mock, &account_id, &user, 10_000, "uatom", "uosmo"); + let attrs = extract(&res); + assert_eq!(attrs.get("voting_power").unwrap(), "10000000000"); + assert_eq!(attrs.get("tier_id").unwrap(), "tier_2"); + assert_eq!(attrs.get("discount_pct").unwrap(), &Decimal::percent(10).to_string()); + + // Verify fees: 10% discount means effective_fee = base_fee * (1 - 0.1) = base_fee * 0.9 + let (base_fee, effective_fee) = extract_fees(&res); + assert_eq!(base_fee, Decimal::percent(1)); // 1% base fee + assert_eq!(effective_fee, Decimal::percent(1) * (Decimal::one() - Decimal::percent(10))); // 0.9% effective fee + + // Verify account balances for tier 2 (10% discount) + let positions = mock.query_positions(&account_id); + assert_eq!(positions.deposits.len(), 1); + let osmo_deposit = positions.deposits.iter().find(|d| d.denom == "uosmo").unwrap(); + assert!(osmo_deposit.amount > Uint128::zero()); + + let atom_deposit = positions.deposits.iter().find(|d| d.denom == "uatom"); + assert!(atom_deposit.is_none()); + + // Verify Credit Manager contract balances + let rover_atom_balance = mock.query_balance(&mock.rover, "uatom"); + // Cumulative retained fee: 100 (first) + 90 (second with 0.9%) = 190 + assert_eq!(rover_atom_balance.amount, Uint128::new(190)); + + let rover_osmo_balance = mock.query_balance(&mock.rover, "uosmo"); + assert_eq!(rover_osmo_balance.amount, osmo_deposit.amount); + + // Verify user wallet balances - check that swap amount is correctly debited + let user_atom_balance = mock.query_balance(&user, "uatom"); + assert_eq!(user_atom_balance.amount, Uint128::new(30_000 - 20_000)); // 10,000 ATOM remaining (20,000 debited for 2 swaps) + + let user_osmo_balance = mock.query_balance(&user, "uosmo"); + assert_eq!(user_osmo_balance.amount, Uint128::zero()); + + // Tier 5 (>= 250_000 MARS) → 45% discount + mock.set_voting_power(&user, Uint128::new(250_000_000_000)); + let res = do_swap(&mut mock, &account_id, &user, 10_000, "uatom", "uosmo"); + let attrs = extract(&res); + assert_eq!(attrs.get("voting_power").unwrap(), "250000000000"); + assert_eq!(attrs.get("tier_id").unwrap(), "tier_5"); + assert_eq!(attrs.get("discount_pct").unwrap(), &Decimal::percent(45).to_string()); + + // Verify fees: 45% discount means effective_fee = base_fee * (1 - 0.45) = base_fee * 0.55 + let (base_fee, effective_fee) = extract_fees(&res); + assert_eq!(base_fee, Decimal::percent(1)); // 1% base fee + assert_eq!(effective_fee, Decimal::percent(1) * (Decimal::one() - Decimal::percent(45))); // 0.55% effective fee + + // Verify account balances to ensure internal accounting is correct + let positions = mock.query_positions(&account_id); + assert_eq!(positions.deposits.len(), 1); + let osmo_deposit = positions.deposits.iter().find(|d| d.denom == "uosmo").unwrap(); + // Should have received OSMO from the swap (minus fees) + assert!(osmo_deposit.amount > Uint128::zero()); + + // Verify no ATOM remains in account (it was swapped) + let atom_deposit = positions.deposits.iter().find(|d| d.denom == "uatom"); + assert!(atom_deposit.is_none()); + + // Verify Credit Manager contract balances + let rover_atom_balance = mock.query_balance(&mock.rover, "uatom"); + // Cumulative retained fee: 190 + 55 (third with 0.55%) = 245 + assert_eq!(rover_atom_balance.amount, Uint128::new(245)); + + let rover_osmo_balance = mock.query_balance(&mock.rover, "uosmo"); + assert_eq!(rover_osmo_balance.amount, osmo_deposit.amount); // OSMO should match account deposit + + // Verify user wallet balances - check that swap amount is correctly debited + let user_atom_balance = mock.query_balance(&user, "uatom"); + assert_eq!(user_atom_balance.amount, Uint128::new(30_000 - 30_000)); // 0 ATOM remaining (30,000 debited for 3 swaps) + + let user_osmo_balance = mock.query_balance(&user, "uosmo"); + assert_eq!(user_osmo_balance.amount, Uint128::zero()); // No OSMO in user wallet + + assert!(res + .events + .iter() + .any(|e| e.attributes.iter().any(|a| a.key == "action" && a.value == "swapper"))); +} diff --git a/contracts/credit-manager/tests/tests/test_trading_fee.rs b/contracts/credit-manager/tests/tests/test_trading_fee.rs new file mode 100644 index 00000000..a5fe2d11 --- /dev/null +++ b/contracts/credit-manager/tests/tests/test_trading_fee.rs @@ -0,0 +1,221 @@ +use cosmwasm_std::{Addr, Decimal, Uint128}; +use mars_types::{credit_manager::TradingFeeResponse, params::PerpParamsUpdate, perps::MarketType}; +use test_case::test_case; + +use super::helpers::{default_perp_params, uosmo_info, MockEnv}; + +#[test_case( + Uint128::new(100_000_000_000), + "tier_4", + Decimal::percent(30), + Decimal::percent(1); + "spot market tier 4: 30% discount on 1% base fee" +)] +#[test_case( + Uint128::new(50_000_000_000), + "tier_3", + Decimal::percent(20), + Decimal::percent(1); + "spot market tier 3: 20% discount on 1% base fee" +)] +#[test_case( + Uint128::new(10_000_000_000), + "tier_2", + Decimal::percent(10), + Decimal::percent(1); + "spot market tier 2: 10% discount on 1% base fee" +)] +fn test_trading_fee_query_spot( + voting_power: Uint128, + expected_tier_id: &str, + expected_discount: Decimal, + expected_base_fee: Decimal, +) { + let mut mock = + MockEnv::new().set_params(&[uosmo_info()]).swap_fee(Decimal::percent(1)).build().unwrap(); + + // Create a credit account + let user = Addr::unchecked("user"); + let account_id = mock.create_credit_account(&user).unwrap(); + + // Set voting power for the specified tier + mock.set_voting_power(&user, voting_power); + + // Query trading fee for spot market + let response: TradingFeeResponse = mock + .app + .wrap() + .query_wasm_smart( + mock.rover.clone(), + &mars_types::credit_manager::QueryMsg::TradingFee { + account_id: account_id.clone(), + market_type: MarketType::Spot, + }, + ) + .unwrap(); + + // Verify the response + match response { + TradingFeeResponse::Spot(spot_response) => { + assert_eq!(spot_response.base_fee_pct, expected_base_fee); + assert_eq!(spot_response.discount_pct, expected_discount); + + // Calculate the expected effective fee based on the actual response + let calculated_effective = + spot_response.base_fee_pct.checked_mul(Decimal::one() - expected_discount).unwrap(); + assert_eq!(spot_response.effective_fee_pct, calculated_effective); + assert_eq!(spot_response.tier_id, expected_tier_id); + } + _ => panic!("Expected Spot response"), + } +} + +#[test_case( + Uint128::new(250_000_000_000), + "tier_5", + Decimal::percent(45), + "uosmo"; + "perp market tier 5: 45% discount on uosmo" +)] +#[test_case( + Uint128::new(500_000_000_000), + "tier_6", + Decimal::percent(60), + "uosmo"; + "perp market tier 6: 60% discount on uosmo" +)] +#[test_case( + Uint128::new(1_000_000_000_000), + "tier_7", + Decimal::percent(70), + "uosmo"; + "perp market tier 7: 70% discount on uosmo" +)] +fn test_trading_fee_query_perp( + voting_power: Uint128, + expected_tier_id: &str, + expected_discount: Decimal, + denom: &str, +) { + let mut mock = + MockEnv::new().set_params(&[uosmo_info()]).swap_fee(Decimal::percent(1)).build().unwrap(); + + // Create a credit account + let user = Addr::unchecked("user"); + let account_id = mock.create_credit_account(&user).unwrap(); + + // Set voting power for the specified tier + mock.set_voting_power(&user, voting_power); + + // Set up perp params for the specified denom + mock.update_perp_params(PerpParamsUpdate::AddOrUpdate { + params: default_perp_params(denom), + }); + + // Query trading fee for perp market + let response: TradingFeeResponse = mock + .app + .wrap() + .query_wasm_smart( + mock.rover.clone(), + &mars_types::credit_manager::QueryMsg::TradingFee { + account_id: account_id.clone(), + market_type: MarketType::Perp { + denom: denom.to_string(), + }, + }, + ) + .unwrap(); + + // Verify the response + match response { + TradingFeeResponse::Perp(perp_response) => { + assert_eq!(perp_response.discount_pct, expected_discount); + assert_eq!(perp_response.tier_id, expected_tier_id); + + // The effective fees should be base_fee * (1 - discount) + let expected_opening_effective = perp_response + .opening_fee_pct + .checked_mul(Decimal::one() - expected_discount) + .unwrap(); + let expected_closing_effective = perp_response + .closing_fee_pct + .checked_mul(Decimal::one() - expected_discount) + .unwrap(); + + assert_eq!(perp_response.effective_opening_fee_pct, expected_opening_effective); + assert_eq!(perp_response.effective_closing_fee_pct, expected_closing_effective); + } + _ => panic!("Expected Perp response"), + } +} + +#[test] +fn test_trading_fee_query_edge_cases() { + let mut mock = + MockEnv::new().set_params(&[uosmo_info()]).swap_fee(Decimal::percent(1)).build().unwrap(); + + // Create a credit account + let user = Addr::unchecked("user"); + let account_id = mock.create_credit_account(&user).unwrap(); + + // Test tier 8 (highest discount - 80%) + mock.set_voting_power(&user, Uint128::new(1_500_000_000_000)); + + let response: TradingFeeResponse = mock + .app + .wrap() + .query_wasm_smart( + mock.rover.clone(), + &mars_types::credit_manager::QueryMsg::TradingFee { + account_id: account_id.clone(), + market_type: MarketType::Spot, + }, + ) + .unwrap(); + + match response { + TradingFeeResponse::Spot(spot_response) => { + assert_eq!(spot_response.tier_id, "tier_8"); + assert_eq!(spot_response.discount_pct, Decimal::percent(80)); + + // Calculate the expected effective fee based on the actual response + let calculated_effective = spot_response + .base_fee_pct + .checked_mul(Decimal::one() - Decimal::percent(80)) + .unwrap(); + assert_eq!(spot_response.effective_fee_pct, calculated_effective); + } + _ => panic!("Expected Spot response"), + } + + // Test tier 1 (no discount - 0%) + mock.set_voting_power(&user, Uint128::new(0)); + + let response: TradingFeeResponse = mock + .app + .wrap() + .query_wasm_smart( + mock.rover.clone(), + &mars_types::credit_manager::QueryMsg::TradingFee { + account_id: account_id.clone(), + market_type: MarketType::Spot, + }, + ) + .unwrap(); + + match response { + TradingFeeResponse::Spot(spot_response) => { + assert_eq!(spot_response.tier_id, "tier_1"); + assert_eq!(spot_response.discount_pct, Decimal::percent(0)); + + // Calculate the expected effective fee based on the actual response + let calculated_effective = spot_response + .base_fee_pct + .checked_mul(Decimal::one() - Decimal::percent(0)) + .unwrap(); + assert_eq!(spot_response.effective_fee_pct, calculated_effective); + } + _ => panic!("Expected Spot response"), + } +} diff --git a/contracts/credit-manager/tests/tests/test_update_config.rs b/contracts/credit-manager/tests/tests/test_update_config.rs index 038fb01d..3d792fd8 100644 --- a/contracts/credit-manager/tests/tests/test_update_config.rs +++ b/contracts/credit-manager/tests/tests/test_update_config.rs @@ -41,6 +41,8 @@ fn only_owner_can_update_config() { keeper_fee_config: None, perps_liquidation_bonus_ratio: None, swap_fee: None, + fee_tier_config: None, + governance_address: None, }, ); @@ -128,6 +130,8 @@ fn update_config_works_with_full_config() { keeper_fee_config: Some(keeper_fee_config.clone()), perps_liquidation_bonus_ratio: Some(new_perps_lb_ratio), swap_fee: Some(new_swap_fee), + fee_tier_config: None, + governance_address: None, }, ) .unwrap(); diff --git a/contracts/health/tests/tests/helpers/mock_env_builder.rs b/contracts/health/tests/tests/helpers/mock_env_builder.rs index 96d7c0f7..bab04e2a 100644 --- a/contracts/health/tests/tests/helpers/mock_env_builder.rs +++ b/contracts/health/tests/tests/helpers/mock_env_builder.rs @@ -188,6 +188,7 @@ impl MockEnvBuilder { perps: "n/a".to_string(), keeper_fee_config: Default::default(), perps_liquidation_bonus_ratio: Decimal::percent(60), + governance: "n/a".to_string(), }, }, &[], diff --git a/contracts/mock-governance/Cargo.toml b/contracts/mock-governance/Cargo.toml new file mode 100644 index 00000000..7baa9b8e --- /dev/null +++ b/contracts/mock-governance/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mars-mock-governance" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +cosmwasm-schema = "1.5" +cosmwasm-std = "1.5" +cw-storage-plus = "1.2" +thiserror = "1" +mars-types = { path = "../../packages/types" } + + diff --git a/contracts/mock-governance/src/lib.rs b/contracts/mock-governance/src/lib.rs new file mode 100644 index 00000000..811f844c --- /dev/null +++ b/contracts/mock-governance/src/lib.rs @@ -0,0 +1,59 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ + entry_point, to_json_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Reply, Response, + StdResult, Uint128, +}; +use cw_storage_plus::Map; +use mars_types::adapters::governance::{ + GovernanceQueryMsg, VotingPowerAtHeightQuery, VotingPowerAtHeightResponse, +}; + +const VOTING_POWER: Map<&Addr, Uint128> = Map::new("voting_power"); + +#[cw_serde] +pub enum ExecMsg { + SetVotingPower { + address: String, + power: Uint128, + }, +} + +#[entry_point] +pub fn instantiate(_deps: DepsMut, _env: Env, _info: MessageInfo, _msg: ()) -> StdResult { + Ok(Response::new()) +} + +#[entry_point] +pub fn execute(deps: DepsMut, _env: Env, _info: MessageInfo, msg: ExecMsg) -> StdResult { + match msg { + ExecMsg::SetVotingPower { + address, + power, + } => { + let addr = deps.api.addr_validate(&address)?; + VOTING_POWER.save(deps.storage, &addr, &power)?; + Ok(Response::new()) + } + } +} + +#[entry_point] +pub fn query(deps: Deps, _env: Env, msg: GovernanceQueryMsg) -> StdResult { + match msg { + GovernanceQueryMsg::VotingPowerAtHeight(VotingPowerAtHeightQuery { + address, + }) => { + let addr = deps.api.addr_validate(&address)?; + let power = VOTING_POWER.may_load(deps.storage, &addr)?.unwrap_or(Uint128::zero()); + to_json_binary(&VotingPowerAtHeightResponse { + power, + height: 0, + }) + } + } +} + +#[entry_point] +pub fn reply(_deps: DepsMut, _env: Env, _reply: Reply) -> StdResult { + Ok(Response::new()) +} diff --git a/contracts/perps/Cargo.toml b/contracts/perps/Cargo.toml index 1c329e9a..c6832f4f 100644 --- a/contracts/perps/Cargo.toml +++ b/contracts/perps/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mars-perps" -version = "2.3.0" +version = "2.4.0" authors = { workspace = true } license = { workspace = true } edition = { workspace = true } diff --git a/contracts/perps/src/contract.rs b/contracts/perps/src/contract.rs index ada9d5ce..e05cc961 100644 --- a/contracts/perps/src/contract.rs +++ b/contracts/perps/src/contract.rs @@ -1,10 +1,10 @@ use cosmwasm_std::{ - entry_point, to_json_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Reply, Response, + entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Reply, Response, }; use mars_owner::OwnerInit; use mars_types::{ oracle::ActionKind, - perps::{ExecuteMsg, InstantiateMsg, QueryMsg}, + perps::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}, }; use crate::{ @@ -75,15 +75,22 @@ pub fn execute( ExecuteMsg::CloseAllPositions { account_id, action, - } => { - close_all_positions(deps, env, info, account_id, action.unwrap_or(ActionKind::Default)) - } + discount_pct, + } => close_all_positions( + deps, + env, + info, + account_id, + action.unwrap_or(ActionKind::Default), + discount_pct, + ), ExecuteMsg::ExecuteOrder { account_id, denom, size, reduce_only, - } => execute_order(deps, env, info, account_id, denom, size, reduce_only), + discount_pct, + } => execute_order(deps, env, info, account_id, denom, size, reduce_only, discount_pct), ExecuteMsg::Deleverage { account_id, denom, @@ -175,7 +182,8 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> ContractResult { QueryMsg::OpeningFee { denom, size, - } => to_json_binary(&query_opening_fee(deps, &denom, size)?), + discount_pct, + } => to_json_binary(&query_opening_fee(deps, &denom, size, discount_pct)?), QueryMsg::PositionFees { account_id, denom, @@ -189,6 +197,10 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> ContractResult { } #[cfg_attr(not(feature = "library"), entry_point)] -pub fn migrate(deps: DepsMut, _env: Env, _msg: Empty) -> Result { - migrations::v2_3_0::migrate(deps) +pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { + match msg { + MigrateMsg::V2_2_1ToV2_2_3 {} => migrations::v2_2_3::migrate(deps), + MigrateMsg::V2_2_3ToV2_3_0 {} => migrations::v2_3_0::migrate(deps), + MigrateMsg::V2_3_0ToV2_4_0 {} => migrations::v2_4_0::migrate(deps), + } } diff --git a/contracts/perps/src/deleverage.rs b/contracts/perps/src/deleverage.rs index 56685022..9ad9f260 100644 --- a/contracts/perps/src/deleverage.rs +++ b/contracts/perps/src/deleverage.rs @@ -18,13 +18,16 @@ use crate::{ error::{ContractError, ContractResult}, market::MarketStateExt, position::{PositionExt, PositionModification}, - position_management::apply_pnl_and_fees, + position_management::{apply_pnl_and_fees, compute_discounted_fee_rates}, query, state::{ DeleverageRequestTempStorage, CONFIG, DELEVERAGE_REQUEST_TEMP_STORAGE, MARKET_STATES, POSITIONS, REALIZED_PNL, TOTAL_CASH_FLOW, }, - utils::{get_oracle_adapter, get_params_adapter, update_position_attributes}, + utils::{ + get_credit_manager_adapter, get_oracle_adapter, get_params_adapter, + update_position_attributes, + }, }; pub const DELEVERAGE_REQUEST_REPLY_ID: u64 = 10_001; @@ -87,11 +90,12 @@ pub fn deleverage( let addresses = query_contract_addrs( deps.as_ref(), &cfg.address_provider, - vec![MarsAddressType::Oracle, MarsAddressType::Params], + vec![MarsAddressType::Oracle, MarsAddressType::Params, MarsAddressType::CreditManager], )?; let oracle = get_oracle_adapter(&addresses[&MarsAddressType::Oracle]); let params = get_params_adapter(&addresses[&MarsAddressType::Params]); + let credit_manager = get_credit_manager_adapter(&addresses[&MarsAddressType::CreditManager]); // Query prices and parameters let base_denom_price = @@ -114,14 +118,17 @@ pub fn deleverage( let initial_skew = ms.skew()?; ms.close_position(current_time, denom_price, base_denom_price, &position)?; + let discount_pct = credit_manager.query_discount_pct(&deps.querier, &account_id)?; + let (opening_fee_rate, closing_fee_rate) = + compute_discounted_fee_rates(&perp_params, Some(discount_pct))?; // Compute the position's unrealized PnL let pnl_amounts = position.compute_pnl( &ms.funding, initial_skew, denom_price, base_denom_price, - perp_params.opening_fee_rate, - perp_params.closing_fee_rate, + opening_fee_rate, + closing_fee_rate, PositionModification::Decrease(position.size), )?; @@ -160,6 +167,7 @@ pub fn deleverage( // Save updated states POSITIONS.remove(deps.storage, (&account_id, &denom)); + REALIZED_PNL.save(deps.storage, (&account_id, &denom), &realized_pnl)?; MARKET_STATES.save(deps.storage, &denom, &ms)?; TOTAL_CASH_FLOW.save(deps.storage, &tcf)?; diff --git a/contracts/perps/src/migrations/mod.rs b/contracts/perps/src/migrations/mod.rs index 2602415b..21426045 100644 --- a/contracts/perps/src/migrations/mod.rs +++ b/contracts/perps/src/migrations/mod.rs @@ -1,3 +1,4 @@ pub mod v2_2_1; pub mod v2_2_3; pub mod v2_3_0; +pub mod v2_4_0; diff --git a/contracts/perps/src/migrations/v2_2_1.rs b/contracts/perps/src/migrations/v2_2_1.rs index b7f741b6..b737d9f4 100644 --- a/contracts/perps/src/migrations/v2_2_1.rs +++ b/contracts/perps/src/migrations/v2_2_1.rs @@ -1,14 +1,13 @@ use cosmwasm_std::{DepsMut, Response}; use cw2::{assert_contract_version, set_contract_version}; -use crate::{ - contract::{CONTRACT_NAME, CONTRACT_VERSION}, - error::ContractError, - state::MARKET_STATES, -}; +use crate::{contract::CONTRACT_NAME, error::ContractError, state::MARKET_STATES}; const FROM_VERSION: &str = "2.2.0"; +// Hardcode the contract version so we don't break the tests +const CONTRACT_VERSION: &str = "2.3.0"; + pub fn migrate(deps: DepsMut) -> Result { // make sure we're migrating the correct contract and from the correct version assert_contract_version(deps.storage, CONTRACT_NAME, FROM_VERSION)?; diff --git a/contracts/perps/src/migrations/v2_2_3.rs b/contracts/perps/src/migrations/v2_2_3.rs index b40fe055..99263fd9 100644 --- a/contracts/perps/src/migrations/v2_2_3.rs +++ b/contracts/perps/src/migrations/v2_2_3.rs @@ -1,13 +1,13 @@ use cosmwasm_std::{DepsMut, Response}; use cw2::{assert_contract_version, set_contract_version}; -use crate::{ - contract::{CONTRACT_NAME, CONTRACT_VERSION}, - error::ContractError, -}; +use crate::{contract::CONTRACT_NAME, error::ContractError}; const FROM_VERSION: &str = "2.2.1"; +// Hardcode the contract version so we don't break the tests +const CONTRACT_VERSION: &str = "2.2.3"; + pub fn migrate(deps: DepsMut) -> Result { // Make sure we're migrating the correct contract and from the correct version assert_contract_version(deps.storage, &format!("crates.io:{CONTRACT_NAME}"), FROM_VERSION)?; diff --git a/contracts/perps/src/migrations/v2_3_0.rs b/contracts/perps/src/migrations/v2_3_0.rs index 60b74eac..37d67c45 100644 --- a/contracts/perps/src/migrations/v2_3_0.rs +++ b/contracts/perps/src/migrations/v2_3_0.rs @@ -1,13 +1,13 @@ use cosmwasm_std::{DepsMut, Response}; use cw2::{assert_contract_version, set_contract_version}; -use crate::{ - contract::{CONTRACT_NAME, CONTRACT_VERSION}, - error::ContractError, -}; +use crate::{contract::CONTRACT_NAME, error::ContractError}; const FROM_VERSION: &str = "2.2.3"; +// Hardcode the contract version so we don't break the tests +const CONTRACT_VERSION: &str = "2.3.0"; + pub fn migrate(deps: DepsMut) -> Result { // Make sure we're migrating the correct contract and from the correct version assert_contract_version(deps.storage, &format!("crates.io:{CONTRACT_NAME}"), FROM_VERSION)?; diff --git a/contracts/perps/src/migrations/v2_4_0.rs b/contracts/perps/src/migrations/v2_4_0.rs new file mode 100644 index 00000000..7b6def77 --- /dev/null +++ b/contracts/perps/src/migrations/v2_4_0.rs @@ -0,0 +1,22 @@ +use cosmwasm_std::{DepsMut, Response}; +use cw2::{assert_contract_version, set_contract_version}; + +use crate::{ + contract::{CONTRACT_NAME, CONTRACT_VERSION}, + error::ContractError, +}; + +const FROM_VERSION: &str = "2.3.0"; + +pub fn migrate(deps: DepsMut) -> Result { + // Make sure we're migrating the correct contract and from the correct version + assert_contract_version(deps.storage, &format!("crates.io:{CONTRACT_NAME}"), FROM_VERSION)?; + + // Update contract version + set_contract_version(deps.storage, format!("crates.io:{CONTRACT_NAME}"), CONTRACT_VERSION)?; + + Ok(Response::new() + .add_attribute("action", "migrate") + .add_attribute("from_version", FROM_VERSION) + .add_attribute("to_version", CONTRACT_VERSION)) +} diff --git a/contracts/perps/src/position_management.rs b/contracts/perps/src/position_management.rs index b18fcb87..4ca47091 100644 --- a/contracts/perps/src/position_management.rs +++ b/contracts/perps/src/position_management.rs @@ -25,6 +25,22 @@ use crate::{ }, }; +/// Helper function to compute discounted fee rates +pub fn compute_discounted_fee_rates( + perp_params: &PerpParams, + discount_pct: Option, +) -> Result<(Decimal, Decimal), ContractError> { + if let Some(discount) = discount_pct { + let discount_multiplier = Decimal::one().checked_sub(discount)?; + Ok(( + perp_params.opening_fee_rate.checked_mul(discount_multiplier)?, + perp_params.closing_fee_rate.checked_mul(discount_multiplier)?, + )) + } else { + Ok((perp_params.opening_fee_rate, perp_params.closing_fee_rate)) + } +} + /// Executes a perpetual order for a specific account and denom. /// /// Depending on whether a position exists and the reduce_only flag, this function either opens a new @@ -37,6 +53,7 @@ pub fn execute_order( denom: String, size: Int128, reduce_only: Option, + discount_pct: Option, ) -> ContractResult { let position = POSITIONS.may_load(deps.storage, (&account_id, &denom))?; let reduce_only_checked = reduce_only.unwrap_or(false); @@ -45,14 +62,13 @@ pub fn execute_order( None if reduce_only_checked => Err(ContractError::IllegalPositionModification { reason: "Cannot open position if reduce_only = true".to_string(), }), - None => open_position(deps, env, info, account_id, denom, size), + None => open_position(deps, env, info, account_id, denom, size, discount_pct), Some(position) => { let new_size = calculate_new_size(position.size, size, reduce_only_checked)?; - modify_position(deps, env, info, position, account_id, denom, new_size) + modify_position(deps, env, info, position, account_id, denom, new_size, discount_pct) } } } - /// Opens a new position for a specific account and denom. /// /// This function checks if the account can open a new position, validates the position parameters, @@ -64,6 +80,7 @@ fn open_position( account_id: String, denom: String, size: Int128, + discount_pct: Option, ) -> ContractResult { let cfg = CONFIG.load(deps.storage)?; @@ -120,7 +137,10 @@ fn open_position( // Params for the given market let perp_params = params.query_perp_params(&deps.querier, &denom)?; - // Find the opening fee amount + // Apply discount to fee rates if provided + let (opening_fee_rate, closing_fee_rate) = + compute_discounted_fee_rates(&perp_params, discount_pct)?; + let opening_fee_amt = may_pay(&info, &cfg.base_denom)?; // Query the asset's price. @@ -139,8 +159,8 @@ fn open_position( ensure_max_position(position_value, &perp_params)?; let fees = PositionModification::Increase(size).compute_fees( - perp_params.opening_fee_rate, - perp_params.closing_fee_rate, + opening_fee_rate, + closing_fee_rate, denom_price, base_denom_price, ms.skew()?, @@ -247,6 +267,7 @@ fn modify_position( account_id: String, denom: String, new_size: Int128, + discount_pct: Option, ) -> ContractResult { // Load the contract's configuration let cfg = CONFIG.load(deps.storage)?; @@ -273,6 +294,10 @@ fn modify_position( // Query the parameters for the given market (denom) let perp_params = params.query_perp_params(&deps.querier, &denom)?; + // Apply discount to fee rates if provided + let (opening_fee_rate, closing_fee_rate) = + compute_discounted_fee_rates(&perp_params, discount_pct)?; + // Load relevant state variables let mut realized_pnl = REALIZED_PNL.may_load(deps.storage, (&account_id, &denom))?.unwrap_or_default(); @@ -334,8 +359,8 @@ fn modify_position( initial_skew, denom_price, base_denom_price, - perp_params.opening_fee_rate, - perp_params.closing_fee_rate, + opening_fee_rate, + closing_fee_rate, modification, )?; @@ -427,6 +452,7 @@ pub fn close_all_positions( info: MessageInfo, account_id: String, action: ActionKind, + discount_pct: Option, ) -> ContractResult { let cfg = CONFIG.load(deps.storage)?; @@ -496,14 +522,18 @@ pub fn close_all_positions( // Funding rates and index is updated to the current block time (using old size). ms.close_position(env.block.time.seconds(), denom_price, base_denom_price, &position)?; + // Apply discount to fee rates if provided + let (opening_fee_rate, closing_fee_rate) = + compute_discounted_fee_rates(&perp_params, discount_pct)?; + // Compute the position's unrealized PnL let pnl_amounts = position.compute_pnl( &ms.funding, initial_skew, denom_price, base_denom_price, - perp_params.opening_fee_rate, - perp_params.closing_fee_rate, + opening_fee_rate, + closing_fee_rate, PositionModification::Decrease(position.size), )?; diff --git a/contracts/perps/src/query.rs b/contracts/perps/src/query.rs index f463ec99..482abfd1 100644 --- a/contracts/perps/src/query.rs +++ b/contracts/perps/src/query.rs @@ -23,11 +23,14 @@ use crate::{ error::ContractResult, market::{compute_total_accounting_data, MarketStateExt}, position::{PositionExt, PositionModification}, + position_management::compute_discounted_fee_rates, state::{ CONFIG, DEPOSIT_SHARES, MARKET_STATES, POSITIONS, REALIZED_PNL, TOTAL_UNLOCKING_OR_UNLOCKED_SHARES, UNLOCKS, VAULT_STATE, }, - utils::{create_user_id_key, get_oracle_adapter, get_params_adapter}, + utils::{ + create_user_id_key, get_credit_manager_adapter, get_oracle_adapter, get_params_adapter, + }, vault::shares_to_amount, }; @@ -277,11 +280,13 @@ pub fn query_position( let addresses = query_contract_addrs( deps, &cfg.address_provider, - vec![MarsAddressType::Oracle, MarsAddressType::Params], + vec![MarsAddressType::Oracle, MarsAddressType::Params, MarsAddressType::CreditManager], )?; let oracle = get_oracle_adapter(&addresses[&MarsAddressType::Oracle]); let params = get_params_adapter(&addresses[&MarsAddressType::Params]); + let credit_manager = addresses[&MarsAddressType::CreditManager].clone(); + let credit_manager_adapter = get_credit_manager_adapter(&credit_manager); let denom_price = oracle.query_price(&deps.querier, &denom, ActionKind::Default)?.price; let base_denom_price = @@ -306,13 +311,18 @@ pub fn query_position( None => PositionModification::Decrease(position.size), }; + // Query the credit manager to get the discount for this account via adapter + let discount_pct = credit_manager_adapter.query_discount_pct(&deps.querier, &account_id)?; + let (discounted_opening_fee_rate, discounted_closing_fee_rate) = + compute_discounted_fee_rates(&perp_params, Some(discount_pct))?; + let pnl_amounts = position.compute_pnl( &curr_funding, ms.skew()?, denom_price, base_denom_price, - perp_params.opening_fee_rate, - perp_params.closing_fee_rate, + discounted_opening_fee_rate, + discounted_closing_fee_rate, modification, )?; @@ -323,7 +333,7 @@ pub fn query_position( account_id, position: Some(PerpPosition { denom, - base_denom: cfg.base_denom, + base_denom: cfg.base_denom.clone(), size: position.size, entry_price: position.entry_price, current_price: denom_price, @@ -351,11 +361,13 @@ pub fn query_positions( let addresses = query_contract_addrs( deps, &cfg.address_provider, - vec![MarsAddressType::Oracle, MarsAddressType::Params], + vec![MarsAddressType::Oracle, MarsAddressType::Params, MarsAddressType::CreditManager], )?; let oracle = get_oracle_adapter(&addresses[&MarsAddressType::Oracle]); let params = get_params_adapter(&addresses[&MarsAddressType::Params]); + let credit_manager = addresses[&MarsAddressType::CreditManager].clone(); + let credit_manager_adapter = get_credit_manager_adapter(&credit_manager); let start = start_after .as_ref() @@ -394,13 +406,19 @@ pub fn query_positions( (price, params, curr_funding, skew) }; + // Query the credit manager to get the discount for this account via adapter + let discount_pct = + credit_manager_adapter.query_discount_pct(&deps.querier, &account_id)?; + let (discounted_opening_fee_rate, discounted_closing_fee_rate) = + compute_discounted_fee_rates(&perp_params, Some(discount_pct))?; + let pnl_amounts = position.compute_pnl( &funding, skew, current_price, base_denom_price, - perp_params.opening_fee_rate, - perp_params.closing_fee_rate, + discounted_opening_fee_rate, + discounted_closing_fee_rate, PositionModification::Decrease(position.size), )?; @@ -440,11 +458,17 @@ pub fn query_positions_by_account( let addresses = query_contract_addrs( deps, &cfg.address_provider, - vec![MarsAddressType::Oracle, MarsAddressType::Params], + vec![ + MarsAddressType::Oracle, + MarsAddressType::Params, + MarsAddressType::CreditManager, + MarsAddressType::Governance, + ], )?; let oracle = get_oracle_adapter(&addresses[&MarsAddressType::Oracle]); let params = get_params_adapter(&addresses[&MarsAddressType::Params]); + let credit_manager = get_credit_manager_adapter(&addresses[&MarsAddressType::CreditManager]); // Don't query the price if there are no positions. This is important during liquidation as // the price query might fail (if Default pricing is pased in). @@ -471,13 +495,18 @@ pub fn query_positions_by_account( let ms = MARKET_STATES.load(deps.storage, &denom)?; let curr_funding = ms.current_funding(current_time, denom_price, base_denom_price)?; + // Query the credit manager to get the discount for this account via adapter + let discount_pct = credit_manager.query_discount_pct(&deps.querier, &account_id)?; + let (opening_fee_rate, closing_fee_rate) = + compute_discounted_fee_rates(&perp_params, Some(discount_pct))?; + let pnl_amounts = position.compute_pnl( &curr_funding, ms.skew()?, denom_price, base_denom_price, - perp_params.opening_fee_rate, - perp_params.closing_fee_rate, + opening_fee_rate, + closing_fee_rate, PositionModification::Decrease(position.size), )?; @@ -591,8 +620,14 @@ pub fn query_total_accounting(deps: Deps, current_time: u64) -> ContractResult ContractResult { +pub fn query_opening_fee( + deps: Deps, + denom: &str, + size: Int128, + discount_pct: Option, +) -> ContractResult { let cfg = CONFIG.load(deps.storage)?; let ms = MARKET_STATES.load(deps.storage, denom)?; @@ -610,9 +645,13 @@ pub fn query_opening_fee(deps: Deps, denom: &str, size: Int128) -> ContractResul let denom_price = oracle.query_price(&deps.querier, denom, ActionKind::Default)?.price; let perp_params = params.query_perp_params(&deps.querier, denom)?; + // Apply discount to fee rates if provided + let (opening_fee_rate, closing_fee_rate) = + compute_discounted_fee_rates(&perp_params, discount_pct)?; + let fees = PositionModification::Increase(size).compute_fees( - perp_params.opening_fee_rate, - perp_params.closing_fee_rate, + opening_fee_rate, + closing_fee_rate, denom_price, base_denom_price, ms.skew()?, @@ -620,7 +659,7 @@ pub fn query_opening_fee(deps: Deps, denom: &str, size: Int128) -> ContractResul )?; Ok(TradingFee { - rate: perp_params.opening_fee_rate, + rate: opening_fee_rate, fee: coin(fees.opening_fee.unsigned_abs().u128(), cfg.base_denom), }) } @@ -640,12 +679,21 @@ pub fn query_position_fees( let addresses = query_contract_addrs( deps, &cfg.address_provider, - vec![MarsAddressType::Oracle, MarsAddressType::Params], + vec![ + MarsAddressType::Oracle, + MarsAddressType::Params, + MarsAddressType::CreditManager, + MarsAddressType::Governance, + ], )?; let oracle = get_oracle_adapter(&addresses[&MarsAddressType::Oracle]); let params = get_params_adapter(&addresses[&MarsAddressType::Params]); + // Get staking tier discount for this account + let credit_manager = addresses[&MarsAddressType::CreditManager].clone(); + let credit_manager_adapter = get_credit_manager_adapter(&credit_manager); + let base_denom_price = oracle.query_price(&deps.querier, &cfg.base_denom, ActionKind::Default)?.price; let denom_price = oracle.query_price(&deps.querier, denom, ActionKind::Default)?.price; @@ -687,9 +735,14 @@ pub fn query_position_fees( PositionModification::Increase(new_size) } }; + // Query the credit manager to get the discount for this account via adapter + let discount_pct = credit_manager_adapter.query_discount_pct(&deps.querier, account_id)?; + let (discounted_opening_fee_rate, discounted_closing_fee_rate) = + compute_discounted_fee_rates(&perp_params, Some(discount_pct))?; + let fees = modification.compute_fees( - perp_params.opening_fee_rate, - perp_params.closing_fee_rate, + discounted_opening_fee_rate, + discounted_closing_fee_rate, denom_price, base_denom_price, skew, diff --git a/contracts/perps/src/utils.rs b/contracts/perps/src/utils.rs index e1de9f5c..8b202308 100644 --- a/contracts/perps/src/utils.rs +++ b/contracts/perps/src/utils.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use cosmwasm_std::{Addr, Attribute, Decimal, Deps, Int128, Order, SignedDecimal, Uint128}; use mars_types::{ adapters::{ + credit_manager::CreditManagerBase, oracle::{Oracle, OracleBase}, params::ParamsBase, }, @@ -99,6 +100,10 @@ pub fn get_params_adapter(address: &Addr) -> ParamsBase { ParamsBase::new(address.clone()) } +pub fn get_credit_manager_adapter(address: &Addr) -> CreditManagerBase { + CreditManagerBase::new(address.clone()) +} + // Updates the attributes vector with details of a modified position. /// /// This function is responsible for pushing key attributes related to the original diff --git a/contracts/perps/tests/tests/helpers/contracts.rs b/contracts/perps/tests/tests/helpers/contracts.rs index 4fb5f79c..d848855e 100644 --- a/contracts/perps/tests/tests/helpers/contracts.rs +++ b/contracts/perps/tests/tests/helpers/contracts.rs @@ -60,7 +60,10 @@ mod mock_credit_manager { #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult}; - use mars_types::credit_manager::{ExecuteMsg, QueryMsg}; + use mars_types::{ + credit_manager::{Account, ExecuteMsg, Positions, QueryMsg}, + health::AccountKind, + }; #[cfg_attr(not(feature = "library"), entry_point)] pub fn instantiate( @@ -83,7 +86,56 @@ mod mock_credit_manager { } #[cfg_attr(not(feature = "library"), entry_point)] - pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> StdResult { - unimplemented!("query not supported") + pub fn query(_deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::AccountKind { + account_id: _, + } => { + // Return a mock account kind + let account_kind = AccountKind::Default; + cosmwasm_std::to_json_binary(&account_kind) + } + QueryMsg::Positions { + account_id: _, + action: _, + } => { + // Return empty positions + let positions = Positions { + account_id: "1".to_string(), + account_kind: AccountKind::Default, + deposits: vec![], + debts: vec![], + lends: vec![], + vaults: vec![], + staked_astro_lps: vec![], + perps: vec![], + }; + cosmwasm_std::to_json_binary(&positions) + } + QueryMsg::Accounts { + owner: _, + start_after: _, + limit: _, + } => { + // Return a mock account + let account = Account { + id: "1".to_string(), + kind: AccountKind::Default, + }; + cosmwasm_std::to_json_binary(&vec![account]) + } + QueryMsg::GetAccountTierAndDiscount { + account_id: _, + } => { + // Return a mock tier and discount response + let response = mars_types::credit_manager::AccountTierAndDiscountResponse { + tier_id: "default".to_string(), + discount_pct: cosmwasm_std::Decimal::zero(), + voting_power: cosmwasm_std::Uint128::zero(), + }; + cosmwasm_std::to_json_binary(&response) + } + _ => unimplemented!("query not supported: {:?}", msg), + } } } diff --git a/contracts/perps/tests/tests/helpers/mock_env.rs b/contracts/perps/tests/tests/helpers/mock_env.rs index 59a95736..069b0460 100644 --- a/contracts/perps/tests/tests/helpers/mock_env.rs +++ b/contracts/perps/tests/tests/helpers/mock_env.rs @@ -58,6 +58,7 @@ pub struct MockEnvBuilder { deleverage_enabled: bool, withdraw_enabled: bool, max_unlocks: u8, + pub governance_addr: Option, } #[allow(clippy::new_ret_no_self)] @@ -77,6 +78,7 @@ impl MockEnv { deleverage_enabled: true, withdraw_enabled: true, max_unlocks: 5, + governance_addr: Some(Addr::unchecked("mock-governance")), } } @@ -223,6 +225,7 @@ impl MockEnv { denom: denom.to_string(), size, reduce_only, + discount_pct: None, }, funds, ) @@ -240,6 +243,7 @@ impl MockEnv { &perps::ExecuteMsg::CloseAllPositions { account_id: account_id.to_string(), action: None, + discount_pct: None, }, funds, ) @@ -479,7 +483,12 @@ impl MockEnv { .unwrap() } - pub fn query_opening_fee(&self, denom: &str, size: Int128) -> TradingFee { + pub fn query_opening_fee( + &self, + denom: &str, + size: Int128, + discount_pct: Option, + ) -> TradingFee { self.app .wrap() .query_wasm_smart( @@ -487,6 +496,7 @@ impl MockEnv { &perps::QueryMsg::OpeningFee { denom: denom.to_string(), size, + discount_pct, }, ) .unwrap() @@ -522,6 +532,20 @@ impl MockEnv { ) .unwrap() } + + pub fn set_governance_address(&mut self, address: &Addr) { + self.app + .execute_contract( + self.owner.clone(), + self.address_provider.clone(), + &address_provider::ExecuteMsg::SetAddress { + address_type: MarsAddressType::Governance, + address: address.to_string(), + }, + &[], + ) + .unwrap(); + } } impl MockEnvBuilder { @@ -535,6 +559,15 @@ impl MockEnvBuilder { let perps_contract = self.deploy_perps(address_provider_contract.as_str()); let incentives_contract = self.deploy_incentives(&address_provider_contract); + // Deploy governance if provided + if let Some(governance_addr) = self.governance_addr.clone() { + self.update_address_provider( + &address_provider_contract, + MarsAddressType::Governance, + &governance_addr, + ); + } + self.update_address_provider( &address_provider_contract, MarsAddressType::Incentives, @@ -844,4 +877,22 @@ impl MockEnvBuilder { self.max_unlocks = max_unlocks; self } + + pub fn set_governance_addr(mut self, addr: &Addr) -> Self { + self.governance_addr = Some(addr.clone()); + self + } + + pub fn deploy_mock_governance(&mut self) -> &mut Self { + let governance_addr = self.deploy_governance(); + self.governance_addr = Some(governance_addr); + self + } + + fn deploy_governance(&mut self) -> Addr { + // Create a simple mock governance contract address + let addr = Addr::unchecked("mock-governance"); + self.set_address(MarsAddressType::Governance, addr.clone()); + addr + } } diff --git a/contracts/perps/tests/tests/mod.rs b/contracts/perps/tests/tests/mod.rs index ff49f8f0..d495b2e8 100644 --- a/contracts/perps/tests/tests/mod.rs +++ b/contracts/perps/tests/tests/mod.rs @@ -1,10 +1,12 @@ mod helpers; mod test_accounting; +mod test_accounting_with_discount; mod test_instantiate; mod test_managing_markets; mod test_migration_v2; mod test_migration_v2_3_0; +mod test_migration_v2_4_0; mod test_position; mod test_protocol_fees; mod test_query; diff --git a/contracts/perps/tests/tests/test_accounting.rs b/contracts/perps/tests/tests/test_accounting.rs index 4e567def..6263bc4e 100644 --- a/contracts/perps/tests/tests/test_accounting.rs +++ b/contracts/perps/tests/tests/test_accounting.rs @@ -76,7 +76,7 @@ fn accounting() { // open few positions for account 1 let osmo_size = Int128::from_str("10000000").unwrap(); - let osmo_opening_fee = mock.query_opening_fee("uosmo", osmo_size).fee; + let osmo_opening_fee = mock.query_opening_fee("uosmo", osmo_size, None).fee; let osmo_opening_protocol_fee = osmo_opening_fee.amount.checked_mul_ceil(protocol_fee_rate).unwrap(); assert!(!osmo_opening_protocol_fee.is_zero()); @@ -90,7 +90,7 @@ fn accounting() { ) .unwrap(); let atom_size = Int128::from_str("-260000").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", atom_size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", atom_size, None).fee; let atom_opening_protocol_fee = atom_opening_fee.amount.checked_mul_ceil(protocol_fee_rate).unwrap(); assert!(!atom_opening_protocol_fee.is_zero()); @@ -459,7 +459,7 @@ fn accounting_works_up_to_oi_limits( loop { // Query the opening fee for the given size of the position (eth_size). - let eth_opening_fee = mock.query_opening_fee("ueth", eth_size).fee; + let eth_opening_fee = mock.query_opening_fee("ueth", eth_size, None).fee; // Attempt to execute a perpetual order using the credit manager for the current position size. let res = mock.execute_perp_order( diff --git a/contracts/perps/tests/tests/test_accounting_with_discount.rs b/contracts/perps/tests/tests/test_accounting_with_discount.rs new file mode 100644 index 00000000..19b3560c --- /dev/null +++ b/contracts/perps/tests/tests/test_accounting_with_discount.rs @@ -0,0 +1,236 @@ +use std::str::FromStr; + +use cosmwasm_std::{coin, Addr, Decimal, Int128, Uint128}; +use mars_types::{ + params::{PerpParams, PerpParamsUpdate}, + perps::Accounting, +}; + +use super::helpers::MockEnv; +use crate::tests::helpers::default_perp_params; + +#[test] +fn accounting_with_discount_fees() { + let protocol_fee_rate = Decimal::percent(2); + let mut mock = MockEnv::new().protocol_fee_rate(protocol_fee_rate).build().unwrap(); + + // Set up governance after building + let governance_addr = Addr::unchecked("mock-governance"); + mock.set_governance_address(&governance_addr); + + let owner = mock.owner.clone(); + let credit_manager = mock.credit_manager.clone(); + let user = "jake"; + // Fund credit manager and set up prices + mock.fund_accounts(&[&credit_manager], 1_000_000_000_000_000u128, &["uosmo", "uatom", "uusdc"]); + mock.set_price(&owner, "uusdc", Decimal::from_str("0.9").unwrap()).unwrap(); + mock.set_price(&owner, "uosmo", Decimal::from_str("1.25").unwrap()).unwrap(); + mock.set_price(&owner, "uatom", Decimal::from_str("10.5").unwrap()).unwrap(); + + // Deposit USDC to vault + mock.deposit_to_vault( + &credit_manager, + Some(user), + None, + &[coin(1_000_000_000_000u128, "uusdc")], + ) + .unwrap(); + + // Set up perp markets with different fee rates + mock.update_perp_params( + &owner, + PerpParamsUpdate::AddOrUpdate { + params: PerpParams { + closing_fee_rate: Decimal::percent(1), + opening_fee_rate: Decimal::percent(2), + max_funding_velocity: Decimal::from_str("32").unwrap(), + ..default_perp_params("uosmo") + }, + }, + ); + mock.update_perp_params( + &owner, + PerpParamsUpdate::AddOrUpdate { + params: PerpParams { + closing_fee_rate: Decimal::percent(1), + opening_fee_rate: Decimal::percent(2), + max_funding_velocity: Decimal::from_str("30").unwrap(), + ..default_perp_params("uatom") + }, + }, + ); + + // Check accounting in the beginning + let osmo_accounting_before = mock.query_market_accounting("uosmo").accounting; + let atom_accounting_before = mock.query_market_accounting("uatom").accounting; + let total_accounting_before = mock.query_total_accounting().accounting; + + assert_eq!(osmo_accounting_before, Accounting::default()); + assert_eq!(atom_accounting_before, Accounting::default()); + assert_eq!(total_accounting_before, Accounting::default()); + + // Test opening fees with and without discount + let atom_size = Int128::from_str("1000000").unwrap(); + + // Query opening fee without discount + let atom_opening_fee_no_discount = mock.query_opening_fee("uatom", atom_size, None).fee; + + // Query opening fee with 50% discount + let discount_pct = Decimal::percent(50); + let atom_opening_fee_with_discount = + mock.query_opening_fee("uatom", atom_size, Some(discount_pct)).fee; + + // Verify discount is applied correctly + assert!(atom_opening_fee_with_discount.amount < atom_opening_fee_no_discount.amount); + let expected_discount_amount = + atom_opening_fee_no_discount.amount.checked_mul_ceil(discount_pct).unwrap(); + let expected_fee_with_discount = + atom_opening_fee_no_discount.amount.checked_sub(expected_discount_amount).unwrap(); + + // Allow for small rounding differences (within 1 unit) + let difference = if expected_fee_with_discount > atom_opening_fee_with_discount.amount { + expected_fee_with_discount.checked_sub(atom_opening_fee_with_discount.amount).unwrap() + } else { + atom_opening_fee_with_discount.amount.checked_sub(expected_fee_with_discount).unwrap() + }; + assert!( + difference <= Uint128::new(1), + "Discount calculation difference too large: expected {}, got {}, difference {}", + expected_fee_with_discount, + atom_opening_fee_with_discount.amount, + difference + ); + + // Test different discount percentages + let discount_25 = Decimal::percent(25); + let atom_opening_fee_25_discount = + mock.query_opening_fee("uatom", atom_size, Some(discount_25)).fee; + assert!(atom_opening_fee_25_discount.amount < atom_opening_fee_no_discount.amount); + assert!(atom_opening_fee_25_discount.amount > atom_opening_fee_with_discount.amount); + + let discount_75 = Decimal::percent(75); + let atom_opening_fee_75_discount = + mock.query_opening_fee("uatom", atom_size, Some(discount_75)).fee; + assert!(atom_opening_fee_75_discount.amount < atom_opening_fee_25_discount.amount); + + // Test that 100% discount results in 0 fee + let discount_100 = Decimal::percent(100); + let atom_opening_fee_100_discount = + mock.query_opening_fee("uatom", atom_size, Some(discount_100)).fee; + assert_eq!(atom_opening_fee_100_discount.amount, Uint128::zero()); + + // Test that 0% discount is same as no discount + let discount_0 = Decimal::zero(); + let atom_opening_fee_0_discount = + mock.query_opening_fee("uatom", atom_size, Some(discount_0)).fee; + assert_eq!(atom_opening_fee_0_discount.amount, atom_opening_fee_no_discount.amount); + + // Test with different position sizes + let small_size = Int128::from_str("100000").unwrap(); + let small_fee_no_discount = mock.query_opening_fee("uatom", small_size, None).fee; + let small_fee_with_discount = + mock.query_opening_fee("uatom", small_size, Some(discount_pct)).fee; + + // Verify proportional discount + let small_expected_discount = + small_fee_no_discount.amount.checked_mul_ceil(discount_pct).unwrap(); + let small_expected_fee = + small_fee_no_discount.amount.checked_sub(small_expected_discount).unwrap(); + let small_difference = if small_expected_fee > small_fee_with_discount.amount { + small_expected_fee.checked_sub(small_fee_with_discount.amount).unwrap() + } else { + small_fee_with_discount.amount.checked_sub(small_expected_fee).unwrap() + }; + assert!(small_difference <= Uint128::new(1)); + + // Test with negative size (short position) + let short_size = Int128::from_str("-500000").unwrap(); + let short_fee_no_discount = mock.query_opening_fee("uatom", short_size, None).fee; + let short_fee_with_discount = + mock.query_opening_fee("uatom", short_size, Some(discount_pct)).fee; + + assert!(short_fee_with_discount.amount < short_fee_no_discount.amount); + let short_expected_discount = + short_fee_no_discount.amount.checked_mul_ceil(discount_pct).unwrap(); + let short_expected_fee = + short_fee_no_discount.amount.checked_sub(short_expected_discount).unwrap(); + let short_difference = if short_expected_fee > short_fee_with_discount.amount { + short_expected_fee.checked_sub(short_fee_with_discount.amount).unwrap() + } else { + short_fee_with_discount.amount.checked_sub(short_expected_fee).unwrap() + }; + assert!(short_difference <= Uint128::new(1)); + + // Test discount consistency across different denoms + let osmo_size = Int128::from_str("500000").unwrap(); + let osmo_opening_fee_no_discount = mock.query_opening_fee("uosmo", osmo_size, None).fee; + let osmo_opening_fee_with_discount = + mock.query_opening_fee("uosmo", osmo_size, Some(discount_pct)).fee; + + // Verify discount is applied consistently + assert!(osmo_opening_fee_with_discount.amount < osmo_opening_fee_no_discount.amount); + let osmo_expected_discount = + osmo_opening_fee_no_discount.amount.checked_mul_ceil(discount_pct).unwrap(); + let osmo_expected_fee = + osmo_opening_fee_no_discount.amount.checked_sub(osmo_expected_discount).unwrap(); + let osmo_difference = if osmo_expected_fee > osmo_opening_fee_with_discount.amount { + osmo_expected_fee.checked_sub(osmo_opening_fee_with_discount.amount).unwrap() + } else { + osmo_opening_fee_with_discount.amount.checked_sub(osmo_expected_fee).unwrap() + }; + assert!(osmo_difference <= Uint128::new(1)); + + // Verify that accounting is still clean after all the queries + let osmo_accounting_after = mock.query_market_accounting("uosmo").accounting; + let atom_accounting_after = mock.query_market_accounting("uatom").accounting; + let total_accounting_after = mock.query_total_accounting().accounting; + + // Since we didn't execute any orders, accounting should still be default + assert_eq!(osmo_accounting_after, Accounting::default()); + assert_eq!(atom_accounting_after, Accounting::default()); + assert_eq!(total_accounting_after, Accounting::default()); +} + +#[test] +fn discount_fee_edge_cases() { + let mut mock = MockEnv::new().build().unwrap(); + let owner = mock.owner.clone(); + let credit_manager = mock.credit_manager.clone(); + let user = "alice"; + + // Set up basic environment + mock.fund_accounts(&[&credit_manager], 1_000_000_000u128, &["uusdc"]); + mock.set_price(&owner, "uusdc", Decimal::from_str("1").unwrap()).unwrap(); + mock.set_price(&owner, "uatom", Decimal::from_str("10").unwrap()).unwrap(); + + mock.deposit_to_vault(&credit_manager, Some(user), None, &[coin(1_000_000_000u128, "uusdc")]) + .unwrap(); + + mock.update_perp_params( + &owner, + PerpParamsUpdate::AddOrUpdate { + params: PerpParams { + opening_fee_rate: Decimal::percent(1), + closing_fee_rate: Decimal::percent(1), + ..default_perp_params("uatom") + }, + }, + ); + + let size = Int128::from_str("1000").unwrap(); + + // Test 100% discount (should result in 0 fee) + let fee_100_discount = mock.query_opening_fee("uatom", size, Some(Decimal::percent(100))).fee; + assert_eq!(fee_100_discount.amount, Uint128::zero()); + + // Test 0% discount (should be same as no discount) + let fee_0_discount = mock.query_opening_fee("uatom", size, Some(Decimal::zero())).fee; + let fee_no_discount = mock.query_opening_fee("uatom", size, None).fee; + assert_eq!(fee_0_discount.amount, fee_no_discount.amount); + + // Test very small discount + let small_discount = Decimal::from_str("0.001").unwrap(); // 0.1% + let fee_small_discount = mock.query_opening_fee("uatom", size, Some(small_discount)).fee; + assert!(fee_small_discount.amount < fee_no_discount.amount); + assert!(fee_small_discount.amount > Uint128::zero()); +} diff --git a/contracts/perps/tests/tests/test_migration_v2_3_0.rs b/contracts/perps/tests/tests/test_migration_v2_3_0.rs index 9d300cf9..25f6b19b 100644 --- a/contracts/perps/tests/tests/test_migration_v2_3_0.rs +++ b/contracts/perps/tests/tests/test_migration_v2_3_0.rs @@ -1,7 +1,8 @@ -use cosmwasm_std::{attr, testing::mock_env, Empty}; +use cosmwasm_std::{attr, testing::mock_env}; use cw2::{ContractVersion, VersionError}; use mars_perps::{contract::migrate, error::ContractError}; use mars_testing::mock_dependencies; +use mars_types::perps::MigrateMsg; const CONTRACT_NAME: &str = "mars-perps"; const CONTRACT_VERSION: &str = "2.3.0"; @@ -11,7 +12,7 @@ fn wrong_contract_name() { let mut deps = mock_dependencies(&[]); cw2::set_contract_version(deps.as_mut().storage, "contract_xyz", "2.2.3").unwrap(); - let err = migrate(deps.as_mut(), mock_env(), Empty {}).unwrap_err(); + let err = migrate(deps.as_mut(), mock_env(), MigrateMsg::V2_2_3ToV2_3_0 {}).unwrap_err(); assert_eq!( err, @@ -28,7 +29,7 @@ fn wrong_contract_version() { cw2::set_contract_version(deps.as_mut().storage, format!("crates.io:{CONTRACT_NAME}"), "1.0.0") .unwrap(); - let err = migrate(deps.as_mut(), mock_env(), Empty {}).unwrap_err(); + let err = migrate(deps.as_mut(), mock_env(), MigrateMsg::V2_2_3ToV2_3_0 {}).unwrap_err(); assert_eq!( err, @@ -45,7 +46,7 @@ fn successful_migration_from_2_2_1() { cw2::set_contract_version(deps.as_mut().storage, format!("crates.io:{CONTRACT_NAME}"), "2.2.3") .unwrap(); - let res = migrate(deps.as_mut(), mock_env(), Empty {}).unwrap(); + let res = migrate(deps.as_mut(), mock_env(), MigrateMsg::V2_2_3ToV2_3_0 {}).unwrap(); assert_eq!(res.messages, vec![]); assert_eq!( diff --git a/contracts/perps/tests/tests/test_migration_v2_4_0.rs b/contracts/perps/tests/tests/test_migration_v2_4_0.rs new file mode 100644 index 00000000..d072abbe --- /dev/null +++ b/contracts/perps/tests/tests/test_migration_v2_4_0.rs @@ -0,0 +1,71 @@ +use cosmwasm_std::{attr, testing::mock_env}; +use cw2::{ContractVersion, VersionError}; +use mars_perps::{contract::migrate, error::ContractError}; +use mars_testing::mock_dependencies; +use mars_types::perps::MigrateMsg; + +const CONTRACT_NAME: &str = "mars-perps"; +const CONTRACT_VERSION: &str = "2.4.0"; + +#[test] +fn wrong_contract_name() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version(deps.as_mut().storage, "contract_xyz", "2.3.0").unwrap(); + + let err = migrate(deps.as_mut(), mock_env(), MigrateMsg::V2_3_0ToV2_4_0 {}).unwrap_err(); + + assert_eq!( + err, + ContractError::Version(VersionError::WrongContract { + expected: format!("crates.io:{CONTRACT_NAME}"), + found: "contract_xyz".to_string() + }) + ); +} + +#[test] +fn wrong_contract_version() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version(deps.as_mut().storage, format!("crates.io:{CONTRACT_NAME}"), "1.0.0") + .unwrap(); + + let err = migrate(deps.as_mut(), mock_env(), MigrateMsg::V2_3_0ToV2_4_0 {}).unwrap_err(); + + assert_eq!( + err, + ContractError::Version(VersionError::WrongVersion { + expected: "2.3.0".to_string(), + found: "1.0.0".to_string() + }) + ); +} + +#[test] +fn successful_migration_from_2_3_0() { + let mut deps = mock_dependencies(&[]); + cw2::set_contract_version(deps.as_mut().storage, format!("crates.io:{CONTRACT_NAME}"), "2.3.0") + .unwrap(); + + let res = migrate(deps.as_mut(), mock_env(), MigrateMsg::V2_3_0ToV2_4_0 {}).unwrap(); + + assert_eq!(res.messages, vec![]); + assert_eq!( + res.attributes, + vec![ + attr("action", "migrate"), + attr("from_version", "2.3.0"), + attr("to_version", CONTRACT_VERSION), + ] + ); + assert!(res.data.is_none()); + + // Verify the contract version was updated + let new_contract_version = cw2::get_contract_version(deps.as_ref().storage).unwrap(); + assert_eq!( + new_contract_version, + ContractVersion { + contract: format!("crates.io:{CONTRACT_NAME}"), + version: CONTRACT_VERSION.to_string(), + } + ); +} diff --git a/contracts/perps/tests/tests/test_position.rs b/contracts/perps/tests/tests/test_position.rs index b2e8fb2f..a0106c8d 100644 --- a/contracts/perps/tests/tests/test_position.rs +++ b/contracts/perps/tests/tests/test_position.rs @@ -64,7 +64,7 @@ fn random_user_cannot_modify_position() { ); let size = Int128::from_str("-125").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "2", "uatom", size, None, &[atom_opening_fee]) .unwrap(); @@ -155,7 +155,7 @@ fn cannot_modify_position_for_disabled_denom() { ); let size = Int128::from_str("-125").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "2", "uatom", size, None, &[atom_opening_fee]) .unwrap(); @@ -240,7 +240,7 @@ fn only_close_position_possible_for_disabled_denom() { ); let size = Int128::from_str("125").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "2", "uatom", size, None, &[atom_opening_fee]) .unwrap(); @@ -466,7 +466,7 @@ fn reduced_position_cannot_be_too_small() { // create valid position let size = Int128::from_str("200").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "2", "uatom", size, None, &[atom_opening_fee]) .unwrap(); @@ -572,7 +572,7 @@ fn increased_position_cannot_be_too_big() { // position size is too big // 100 * 12.5 = 1250 let size = Int128::from_str("50").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "2", "uatom", size, None, &[atom_opening_fee]) .unwrap(); @@ -794,7 +794,7 @@ fn error_when_new_size_equals_old_size() { // Test with positive size let size = Int128::from_str("12").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee]) .unwrap(); // Try to modify position of 12 to 12 @@ -809,7 +809,7 @@ fn error_when_new_size_equals_old_size() { // Test with negative size let size = Int128::from_str("-3").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order( &credit_manager, "2", @@ -885,11 +885,11 @@ fn error_when_oi_limits_exceeded() { // Short OI = (0) -40 = -40 // Net OI = (0) + 30 - 40 = -10 let size = Int128::from_str("30").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee]) .unwrap(); let size = Int128::from_str("-40").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "2", "uatom", size, None, &[atom_opening_fee]) .unwrap(); @@ -1056,7 +1056,7 @@ fn modify_position_realises_pnl() { // prepare some OI let size = Int128::from_str("300").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee.clone()]) .unwrap(); @@ -1065,7 +1065,7 @@ fn modify_position_realises_pnl() { // how much opening fee we will pay for increase from 300 to 400 let atom_opening_fee_for_increase = - mock.query_opening_fee("uatom", Int128::from_str("100").unwrap()).fee; + mock.query_opening_fee("uatom", Int128::from_str("100").unwrap(), None).fee; // modify and verify that our pnl is realized mock.execute_perp_order( @@ -1163,7 +1163,7 @@ fn shouldnt_open_when_reduce_only() { ); let size = Int128::from_str("50").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; let res = mock.execute_perp_order( &credit_manager, "2", @@ -1220,7 +1220,7 @@ fn should_open_when_reduce_only_false_or_none() { ); let size = Int128::from_str("50").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order( &credit_manager, "2", @@ -1286,7 +1286,7 @@ fn should_reduce_when_reduce_only_true() { let size_long_position = Int128::from_str("50").unwrap(); let size_short_position = Int128::from_str("-50").unwrap(); - let atom_opening_fee = mock.query_opening_fee(denom, size_long_position).fee; + let atom_opening_fee = mock.query_opening_fee(denom, size_long_position, None).fee; mock.execute_perp_order( &credit_manager, @@ -1419,7 +1419,7 @@ fn shouldnt_increase_when_reduce_only_true() { let size_long_position = Int128::from_str("500").unwrap(); let size_short_position = Int128::from_str("-500").unwrap(); - let atom_opening_fee = mock.query_opening_fee(denom, size_long_position).fee; + let atom_opening_fee = mock.query_opening_fee(denom, size_long_position, None).fee; mock.execute_perp_order( &credit_manager, "2", @@ -1430,7 +1430,7 @@ fn shouldnt_increase_when_reduce_only_true() { ) .unwrap(); - let atom_opening_fee = mock.query_opening_fee(denom, size_short_position).fee; + let atom_opening_fee = mock.query_opening_fee(denom, size_short_position, None).fee; mock.execute_perp_order( &credit_manager, "3", @@ -1553,7 +1553,7 @@ fn increase_when_reduce_only_false() { let size_long_position = Int128::from_str("50").unwrap(); let size_short_position = Int128::from_str("-50").unwrap(); - let atom_opening_fee = mock.query_opening_fee(denom, size_long_position).fee; + let atom_opening_fee = mock.query_opening_fee(denom, size_long_position, None).fee; mock.execute_perp_order( &credit_manager, @@ -1665,7 +1665,7 @@ fn flip_position_when_reduce_only_true() { let size_long_position = Int128::from_str("50").unwrap(); let size_short_position = Int128::from_str("-50").unwrap(); - let atom_opening_fee = mock.query_opening_fee(denom, size_long_position).fee; + let atom_opening_fee = mock.query_opening_fee(denom, size_long_position, None).fee; mock.execute_perp_order( &credit_manager, @@ -1798,7 +1798,7 @@ fn flip_position_when_reduce_only_false() { let size_long_position = Int128::from_str("50").unwrap(); let size_short_position = Int128::from_str("-50").unwrap(); - let atom_opening_fee = mock.query_opening_fee(denom, size_long_position).fee; + let atom_opening_fee = mock.query_opening_fee(denom, size_long_position, None).fee; mock.execute_perp_order( &credit_manager, @@ -2028,12 +2028,12 @@ fn query_position_fees( // open a position to change skew let size = Int128::from_str("10000").unwrap(); - let opening_fee = mock.query_opening_fee("uosmo", size).fee; + let opening_fee = mock.query_opening_fee("uosmo", size, None).fee; mock.execute_perp_order(&credit_manager, "2", "uosmo", size, None, &[opening_fee]).unwrap(); // open a position if specified if let Some(old_size) = old_size { - let opening_fee = mock.query_opening_fee("uosmo", old_size).fee; + let opening_fee = mock.query_opening_fee("uosmo", old_size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uosmo", old_size, None, &[opening_fee]) .unwrap(); } @@ -2043,6 +2043,99 @@ fn query_position_fees( assert_eq!(position_fees, expected_fees); } +#[test] +fn query_opening_fee_with_voting_power_discounts() { + let mut mock = MockEnv::new().build().unwrap(); + + let owner = mock.owner.clone(); + let credit_manager = mock.credit_manager.clone(); + let user = "jake"; + + // set prices + mock.set_price(&owner, "uusdc", Decimal::from_str("0.9").unwrap()).unwrap(); + mock.set_price(&owner, "uosmo", Decimal::from_str("1.25").unwrap()).unwrap(); + + // credit manager is calling the perps contract, so we need to fund it + mock.fund_accounts(&[&credit_manager], 1_000_000_000_000_000u128, &["uosmo", "uusdc"]); + + // deposit some big number of uusdc to vault + mock.deposit_to_vault( + &credit_manager, + Some(user), + None, + &[coin(1_000_000_000_000u128, "uusdc")], + ) + .unwrap(); + + // init denoms + mock.update_perp_params( + &owner, + PerpParamsUpdate::AddOrUpdate { + params: PerpParams { + opening_fee_rate: Decimal::from_str("0.004").unwrap(), + closing_fee_rate: Decimal::from_str("0.006").unwrap(), + ..default_perp_params("uosmo") + }, + }, + ); + + // open a position to change skew + let size = Int128::from_str("10000").unwrap(); + let opening_fee = mock.query_opening_fee("uosmo", size, None).fee; + mock.execute_perp_order(&credit_manager, "2", "uosmo", size, None, &[opening_fee]).unwrap(); + + let test_size = Int128::from_str("2500").unwrap(); + + // Test different discount tiers + let test_cases = vec![ + (Decimal::percent(0), "Tier 1: 0% discount"), + (Decimal::percent(10), "Tier 2: 10% discount"), + (Decimal::percent(20), "Tier 3: 20% discount"), + (Decimal::percent(30), "Tier 4: 30% discount"), + (Decimal::percent(45), "Tier 5: 45% discount"), + ]; + + // Get base fee without discount + let base_fee = mock.query_opening_fee("uosmo", test_size, None).fee; + + for (discount_pct, description) in test_cases { + // Query fee with discount + let discounted_fee = mock.query_opening_fee("uosmo", test_size, Some(discount_pct)).fee; + + // Calculate expected fee with discount + let discount_multiplier = Decimal::one().checked_sub(discount_pct).unwrap(); + let expected_fee_amount = base_fee.amount.checked_mul_floor(discount_multiplier).unwrap(); + + // Verify discount is applied correctly + assert_eq!( + discounted_fee.amount, expected_fee_amount, + "{}: Expected fee {} but got {}", + description, expected_fee_amount, discounted_fee.amount + ); + + // Verify that higher discounts result in lower fees + if discount_pct > Decimal::percent(0) { + assert!( + discounted_fee.amount < base_fee.amount, + "{}: Discounted fee should be less than base fee", + description + ); + } + } + + // Test edge cases + // 100% discount should result in 0 fee + let zero_fee = mock.query_opening_fee("uosmo", test_size, Some(Decimal::percent(100))).fee; + assert_eq!(zero_fee.amount, Uint128::zero(), "100% discount should result in 0 fee"); + + // 0% discount should be same as no discount + let no_discount_fee = mock.query_opening_fee("uosmo", test_size, Some(Decimal::percent(0))).fee; + assert_eq!( + no_discount_fee.amount, base_fee.amount, + "0% discount should be same as no discount" + ); +} + #[test] fn random_user_cannot_close_all_positions() { let mut mock = MockEnv::new().build().unwrap(); @@ -2133,17 +2226,17 @@ fn close_all_positions( // open few positions let size = Int128::from_str("300").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee.clone()]) .unwrap(); let size = Int128::from_str("-500").unwrap(); - let ntrn_opening_fee = mock.query_opening_fee("untrn", size).fee; + let ntrn_opening_fee = mock.query_opening_fee("untrn", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "untrn", size, None, &[ntrn_opening_fee.clone()]) .unwrap(); let size = Int128::from_str("100").unwrap(); - let osmo_opening_fee = mock.query_opening_fee("uosmo", size).fee; + let osmo_opening_fee = mock.query_opening_fee("uosmo", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uosmo", size, None, &[osmo_opening_fee.clone()]) .unwrap(); @@ -2294,7 +2387,7 @@ fn open_very_small_position_with_zero_opening_fee() { // openining fee is zero let size = Int128::from_str("1").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; assert!(atom_opening_fee.amount.is_zero()); // open a very small position where opening fee is zero but opening_fee_rate is not zero @@ -2339,7 +2432,7 @@ fn global_realized_pnl_matches_positions_realized_pnl() { // Open a LONG position let size = Int128::from_str("300000").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee.clone()]) .unwrap(); @@ -2364,7 +2457,7 @@ fn global_realized_pnl_matches_positions_realized_pnl() { // Open a SHORT position let size = Int128::from_str("-300000").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee.clone()]) .unwrap(); diff --git a/contracts/perps/tests/tests/test_risk_verification.rs b/contracts/perps/tests/tests/test_risk_verification.rs index ffb78156..d9bc4790 100644 --- a/contracts/perps/tests/tests/test_risk_verification.rs +++ b/contracts/perps/tests/tests/test_risk_verification.rs @@ -183,7 +183,7 @@ fn verify_accounting_with_input_actions() { vec![] } } else { - let opening_fee = mock.query_opening_fee(denom, *order_size).fee; + let opening_fee = mock.query_opening_fee(denom, *order_size, None).fee; vec![opening_fee] }; diff --git a/contracts/perps/tests/tests/test_vault.rs b/contracts/perps/tests/tests/test_vault.rs index 7b562dc0..75c24cb7 100644 --- a/contracts/perps/tests/tests/test_vault.rs +++ b/contracts/perps/tests/tests/test_vault.rs @@ -501,7 +501,7 @@ fn unlock_and_withdraw_if_zero_withdrawal_balance() { // open a position let size = Int128::from_str("50").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee]) .unwrap(); @@ -583,7 +583,7 @@ fn calculate_shares_correctly_after_zero_withdrawal_balance() { // open a position let size = Int128::from_str("100").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee]) .unwrap(); @@ -902,7 +902,7 @@ fn withdraw_profits_for_depositors() { // open a position let size = Int128::from_str("100").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; assert_eq!(atom_opening_fee, coin(23, "uusdc")); mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee.clone()]) .unwrap(); @@ -1047,7 +1047,7 @@ fn cannot_withdraw_if_cr_decreases_below_threshold() { // open a position let size = Int128::from_str("100").unwrap(); - let atom_opening_fee = mock.query_opening_fee("uatom", size).fee; + let atom_opening_fee = mock.query_opening_fee("uatom", size, None).fee; mock.execute_perp_order(&credit_manager, "1", "uatom", size, None, &[atom_opening_fee.clone()]) .unwrap(); diff --git a/contracts/vault/tests/tests/test_redeem.rs b/contracts/vault/tests/tests/test_redeem.rs index 2690d9e5..ce364fef 100644 --- a/contracts/vault/tests/tests/test_redeem.rs +++ b/contracts/vault/tests/tests/test_redeem.rs @@ -90,7 +90,7 @@ fn redeem_invalid_funds() { ); assert_vault_err( res, - ContractError::Payment(PaymentError::MissingDenom("factory/contract14/vault".to_string())), + ContractError::Payment(PaymentError::MissingDenom("factory/contract15/vault".to_string())), ); } diff --git a/packages/testing/Cargo.toml b/packages/testing/Cargo.toml index 9d0ce5f9..c08ef6d0 100644 --- a/packages/testing/Cargo.toml +++ b/packages/testing/Cargo.toml @@ -45,6 +45,7 @@ mars-mock-oracle = { workspace = true } mars-mock-pyth = { workspace = true } mars-mock-red-bank = { workspace = true } mars-mock-vault = { workspace = true } +mars-mock-governance = { workspace = true } mars-oracle-osmosis = { workspace = true } mars-oracle-wasm = { workspace = true } mars-owner = { workspace = true } diff --git a/packages/testing/src/multitest/helpers/contracts.rs b/packages/testing/src/multitest/helpers/contracts.rs index 33e2dd12..b209f387 100644 --- a/packages/testing/src/multitest/helpers/contracts.rs +++ b/packages/testing/src/multitest/helpers/contracts.rs @@ -128,3 +128,13 @@ pub fn mock_perps_contract() -> Box> { .with_reply(mars_perps::contract::reply); Box::new(contract) } + +pub fn mock_governance_contract() -> Box> { + let contract = ContractWrapper::new( + mars_mock_governance::execute, + mars_mock_governance::instantiate, + mars_mock_governance::query, + ) + .with_reply(mars_mock_governance::reply); + Box::new(contract) +} diff --git a/packages/testing/src/multitest/helpers/mock_env.rs b/packages/testing/src/multitest/helpers/mock_env.rs index fed19fa2..4c3b1e81 100644 --- a/packages/testing/src/multitest/helpers/mock_env.rs +++ b/packages/testing/src/multitest/helpers/mock_env.rs @@ -13,6 +13,7 @@ use cw_vault_standard::{ extensions::lockup::{LockupQueryMsg, UnlockingPosition}, msg::{ExtensionQueryMsg, VaultStandardQueryMsg::VaultExtension}, }; +use mars_mock_governance::ExecMsg as GovernanceExecMsg; use mars_mock_oracle::msg::{ CoinPrice, ExecuteMsg as OracleExecuteMsg, InstantiateMsg as OracleInstantiateMsg, }; @@ -27,6 +28,7 @@ use mars_types::{ }, adapters::{ account_nft::AccountNftUnchecked, + governance::GovernanceUnchecked, health::HealthContract, incentives::{Incentives, IncentivesUnchecked}, oracle::{Oracle, OracleBase, OracleUnchecked}, @@ -45,6 +47,7 @@ use mars_types::{ SharesResponseItem, TriggerOrderResponse, VaultBinding, VaultPositionResponseItem, VaultUtilizationResponse, }, + fee_tiers::{FeeTier, FeeTierConfig}, health::{ AccountKind, ExecuteMsg::UpdateConfig, HealthValuesResponse, InstantiateMsg as HealthInstantiateMsg, QueryMsg::HealthValues, @@ -87,10 +90,11 @@ use mars_zapper_mock::msg::{InstantiateMsg as ZapperInstantiateMsg, LpConfig}; use super::{ lp_token_info, mock_account_nft_contract, mock_address_provider_contract, - mock_astro_incentives_contract, mock_health_contract, mock_incentives_contract, - mock_managed_vault_contract, mock_oracle_contract, mock_params_contract, mock_perps_contract, - mock_red_bank_contract, mock_rover_contract, mock_swapper_contract, mock_v2_zapper_contract, - mock_vault_contract, AccountToFund, CoinInfo, VaultTestInfo, ASTRO_LP_DENOM, + mock_astro_incentives_contract, mock_governance_contract, mock_health_contract, + mock_incentives_contract, mock_managed_vault_contract, mock_oracle_contract, + mock_params_contract, mock_perps_contract, mock_red_bank_contract, mock_rover_contract, + mock_swapper_contract, mock_v2_zapper_contract, mock_vault_contract, AccountToFund, CoinInfo, + VaultTestInfo, ASTRO_LP_DENOM, }; use crate::{ integration::mock_contracts::mock_rewards_collector_osmosis_contract, @@ -136,6 +140,8 @@ pub struct MockEnvBuilder { pub perps_liquidation_bonus_ratio: Option, pub perps_protocol_fee_ratio: Option, pub swap_fee: Option, + pub fee_tier_config: Option, + pub governance_addr: Option, } #[allow(clippy::new_ret_no_self)] @@ -170,6 +176,8 @@ impl MockEnv { perps_liquidation_bonus_ratio: None, perps_protocol_fee_ratio: None, swap_fee: None, + fee_tier_config: None, + governance_addr: None, } } @@ -1141,7 +1149,12 @@ impl MockEnv { .unwrap() } - pub fn query_perp_opening_fee(&self, denom: &str, size: Int128) -> TradingFee { + pub fn query_perp_opening_fee( + &self, + denom: &str, + size: Int128, + discount_pct: Option, + ) -> TradingFee { self.app .wrap() .query_wasm_smart( @@ -1149,6 +1162,7 @@ impl MockEnv { &perps::QueryMsg::OpeningFee { denom: denom.to_string(), size, + discount_pct, }, ) .unwrap() @@ -1192,6 +1206,21 @@ impl MockEnv { Addr::unchecked(res.address) } + + pub fn set_voting_power(&mut self, user: &Addr, power: Uint128) { + let governance = self.query_address_provider(MarsAddressType::Governance); + self.app + .execute_contract( + Addr::unchecked("owner"), + governance, + &GovernanceExecMsg::SetVotingPower { + address: user.to_string(), + power, + }, + &[], + ) + .unwrap(); + } } impl MockEnvBuilder { @@ -1212,6 +1241,8 @@ impl MockEnvBuilder { self.update_health_contract_config(&rover); self.deploy_nft_contract(&rover); + self.deploy_governance(&rover); + self.set_fee_tiers(&rover); if self.deploy_nft_contract && self.set_nft_contract_minter { self.update_config( @@ -1249,6 +1280,20 @@ impl MockEnvBuilder { }) } + pub fn set_fee_tier_config(mut self, cfg: FeeTierConfig) -> Self { + self.fee_tier_config = Some(cfg); + self + } + + pub fn set_governance_addr(mut self, addr: &Addr) -> Self { + self.governance_addr = Some(addr.clone()); + self + } + + pub fn set_swap_fee(mut self, fee: Decimal) -> Self { + self.swap_fee = Some(fee); + self + } //-------------------------------------------------------------------------------------------------- // Execute Msgs //-------------------------------------------------------------------------------------------------- @@ -1364,6 +1409,19 @@ impl MockEnvBuilder { let params = self.get_params_contract().into(); let keeper_fee_config = self.get_keeper_fee_config(); let swap_fee = self.get_swap_fee(); + let fee_tier_config = self.fee_tier_config.clone().unwrap_or(FeeTierConfig { + tiers: vec![FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::zero(), + discount_pct: Decimal::percent(0), + }], + }); + let governance = GovernanceUnchecked::new( + self.governance_addr + .clone() + .unwrap_or_else(|| Addr::unchecked("mock-governance")) + .to_string(), + ); self.deploy_rewards_collector(); self.deploy_astroport_incentives(); @@ -1389,6 +1447,8 @@ impl MockEnvBuilder { keeper_fee_config, perps_liquidation_bonus_ratio, swap_fee, + fee_tier_config, + governance_address: governance, }, &[], "mock-rover-contract", @@ -1432,6 +1492,88 @@ impl MockEnvBuilder { .unwrap() } + fn deploy_governance(&mut self, rover: &Addr) -> Addr { + let governance_addr = if let Some(addr) = self.governance_addr.clone() { + addr + } else { + let code_id = self.app.store_code(mock_governance_contract()); + self.app + .instantiate_contract(code_id, self.get_owner(), &(), &[], "mock-governance", None) + .unwrap() + }; + + // Register in address provider for queries that fetch Governance via AP + self.set_address(MarsAddressType::Governance, governance_addr.clone()); + + // Update CM config with governance address only + self.update_config( + rover, + ConfigUpdates { + governance_address: Some(GovernanceUnchecked::new(governance_addr.to_string())), + ..Default::default() + }, + ); + + governance_addr + } + + fn set_fee_tiers(&mut self, rover: &Addr) { + // Default 8-tier config if none provided (descending thresholds) + let fee_cfg = self.fee_tier_config.clone().unwrap_or(FeeTierConfig { + tiers: vec![ + FeeTier { + id: "tier_8".to_string(), + min_voting_power: Uint128::new(1500000000000), // 1,500,000 MARS = 1,500,000,000,000 uMARS + discount_pct: Decimal::percent(80), + }, + FeeTier { + id: "tier_7".to_string(), + min_voting_power: Uint128::new(1000000000000), // 1,000,000 MARS = 1,000,000,000,000 uMARS + discount_pct: Decimal::percent(70), + }, + FeeTier { + id: "tier_6".to_string(), + min_voting_power: Uint128::new(500000000000), // 500,000 MARS + discount_pct: Decimal::percent(60), + }, + FeeTier { + id: "tier_5".to_string(), + min_voting_power: Uint128::new(250000000000), // 250,000 MARS + discount_pct: Decimal::percent(45), + }, + FeeTier { + id: "tier_4".to_string(), + min_voting_power: Uint128::new(100000000000), // 100,000 MARS + discount_pct: Decimal::percent(30), + }, + FeeTier { + id: "tier_3".to_string(), + min_voting_power: Uint128::new(50000000000), // 50,000 MARS + discount_pct: Decimal::percent(20), + }, + FeeTier { + id: "tier_2".to_string(), + min_voting_power: Uint128::new(10000000000), // 10,000 MARS + discount_pct: Decimal::percent(10), + }, + FeeTier { + id: "tier_1".to_string(), + min_voting_power: Uint128::zero(), + discount_pct: Decimal::percent(0), + }, + ], + }); + + // Push into Rover config + self.update_config( + rover, + ConfigUpdates { + fee_tier_config: Some(fee_cfg), + ..Default::default() + }, + ); + } + fn get_oracle(&mut self) -> Oracle { if self.oracle.is_none() { let addr = self.deploy_oracle(); diff --git a/packages/types/src/adapters/credit_manager.rs b/packages/types/src/adapters/credit_manager.rs new file mode 100644 index 00000000..347d4e75 --- /dev/null +++ b/packages/types/src/adapters/credit_manager.rs @@ -0,0 +1,56 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Api, Decimal, QuerierWrapper, StdResult}; + +use crate::credit_manager::{AccountTierAndDiscountResponse, QueryMsg}; + +#[cw_serde] +pub struct CreditManagerBase(T); + +impl CreditManagerBase { + pub fn new(address: T) -> CreditManagerBase { + CreditManagerBase(address) + } + + pub fn address(&self) -> &T { + &self.0 + } +} + +pub type CreditManagerUnchecked = CreditManagerBase; +pub type CreditManager = CreditManagerBase; + +impl From for CreditManagerUnchecked { + fn from(cm: CreditManager) -> Self { + Self(cm.address().to_string()) + } +} + +impl CreditManagerUnchecked { + pub fn check(&self, api: &dyn Api) -> StdResult { + Ok(CreditManagerBase::new(api.addr_validate(self.address())?)) + } +} + +impl CreditManager { + pub fn query_account_tier_and_discount( + &self, + querier: &QuerierWrapper, + account_id: &str, + ) -> StdResult { + let res: AccountTierAndDiscountResponse = querier.query_wasm_smart( + self.address(), + &QueryMsg::GetAccountTierAndDiscount { + account_id: account_id.to_string(), + }, + )?; + Ok(res) + } + + pub fn query_discount_pct( + &self, + querier: &QuerierWrapper, + account_id: &str, + ) -> StdResult { + Ok(self.query_account_tier_and_discount(querier, account_id)?.discount_pct) + } +} diff --git a/packages/types/src/adapters/governance.rs b/packages/types/src/adapters/governance.rs new file mode 100644 index 00000000..33918b66 --- /dev/null +++ b/packages/types/src/adapters/governance.rs @@ -0,0 +1,60 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Api, QuerierWrapper, StdResult, Uint128}; + +#[cw_serde] +pub struct VotingPowerAtHeightQuery { + pub address: String, +} + +#[cw_serde] +pub struct VotingPowerAtHeightResponse { + pub power: Uint128, + pub height: u64, +} + +#[cw_serde] +pub enum GovernanceQueryMsg { + VotingPowerAtHeight(VotingPowerAtHeightQuery), +} + +#[cw_serde] +pub struct GovernanceBase(T); + +impl GovernanceBase { + pub fn new(address: T) -> GovernanceBase { + GovernanceBase(address) + } + + pub fn address(&self) -> &T { + &self.0 + } +} + +pub type GovernanceUnchecked = GovernanceBase; +pub type Governance = GovernanceBase; + +impl From for GovernanceUnchecked { + fn from(governance: Governance) -> Self { + Self(governance.address().to_string()) + } +} + +impl GovernanceUnchecked { + pub fn check(&self, api: &dyn Api) -> StdResult { + Ok(GovernanceBase::new(api.addr_validate(self.address())?)) + } +} + +impl Governance { + pub fn query_voting_power_at_height( + &self, + querier: &QuerierWrapper, + address: &str, + ) -> StdResult { + let query_msg = GovernanceQueryMsg::VotingPowerAtHeight(VotingPowerAtHeightQuery { + address: address.to_string(), + }); + + querier.query_wasm_smart(self.address().to_string(), &query_msg) + } +} diff --git a/packages/types/src/adapters/mod.rs b/packages/types/src/adapters/mod.rs index 6538389d..b535c9c6 100644 --- a/packages/types/src/adapters/mod.rs +++ b/packages/types/src/adapters/mod.rs @@ -1,4 +1,6 @@ pub mod account_nft; +pub mod credit_manager; +pub mod governance; pub mod health; pub mod incentives; pub mod oracle; diff --git a/packages/types/src/adapters/perps.rs b/packages/types/src/adapters/perps.rs index 1f030338..9552063e 100644 --- a/packages/types/src/adapters/perps.rs +++ b/packages/types/src/adapters/perps.rs @@ -1,6 +1,7 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{ - to_json_binary, Addr, Api, Coin, CosmosMsg, Int128, QuerierWrapper, StdResult, Uint128, WasmMsg, + to_json_binary, Addr, Api, Coin, CosmosMsg, Decimal, Int128, QuerierWrapper, StdResult, + Uint128, WasmMsg, }; use crate::{ @@ -96,6 +97,7 @@ impl Perps { size: Int128, reduce_only: Option, funds: Vec, + discount_pct: Option, ) -> StdResult { Ok(CosmosMsg::Wasm(WasmMsg::Execute { contract_addr: self.address().into(), @@ -104,6 +106,7 @@ impl Perps { denom: denom.into(), size, reduce_only, + discount_pct, })?, funds, })) @@ -115,12 +118,14 @@ impl Perps { account_id: impl Into, funds: Vec, action: ActionKind, + discount_pct: Option, ) -> StdResult { Ok(CosmosMsg::Wasm(WasmMsg::Execute { contract_addr: self.address().into(), msg: to_json_binary(&ExecuteMsg::CloseAllPositions { account_id: account_id.into(), action: Some(action), + discount_pct, })?, funds, })) @@ -167,12 +172,14 @@ impl Perps { querier: &QuerierWrapper, denom: impl Into, size: Int128, + discount_pct: Option, ) -> StdResult { let res: TradingFee = querier.query_wasm_smart( self.address(), &QueryMsg::OpeningFee { denom: denom.into(), size, + discount_pct, }, )?; Ok(res) diff --git a/packages/types/src/address_provider.rs b/packages/types/src/address_provider.rs index 22318fb5..b07f98c5 100644 --- a/packages/types/src/address_provider.rs +++ b/packages/types/src/address_provider.rs @@ -45,6 +45,8 @@ pub enum MarsAddressType { Health, /// The address that shall receive the revenue share given to neutron (10%) RevenueShare, + /// Governance contract + Governance, } impl fmt::Display for MarsAddressType { @@ -65,6 +67,7 @@ impl fmt::Display for MarsAddressType { MarsAddressType::Perps => "perps", MarsAddressType::Health => "health", MarsAddressType::RevenueShare => "revenue_share", + MarsAddressType::Governance => "governance", }; write!(f, "{s}") } @@ -90,6 +93,7 @@ impl FromStr for MarsAddressType { "perps" => Ok(MarsAddressType::Perps), "health" => Ok(MarsAddressType::Health), "revenue_share" => Ok(MarsAddressType::RevenueShare), + "governance" => Ok(MarsAddressType::Governance), _ => Err(StdError::parse_err(type_name::(), s)), } } @@ -172,6 +176,7 @@ pub struct AddressResponseItem { #[cw_serde] pub enum MigrateMsg { + V2_3_2ToV2_4_0 {}, V2_2_2ToV2_3_2 {}, V2_2_0ToV2_2_2 {}, } diff --git a/packages/types/src/credit_manager/instantiate.rs b/packages/types/src/credit_manager/instantiate.rs index c607161c..e190747a 100644 --- a/packages/types/src/credit_manager/instantiate.rs +++ b/packages/types/src/credit_manager/instantiate.rs @@ -2,11 +2,14 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{Decimal, Uint128}; use super::KeeperFeeConfig; -use crate::adapters::{ - account_nft::AccountNftUnchecked, health::HealthContractUnchecked, - incentives::IncentivesUnchecked, oracle::OracleUnchecked, params::ParamsUnchecked, - perps::PerpsUnchecked, red_bank::RedBankUnchecked, swapper::SwapperUnchecked, - zapper::ZapperUnchecked, +use crate::{ + adapters::{ + account_nft::AccountNftUnchecked, governance::GovernanceUnchecked, + health::HealthContractUnchecked, incentives::IncentivesUnchecked, oracle::OracleUnchecked, + params::ParamsUnchecked, perps::PerpsUnchecked, red_bank::RedBankUnchecked, + swapper::SwapperUnchecked, zapper::ZapperUnchecked, + }, + fee_tiers::FeeTierConfig, }; #[cw_serde] @@ -52,6 +55,10 @@ pub struct InstantiateMsg { /// For example, if set to 0.0001, 0.01% of the swap amount will be taken as a fee. /// This fee is applied once, no matter how many hops in the route pub swap_fee: Decimal, + /// Configuration for fee tiers based on staking + pub fee_tier_config: FeeTierConfig, + /// Address of the governance contract + pub governance_address: GovernanceUnchecked, } /// Used when you want to update fields on Instantiate config @@ -76,4 +83,7 @@ pub struct ConfigUpdates { pub keeper_fee_config: Option, pub perps_liquidation_bonus_ratio: Option, pub swap_fee: Option, + // Staking-based fee tiers + pub fee_tier_config: Option, + pub governance_address: Option, } diff --git a/packages/types/src/credit_manager/migrate.rs b/packages/types/src/credit_manager/migrate.rs index 985cf316..5cdb69f5 100644 --- a/packages/types/src/credit_manager/migrate.rs +++ b/packages/types/src/credit_manager/migrate.rs @@ -1,5 +1,7 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::Decimal; +use cosmwasm_std::{Addr, Decimal}; + +use crate::fee_tiers::FeeTierConfig; #[cw_serde] pub enum MigrateMsg { @@ -10,4 +12,8 @@ pub enum MigrateMsg { V2_3_0ToV2_3_1 { swap_fee: Decimal, }, + V2_3_1ToV2_4_0 { + fee_tier_config: FeeTierConfig, + governance_address: Addr, + }, } diff --git a/packages/types/src/credit_manager/query.rs b/packages/types/src/credit_manager/query.rs index 53e1884f..35c85444 100644 --- a/packages/types/src/credit_manager/query.rs +++ b/packages/types/src/credit_manager/query.rs @@ -8,9 +8,10 @@ use crate::{ rewards_collector::RewardsCollector, vault::{Vault, VaultPosition, VaultUnchecked}, }, + fee_tiers::FeeTierConfig, health::AccountKind, oracle::ActionKind, - perps::PerpPosition, + perps::{MarketType, PerpPosition}, traits::Coins, }; @@ -114,8 +115,49 @@ pub enum QueryMsg { start_after: Option, limit: Option, }, + + /// Get the staking tier and discount percentage for an account based on their voting power + #[returns(AccountTierAndDiscountResponse)] + GetAccountTierAndDiscount { + account_id: String, + }, + + /// Query the trading fee for a specific account and market type. + #[returns(TradingFeeResponse)] + TradingFee { + account_id: String, + market_type: MarketType, + }, + #[returns(Decimal)] SwapFeeRate {}, + + #[returns(FeeTierConfigResponse)] + FeeTierConfig {}, +} + +#[cw_serde] +pub struct SpotTradingFeeResponse { + pub base_fee_pct: Decimal, + pub discount_pct: Decimal, + pub effective_fee_pct: Decimal, + pub tier_id: String, +} + +#[cw_serde] +pub struct PerpTradingFeeResponse { + pub opening_fee_pct: Decimal, + pub closing_fee_pct: Decimal, + pub discount_pct: Decimal, + pub effective_opening_fee_pct: Decimal, + pub effective_closing_fee_pct: Decimal, + pub tier_id: String, +} + +#[cw_serde] +pub enum TradingFeeResponse { + Spot(SpotTradingFeeResponse), + Perp(PerpTradingFeeResponse), } #[cw_serde] @@ -231,6 +273,7 @@ pub struct ConfigResponse { pub rewards_collector: Option, pub keeper_fee_config: KeeperFeeConfig, pub perps_liquidation_bonus_ratio: Decimal, + pub governance: String, } #[cw_serde] @@ -244,3 +287,15 @@ pub struct VaultBinding { pub account_id: String, pub vault_address: String, } + +#[cw_serde] +pub struct AccountTierAndDiscountResponse { + pub tier_id: String, + pub discount_pct: Decimal, + pub voting_power: Uint128, +} + +#[cw_serde] +pub struct FeeTierConfigResponse { + pub fee_tier_config: FeeTierConfig, +} diff --git a/packages/types/src/fee_tiers.rs b/packages/types/src/fee_tiers.rs new file mode 100644 index 00000000..82309310 --- /dev/null +++ b/packages/types/src/fee_tiers.rs @@ -0,0 +1,38 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Decimal, Uint128}; + +#[cw_serde] +pub struct FeeTier { + pub id: String, + pub min_voting_power: Uint128, + pub discount_pct: Decimal, // Percentage as Decimal (e.g., 0.25 for 25%) +} + +#[cw_serde] +pub struct FeeTierConfig { + pub tiers: Vec, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum FeeTierQueryMsg { + #[returns(TradingFeeResponse)] + TradingFee { + address: String, + market_type: MarketType, + }, +} + +#[cw_serde] +pub struct TradingFeeResponse { + pub base_fee_bps: u16, + pub discount_pct: Decimal, + pub effective_fee_bps: u16, + pub bucket_id: String, +} + +#[cw_serde] +pub enum MarketType { + Spot, + Perp, +} diff --git a/packages/types/src/lib.rs b/packages/types/src/lib.rs index 0a681f98..56f93d21 100644 --- a/packages/types/src/lib.rs +++ b/packages/types/src/lib.rs @@ -3,6 +3,7 @@ pub mod adapters; pub mod address_provider; pub mod credit_manager; pub mod error; +pub mod fee_tiers; pub mod health; pub mod incentives; pub mod keys; diff --git a/packages/types/src/perps.rs b/packages/types/src/perps.rs index 7949cead..04c4e262 100644 --- a/packages/types/src/perps.rs +++ b/packages/types/src/perps.rs @@ -62,6 +62,14 @@ pub struct Config { /// The maximum number of unlocks that can be requested by a single user pub max_unlocks: u8, } +// ------------------------------- migrate messages ------------------------------- + +#[cw_serde] +pub enum MigrateMsg { + V2_2_1ToV2_2_3 {}, + V2_2_3ToV2_3_0 {}, + V2_3_0ToV2_4_0 {}, +} impl Config { pub fn check(self, api: &dyn Api) -> StdResult> { @@ -586,6 +594,9 @@ pub enum ExecuteMsg { // Reduce Only enforces a position size cannot increase in absolute terms, ensuring a position will never flip // from long to short or vice versa reduce_only: Option, + + // Discount percentage to apply to trading fees based on staking tier + discount_pct: Option, }, /// Close all perp positions. Use this to liquidate a user's credit account. @@ -595,6 +606,8 @@ pub enum ExecuteMsg { CloseAllPositions { account_id: String, action: Option, + // Discount percentage to apply to trading fees based on staking tier + discount_pct: Option, }, /// Deleveraging a vault by closing a position for an account. @@ -714,6 +727,7 @@ pub enum QueryMsg { OpeningFee { denom: String, size: Int128, + discount_pct: Option, }, /// Query the fees associated with modifying a specific position. @@ -797,6 +811,28 @@ pub struct PositionFeesResponse { pub closing_exec_price: Option, } +#[cw_serde] +pub enum MarketType { + Spot, + Perp { + denom: String, + }, +} + +#[cw_serde] +pub struct TradingFeeQuery { + pub account_id: String, + pub market_type: MarketType, +} + +#[cw_serde] +pub struct TradingFeeResponse { + pub base_fee_pct: Decimal, + pub discount_pct: Decimal, + pub effective_fee_pct: Decimal, + pub tier_id: String, +} + #[derive(Error, Debug, PartialEq)] pub enum PerpsError { #[error("{0}")] diff --git a/schemas/mars-address-provider/mars-address-provider.json b/schemas/mars-address-provider/mars-address-provider.json index 1cc65b71..faf979e4 100644 --- a/schemas/mars-address-provider/mars-address-provider.json +++ b/schemas/mars-address-provider/mars-address-provider.json @@ -1,6 +1,6 @@ { "contract_name": "mars-address-provider", - "contract_version": "2.3.2", + "contract_version": "2.4.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -143,6 +143,13 @@ "enum": [ "revenue_share" ] + }, + { + "description": "Governance contract", + "type": "string", + "enum": [ + "governance" + ] } ] }, @@ -382,6 +389,13 @@ "enum": [ "revenue_share" ] + }, + { + "description": "Governance contract", + "type": "string", + "enum": [ + "governance" + ] } ] } @@ -489,6 +503,13 @@ "enum": [ "revenue_share" ] + }, + { + "description": "Governance contract", + "type": "string", + "enum": [ + "governance" + ] } ] } @@ -599,6 +620,13 @@ "enum": [ "revenue_share" ] + }, + { + "description": "Governance contract", + "type": "string", + "enum": [ + "governance" + ] } ] } @@ -709,6 +737,13 @@ "enum": [ "revenue_share" ] + }, + { + "description": "Governance contract", + "type": "string", + "enum": [ + "governance" + ] } ] } diff --git a/schemas/mars-credit-manager/mars-credit-manager.json b/schemas/mars-credit-manager/mars-credit-manager.json index 2d8176e5..a6428d87 100644 --- a/schemas/mars-credit-manager/mars-credit-manager.json +++ b/schemas/mars-credit-manager/mars-credit-manager.json @@ -1,6 +1,6 @@ { "contract_name": "mars-credit-manager", - "contract_version": "2.3.1", + "contract_version": "2.4.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -8,6 +8,8 @@ "type": "object", "required": [ "duality_swapper", + "fee_tier_config", + "governance_address", "health_contract", "incentives", "keeper_fee_config", @@ -32,6 +34,22 @@ } ] }, + "fee_tier_config": { + "description": "Configuration for fee tiers based on staking", + "allOf": [ + { + "$ref": "#/definitions/FeeTierConfig" + } + ] + }, + "governance_address": { + "description": "Address of the governance contract", + "allOf": [ + { + "$ref": "#/definitions/GovernanceBase_for_String" + } + ] + }, "health_contract": { "description": "Helper contract for calculating health factor", "allOf": [ @@ -160,6 +178,44 @@ "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", "type": "string" }, + "FeeTier": { + "type": "object", + "required": [ + "discount_pct", + "id", + "min_voting_power" + ], + "properties": { + "discount_pct": { + "$ref": "#/definitions/Decimal" + }, + "id": { + "type": "string" + }, + "min_voting_power": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "FeeTierConfig": { + "type": "object", + "required": [ + "tiers" + ], + "properties": { + "tiers": { + "type": "array", + "items": { + "$ref": "#/definitions/FeeTier" + } + } + }, + "additionalProperties": false + }, + "GovernanceBase_for_String": { + "type": "string" + }, "HealthContractBase_for_String": { "type": "string" }, @@ -2440,6 +2496,26 @@ } ] }, + "fee_tier_config": { + "anyOf": [ + { + "$ref": "#/definitions/FeeTierConfig" + }, + { + "type": "null" + } + ] + }, + "governance_address": { + "anyOf": [ + { + "$ref": "#/definitions/GovernanceBase_for_String" + }, + { + "type": "null" + } + ] + }, "health_contract": { "anyOf": [ { @@ -2708,6 +2784,44 @@ } ] }, + "FeeTier": { + "type": "object", + "required": [ + "discount_pct", + "id", + "min_voting_power" + ], + "properties": { + "discount_pct": { + "$ref": "#/definitions/Decimal" + }, + "id": { + "type": "string" + }, + "min_voting_power": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "FeeTierConfig": { + "type": "object", + "required": [ + "tiers" + ], + "properties": { + "tiers": { + "type": "array", + "items": { + "$ref": "#/definitions/FeeTier" + } + } + }, + "additionalProperties": false + }, + "GovernanceBase_for_String": { + "type": "string" + }, "HealthContractBase_for_String": { "type": "string" }, @@ -3744,6 +3858,54 @@ }, "additionalProperties": false }, + { + "description": "Get the staking tier and discount percentage for an account based on their voting power", + "type": "object", + "required": [ + "get_account_tier_and_discount" + ], + "properties": { + "get_account_tier_and_discount": { + "type": "object", + "required": [ + "account_id" + ], + "properties": { + "account_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Query the trading fee for a specific account and market type.", + "type": "object", + "required": [ + "trading_fee" + ], + "properties": { + "trading_fee": { + "type": "object", + "required": [ + "account_id", + "market_type" + ], + "properties": { + "account_id": { + "type": "string" + }, + "market_type": { + "$ref": "#/definitions/MarketType" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "type": "object", "required": [ @@ -3756,6 +3918,19 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "required": [ + "fee_tier_config" + ], + "properties": { + "fee_tier_config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { @@ -3802,6 +3977,37 @@ }, "additionalProperties": false }, + "MarketType": { + "oneOf": [ + { + "type": "string", + "enum": [ + "spot" + ] + }, + { + "type": "object", + "required": [ + "perp" + ], + "properties": { + "perp": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "Uint128": { "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", "type": "string" @@ -6771,6 +6977,7 @@ "title": "ConfigResponse", "type": "object", "required": [ + "governance", "health_contract", "incentives", "keeper_fee_config", @@ -6792,6 +6999,9 @@ "null" ] }, + "governance": { + "type": "string" + }, "health_contract": { "type": "string" }, @@ -6967,6 +7177,97 @@ } } }, + "fee_tier_config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FeeTierConfigResponse", + "type": "object", + "required": [ + "fee_tier_config" + ], + "properties": { + "fee_tier_config": { + "$ref": "#/definitions/FeeTierConfig" + } + }, + "additionalProperties": false, + "definitions": { + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "FeeTier": { + "type": "object", + "required": [ + "discount_pct", + "id", + "min_voting_power" + ], + "properties": { + "discount_pct": { + "$ref": "#/definitions/Decimal" + }, + "id": { + "type": "string" + }, + "min_voting_power": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "FeeTierConfig": { + "type": "object", + "required": [ + "tiers" + ], + "properties": { + "tiers": { + "type": "array", + "items": { + "$ref": "#/definitions/FeeTier" + } + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_account_tier_and_discount": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AccountTierAndDiscountResponse", + "type": "object", + "required": [ + "discount_pct", + "tier_id", + "voting_power" + ], + "properties": { + "discount_pct": { + "$ref": "#/definitions/Decimal" + }, + "tier_id": { + "type": "string" + }, + "voting_power": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false, + "definitions": { + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, "positions": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Positions", @@ -7338,6 +7639,98 @@ } } }, + "trading_fee": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TradingFeeResponse", + "oneOf": [ + { + "type": "object", + "required": [ + "spot" + ], + "properties": { + "spot": { + "$ref": "#/definitions/SpotTradingFeeResponse" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "perp" + ], + "properties": { + "perp": { + "$ref": "#/definitions/PerpTradingFeeResponse" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "PerpTradingFeeResponse": { + "type": "object", + "required": [ + "closing_fee_pct", + "discount_pct", + "effective_closing_fee_pct", + "effective_opening_fee_pct", + "opening_fee_pct", + "tier_id" + ], + "properties": { + "closing_fee_pct": { + "$ref": "#/definitions/Decimal" + }, + "discount_pct": { + "$ref": "#/definitions/Decimal" + }, + "effective_closing_fee_pct": { + "$ref": "#/definitions/Decimal" + }, + "effective_opening_fee_pct": { + "$ref": "#/definitions/Decimal" + }, + "opening_fee_pct": { + "$ref": "#/definitions/Decimal" + }, + "tier_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "SpotTradingFeeResponse": { + "type": "object", + "required": [ + "base_fee_pct", + "discount_pct", + "effective_fee_pct", + "tier_id" + ], + "properties": { + "base_fee_pct": { + "$ref": "#/definitions/Decimal" + }, + "discount_pct": { + "$ref": "#/definitions/Decimal" + }, + "effective_fee_pct": { + "$ref": "#/definitions/Decimal" + }, + "tier_id": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, "vault_bindings": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Array_of_VaultBinding", diff --git a/schemas/mars-perps/mars-perps.json b/schemas/mars-perps/mars-perps.json index cf993f0f..76ff81ef 100644 --- a/schemas/mars-perps/mars-perps.json +++ b/schemas/mars-perps/mars-perps.json @@ -1,6 +1,6 @@ { "contract_name": "mars-perps", - "contract_version": "2.3.0", + "contract_version": "2.4.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -217,6 +217,16 @@ "denom": { "type": "string" }, + "discount_pct": { + "anyOf": [ + { + "$ref": "#/definitions/Decimal" + }, + { + "type": "null" + } + ] + }, "reduce_only": { "type": [ "boolean", @@ -257,6 +267,16 @@ "type": "null" } ] + }, + "discount_pct": { + "anyOf": [ + { + "$ref": "#/definitions/Decimal" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false @@ -993,6 +1013,16 @@ "denom": { "type": "string" }, + "discount_pct": { + "anyOf": [ + { + "$ref": "#/definitions/Decimal" + }, + { + "type": "null" + } + ] + }, "size": { "$ref": "#/definitions/Int128" } @@ -1042,6 +1072,10 @@ "liquidation" ] }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, "Int128": { "description": "An implementation of i128 that is using strings for JSON encoding/decoding, such that the full i128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `i128` to get the value out:\n\n``` # use cosmwasm_std::Int128; let a = Int128::from(258i128); assert_eq!(a.i128(), 258); ```", "type": "string" diff --git a/scripts/deploy/base/deployer.ts b/scripts/deploy/base/deployer.ts index 2f1a4e95..1cf3452f 100644 --- a/scripts/deploy/base/deployer.ts +++ b/scripts/deploy/base/deployer.ts @@ -210,6 +210,8 @@ export class Deployer { keeper_fee_config: this.config.keeperFeeConfig, perps_liquidation_bonus_ratio: this.config.perpsLiquidationBonusRatio, swap_fee: this.config.swapFee, + fee_tier_config: this.config.feeTierConfig, + governance_address: this.config.governance, } await this.instantiate('creditManager', this.storage.codeIds.creditManager!, msg) diff --git a/scripts/deploy/neutron/devnet-config.ts b/scripts/deploy/neutron/devnet-config.ts index f4144b59..52fecadf 100644 --- a/scripts/deploy/neutron/devnet-config.ts +++ b/scripts/deploy/neutron/devnet-config.ts @@ -510,4 +510,49 @@ export const neutronDevnetConfig: DeploymentConfig = { maxPerpParams: 20, perpsLiquidationBonusRatio: '0.6', swapFee: '0.0005', + feeTierConfig: { + tiers: [ + { + id: 'tier_8', + min_voting_power: '1500000000000', // 1,500,000 MARS + discount_pct: '0.80', // 80% discount + }, + { + id: 'tier_7', + min_voting_power: '1000000000000', // 1,000,000 MARS + discount_pct: '0.70', // 70% discount + }, + { + id: 'tier_6', + min_voting_power: '500000000000', // 500,000 MARS + discount_pct: '0.60', // 60% discount + }, + { + id: 'tier_5', + min_voting_power: '250000000000', // 250,000 MARS + discount_pct: '0.45', // 45% discount + }, + { + id: 'tier_4', + min_voting_power: '100000000000', // 100,000 MARS + discount_pct: '0.30', // 30% discount + }, + { + id: 'tier_3', + min_voting_power: '50000000000', // 50,000 MARS + discount_pct: '0.20', // 20% discount + }, + { + id: 'tier_2', + min_voting_power: '10000000000', // 10,000 MARS + discount_pct: '0.10', // 10% discount + }, + { + id: 'tier_1', + min_voting_power: '0', + discount_pct: '0.00', // 0% discount + }, + ], + }, + governance: 'neutron1pxjszcmmdxwtw9kv533u3hcudl6qahsa42chcs24gervf4ge40usaw3pcr', } diff --git a/scripts/deploy/neutron/mainnet-config.ts b/scripts/deploy/neutron/mainnet-config.ts index e06f7244..29ff387a 100644 --- a/scripts/deploy/neutron/mainnet-config.ts +++ b/scripts/deploy/neutron/mainnet-config.ts @@ -412,4 +412,8 @@ export const neutronMainnetConfig: DeploymentConfig = { maxPerpParams: 20, perpsLiquidationBonusRatio: '0.6', swapFee: '0.0005', + feeTierConfig: { + tiers: [], + }, + governance: '', } diff --git a/scripts/deploy/neutron/testnet-config.ts b/scripts/deploy/neutron/testnet-config.ts index 23340b14..8b7147ed 100644 --- a/scripts/deploy/neutron/testnet-config.ts +++ b/scripts/deploy/neutron/testnet-config.ts @@ -878,4 +878,8 @@ export const neutronTestnetConfig: DeploymentConfig = { maxPerpParams: 20, perpsLiquidationBonusRatio: '0.6', swapFee: '0.0005', + feeTierConfig: { + tiers: [], + }, + governance: '', } diff --git a/scripts/deploy/osmosis/mainnet-config.ts b/scripts/deploy/osmosis/mainnet-config.ts index 81288a27..eabee4eb 100644 --- a/scripts/deploy/osmosis/mainnet-config.ts +++ b/scripts/deploy/osmosis/mainnet-config.ts @@ -1117,4 +1117,8 @@ export const osmosisMainnetConfig: DeploymentConfig = { maxPerpParams: 20, perpsLiquidationBonusRatio: '0.6', swapFee: '0.0005', + feeTierConfig: { + tiers: [], + }, + governance: '', } diff --git a/scripts/deploy/osmosis/testnet-config.ts b/scripts/deploy/osmosis/testnet-config.ts index 2994d433..1ee5dbcc 100644 --- a/scripts/deploy/osmosis/testnet-config.ts +++ b/scripts/deploy/osmosis/testnet-config.ts @@ -309,4 +309,8 @@ export const osmosisTestnetConfig: DeploymentConfig = { maxPerpParams: 20, perpsLiquidationBonusRatio: '0.6', swapFee: '0.0005', + feeTierConfig: { + tiers: [], + }, + governance: '', } diff --git a/scripts/health/pkg-web/index_bg.wasm b/scripts/health/pkg-web/index_bg.wasm index a464c99b..789a7db3 100644 Binary files a/scripts/health/pkg-web/index_bg.wasm and b/scripts/health/pkg-web/index_bg.wasm differ diff --git a/scripts/health/pkg-web/package.json b/scripts/health/pkg-web/package.json index add497a0..7d4dbc22 100644 --- a/scripts/health/pkg-web/package.json +++ b/scripts/health/pkg-web/package.json @@ -7,6 +7,7 @@ "Gabe R. ", "Larry Engineer ", "Spike Spiegel ", + "Subham Singh ", "Brianna M. ", "Ahmad Kaouk", "Harry Scholes" diff --git a/scripts/types/config.ts b/scripts/types/config.ts index 64d4c8da..48413b3c 100644 --- a/scripts/types/config.ts +++ b/scripts/types/config.ts @@ -58,6 +58,15 @@ export interface AstroportConfig { incentives: string } +export interface FeeTierConfig { + tiers: FeeTier[] +} +export interface FeeTier { + discount_pct: Decimal + id: string + min_voting_power: string +} + export interface DeploymentConfig { mainnet: boolean deployerMnemonic: string @@ -130,6 +139,8 @@ export interface DeploymentConfig { maxPerpParams: number perpsLiquidationBonusRatio: Decimal swapFee: Decimal + feeTierConfig: FeeTierConfig + governance: string } export interface AssetConfig { diff --git a/scripts/types/generated/mars-address-provider/MarsAddressProvider.types.ts b/scripts/types/generated/mars-address-provider/MarsAddressProvider.types.ts index 264d5d44..33455b2a 100644 --- a/scripts/types/generated/mars-address-provider/MarsAddressProvider.types.ts +++ b/scripts/types/generated/mars-address-provider/MarsAddressProvider.types.ts @@ -30,6 +30,7 @@ export type MarsAddressType = | 'perps' | 'health' | 'revenue_share' + | 'governance' export type OwnerUpdate = | { propose_new_owner: { diff --git a/scripts/types/generated/mars-credit-manager/MarsCreditManager.client.ts b/scripts/types/generated/mars-credit-manager/MarsCreditManager.client.ts index fc7324f0..5585468f 100644 --- a/scripts/types/generated/mars-credit-manager/MarsCreditManager.client.ts +++ b/scripts/types/generated/mars-credit-manager/MarsCreditManager.client.ts @@ -9,15 +9,18 @@ import { CosmWasmClient, SigningCosmWasmClient, ExecuteResult } from '@cosmjs/co import { StdFee } from '@cosmjs/amino' import { SwapperBaseForString, + Decimal, + Uint128, + GovernanceBaseForString, HealthContractBaseForString, IncentivesUnchecked, - Uint128, - Decimal, OracleBaseForString, ParamsBaseForString, RedBankUnchecked, ZapperBaseForString, InstantiateMsg, + FeeTierConfig, + FeeTier, KeeperFeeConfig, Coin, ExecuteMsg, @@ -62,6 +65,7 @@ import { VaultAmount, VaultAmount1, UnlockingPositions, + MarketType, VaultPosition, LockingVaultAmount, VaultUnlockingPosition, @@ -85,10 +89,15 @@ import { OwnerResponse, RewardsCollector, ArrayOfCoin, + FeeTierConfigResponse, + AccountTierAndDiscountResponse, Positions, DebtAmount, PerpPosition, PnlAmounts, + TradingFeeResponse, + SpotTradingFeeResponse, + PerpTradingFeeResponse, ArrayOfVaultBinding, VaultBinding, VaultPositionValue, @@ -187,7 +196,20 @@ export interface MarsCreditManagerReadOnlyInterface { limit?: number startAfter?: string }) => Promise + getAccountTierAndDiscount: ({ + accountId, + }: { + accountId: string + }) => Promise + tradingFee: ({ + accountId, + marketType, + }: { + accountId: string + marketType: MarketType + }) => Promise swapFeeRate: () => Promise + feeTierConfig: () => Promise } export class MarsCreditManagerQueryClient implements MarsCreditManagerReadOnlyInterface { client: CosmWasmClient @@ -212,7 +234,10 @@ export class MarsCreditManagerQueryClient implements MarsCreditManagerReadOnlyIn this.allTriggerOrders = this.allTriggerOrders.bind(this) this.allAccountTriggerOrders = this.allAccountTriggerOrders.bind(this) this.vaultBindings = this.vaultBindings.bind(this) + this.getAccountTierAndDiscount = this.getAccountTierAndDiscount.bind(this) + this.tradingFee = this.tradingFee.bind(this) this.swapFeeRate = this.swapFeeRate.bind(this) + this.feeTierConfig = this.feeTierConfig.bind(this) } accountKind = async ({ accountId }: { accountId: string }): Promise => { return this.client.queryContractSmart(this.contractAddress, { @@ -420,11 +445,41 @@ export class MarsCreditManagerQueryClient implements MarsCreditManagerReadOnlyIn }, }) } + getAccountTierAndDiscount = async ({ + accountId, + }: { + accountId: string + }): Promise => { + return this.client.queryContractSmart(this.contractAddress, { + get_account_tier_and_discount: { + account_id: accountId, + }, + }) + } + tradingFee = async ({ + accountId, + marketType, + }: { + accountId: string + marketType: MarketType + }): Promise => { + return this.client.queryContractSmart(this.contractAddress, { + trading_fee: { + account_id: accountId, + market_type: marketType, + }, + }) + } swapFeeRate = async (): Promise => { return this.client.queryContractSmart(this.contractAddress, { swap_fee_rate: {}, }) } + feeTierConfig = async (): Promise => { + return this.client.queryContractSmart(this.contractAddress, { + fee_tier_config: {}, + }) + } } export interface MarsCreditManagerInterface extends MarsCreditManagerReadOnlyInterface { contractAddress: string diff --git a/scripts/types/generated/mars-credit-manager/MarsCreditManager.react-query.ts b/scripts/types/generated/mars-credit-manager/MarsCreditManager.react-query.ts index ffda35e6..d3eaeb70 100644 --- a/scripts/types/generated/mars-credit-manager/MarsCreditManager.react-query.ts +++ b/scripts/types/generated/mars-credit-manager/MarsCreditManager.react-query.ts @@ -10,15 +10,18 @@ import { ExecuteResult } from '@cosmjs/cosmwasm-stargate' import { StdFee } from '@cosmjs/amino' import { SwapperBaseForString, + Decimal, + Uint128, + GovernanceBaseForString, HealthContractBaseForString, IncentivesUnchecked, - Uint128, - Decimal, OracleBaseForString, ParamsBaseForString, RedBankUnchecked, ZapperBaseForString, InstantiateMsg, + FeeTierConfig, + FeeTier, KeeperFeeConfig, Coin, ExecuteMsg, @@ -63,6 +66,7 @@ import { VaultAmount, VaultAmount1, UnlockingPositions, + MarketType, VaultPosition, LockingVaultAmount, VaultUnlockingPosition, @@ -86,10 +90,15 @@ import { OwnerResponse, RewardsCollector, ArrayOfCoin, + FeeTierConfigResponse, + AccountTierAndDiscountResponse, Positions, DebtAmount, PerpPosition, PnlAmounts, + TradingFeeResponse, + SpotTradingFeeResponse, + PerpTradingFeeResponse, ArrayOfVaultBinding, VaultBinding, VaultPositionValue, @@ -248,6 +257,25 @@ export const marsCreditManagerQueryKeys = { args, }, ] as const, + getAccountTierAndDiscount: ( + contractAddress: string | undefined, + args?: Record, + ) => + [ + { + ...marsCreditManagerQueryKeys.address(contractAddress)[0], + method: 'get_account_tier_and_discount', + args, + }, + ] as const, + tradingFee: (contractAddress: string | undefined, args?: Record) => + [ + { + ...marsCreditManagerQueryKeys.address(contractAddress)[0], + method: 'trading_fee', + args, + }, + ] as const, swapFeeRate: (contractAddress: string | undefined, args?: Record) => [ { @@ -256,6 +284,14 @@ export const marsCreditManagerQueryKeys = { args, }, ] as const, + feeTierConfig: (contractAddress: string | undefined, args?: Record) => + [ + { + ...marsCreditManagerQueryKeys.address(contractAddress)[0], + method: 'fee_tier_config', + args, + }, + ] as const, } export interface MarsCreditManagerReactQuery { client: MarsCreditManagerQueryClient | undefined @@ -266,6 +302,21 @@ export interface MarsCreditManagerReactQuery { initialData?: undefined } } +export interface MarsCreditManagerFeeTierConfigQuery + extends MarsCreditManagerReactQuery {} +export function useMarsCreditManagerFeeTierConfigQuery({ + client, + options, +}: MarsCreditManagerFeeTierConfigQuery) { + return useQuery( + marsCreditManagerQueryKeys.feeTierConfig(client?.contractAddress), + () => (client ? client.feeTierConfig() : Promise.reject(new Error('Invalid client'))), + { + ...options, + enabled: !!client && (options?.enabled != undefined ? options.enabled : true), + }, + ) +} export interface MarsCreditManagerSwapFeeRateQuery extends MarsCreditManagerReactQuery {} export function useMarsCreditManagerSwapFeeRateQuery({ @@ -281,6 +332,56 @@ export function useMarsCreditManagerSwapFeeRateQuery({ }, ) } +export interface MarsCreditManagerTradingFeeQuery + extends MarsCreditManagerReactQuery { + args: { + accountId: string + marketType: MarketType + } +} +export function useMarsCreditManagerTradingFeeQuery({ + client, + args, + options, +}: MarsCreditManagerTradingFeeQuery) { + return useQuery( + marsCreditManagerQueryKeys.tradingFee(client?.contractAddress, args), + () => + client + ? client.tradingFee({ + accountId: args.accountId, + marketType: args.marketType, + }) + : Promise.reject(new Error('Invalid client')), + { + ...options, + enabled: !!client && (options?.enabled != undefined ? options.enabled : true), + }, + ) +} +export interface MarsCreditManagerGetAccountTierAndDiscountQuery + extends MarsCreditManagerReactQuery { + args: { + accountId: string + } +} +export function useMarsCreditManagerGetAccountTierAndDiscountQuery< + TData = AccountTierAndDiscountResponse, +>({ client, args, options }: MarsCreditManagerGetAccountTierAndDiscountQuery) { + return useQuery( + marsCreditManagerQueryKeys.getAccountTierAndDiscount(client?.contractAddress, args), + () => + client + ? client.getAccountTierAndDiscount({ + accountId: args.accountId, + }) + : Promise.reject(new Error('Invalid client')), + { + ...options, + enabled: !!client && (options?.enabled != undefined ? options.enabled : true), + }, + ) +} export interface MarsCreditManagerVaultBindingsQuery extends MarsCreditManagerReactQuery { args: { diff --git a/scripts/types/generated/mars-credit-manager/MarsCreditManager.types.ts b/scripts/types/generated/mars-credit-manager/MarsCreditManager.types.ts index bb3ee74b..9c514189 100644 --- a/scripts/types/generated/mars-credit-manager/MarsCreditManager.types.ts +++ b/scripts/types/generated/mars-credit-manager/MarsCreditManager.types.ts @@ -6,16 +6,19 @@ */ export type SwapperBaseForString = string +export type Decimal = string +export type Uint128 = string +export type GovernanceBaseForString = string export type HealthContractBaseForString = string export type IncentivesUnchecked = string -export type Uint128 = string -export type Decimal = string export type OracleBaseForString = string export type ParamsBaseForString = string export type RedBankUnchecked = string export type ZapperBaseForString = string export interface InstantiateMsg { duality_swapper: SwapperBaseForString + fee_tier_config: FeeTierConfig + governance_address: GovernanceBaseForString health_contract: HealthContractBaseForString incentives: IncentivesUnchecked keeper_fee_config: KeeperFeeConfig @@ -31,6 +34,14 @@ export interface InstantiateMsg { swapper: SwapperBaseForString zapper: ZapperBaseForString } +export interface FeeTierConfig { + tiers: FeeTier[] +} +export interface FeeTier { + discount_pct: Decimal + id: string + min_voting_power: Uint128 +} export interface KeeperFeeConfig { min_fee: Coin } @@ -620,6 +631,8 @@ export interface OsmoSwap { export interface ConfigUpdates { account_nft?: AccountNftBaseForString | null duality_swapper?: SwapperBaseForString | null + fee_tier_config?: FeeTierConfig | null + governance_address?: GovernanceBaseForString | null health_contract?: HealthContractBaseForString | null incentives?: IncentivesUnchecked | null keeper_fee_config?: KeeperFeeConfig | null @@ -751,9 +764,23 @@ export type QueryMsg = start_after?: string | null } } + | { + get_account_tier_and_discount: { + account_id: string + } + } + | { + trading_fee: { + account_id: string + market_type: MarketType + } + } | { swap_fee_rate: {} } + | { + fee_tier_config: {} + } export type ActionKind = 'default' | 'liquidation' export type VaultPositionAmount = | { @@ -765,6 +792,13 @@ export type VaultPositionAmount = export type VaultAmount = string export type VaultAmount1 = string export type UnlockingPositions = VaultUnlockingPosition[] +export type MarketType = + | 'spot' + | { + perp: { + denom: string + } + } export interface VaultPosition { amount: VaultPositionAmount vault: VaultBaseForAddr @@ -831,6 +865,7 @@ export interface VaultUtilizationResponse { } export interface ConfigResponse { account_nft?: string | null + governance: string health_contract: string incentives: string keeper_fee_config: KeeperFeeConfig @@ -858,6 +893,14 @@ export interface RewardsCollector { address: string } export type ArrayOfCoin = Coin[] +export interface FeeTierConfigResponse { + fee_tier_config: FeeTierConfig +} +export interface AccountTierAndDiscountResponse { + discount_pct: Decimal + tier_id: string + voting_power: Uint128 +} export interface Positions { account_id: string account_kind: AccountKind @@ -891,6 +934,27 @@ export interface PnlAmounts { pnl: Int128 price_pnl: Int128 } +export type TradingFeeResponse = + | { + spot: SpotTradingFeeResponse + } + | { + perp: PerpTradingFeeResponse + } +export interface SpotTradingFeeResponse { + base_fee_pct: Decimal + discount_pct: Decimal + effective_fee_pct: Decimal + tier_id: string +} +export interface PerpTradingFeeResponse { + closing_fee_pct: Decimal + discount_pct: Decimal + effective_closing_fee_pct: Decimal + effective_opening_fee_pct: Decimal + opening_fee_pct: Decimal + tier_id: string +} export type ArrayOfVaultBinding = VaultBinding[] export interface VaultBinding { account_id: string diff --git a/scripts/types/generated/mars-perps/MarsPerps.client.ts b/scripts/types/generated/mars-perps/MarsPerps.client.ts index e29bd76d..df90094f 100644 --- a/scripts/types/generated/mars-perps/MarsPerps.client.ts +++ b/scripts/types/generated/mars-perps/MarsPerps.client.ts @@ -101,7 +101,15 @@ export interface MarsPerpsReadOnlyInterface { }) => Promise marketAccounting: ({ denom }: { denom: string }) => Promise totalAccounting: () => Promise - openingFee: ({ denom, size }: { denom: string; size: Int128 }) => Promise + openingFee: ({ + denom, + discountPct, + size, + }: { + denom: string + discountPct?: Decimal + size: Int128 + }) => Promise positionFees: ({ accountId, denom, @@ -267,10 +275,19 @@ export class MarsPerpsQueryClient implements MarsPerpsReadOnlyInterface { total_accounting: {}, }) } - openingFee = async ({ denom, size }: { denom: string; size: Int128 }): Promise => { + openingFee = async ({ + denom, + discountPct, + size, + }: { + denom: string + discountPct?: Decimal + size: Int128 + }): Promise => { return this.client.queryContractSmart(this.contractAddress, { opening_fee: { denom, + discount_pct: discountPct, size, }, }) @@ -342,11 +359,13 @@ export interface MarsPerpsInterface extends MarsPerpsReadOnlyInterface { { accountId, denom, + discountPct, reduceOnly, size, }: { accountId: string denom: string + discountPct?: Decimal reduceOnly?: boolean size: Int128 }, @@ -358,9 +377,11 @@ export interface MarsPerpsInterface extends MarsPerpsReadOnlyInterface { { accountId, action, + discountPct, }: { accountId: string action?: ActionKind + discountPct?: Decimal }, fee?: number | StdFee | 'auto', memo?: string, @@ -517,11 +538,13 @@ export class MarsPerpsClient extends MarsPerpsQueryClient implements MarsPerpsIn { accountId, denom, + discountPct, reduceOnly, size, }: { accountId: string denom: string + discountPct?: Decimal reduceOnly?: boolean size: Int128 }, @@ -536,6 +559,7 @@ export class MarsPerpsClient extends MarsPerpsQueryClient implements MarsPerpsIn execute_order: { account_id: accountId, denom, + discount_pct: discountPct, reduce_only: reduceOnly, size, }, @@ -549,9 +573,11 @@ export class MarsPerpsClient extends MarsPerpsQueryClient implements MarsPerpsIn { accountId, action, + discountPct, }: { accountId: string action?: ActionKind + discountPct?: Decimal }, fee: number | StdFee | 'auto' = 'auto', memo?: string, @@ -564,6 +590,7 @@ export class MarsPerpsClient extends MarsPerpsQueryClient implements MarsPerpsIn close_all_positions: { account_id: accountId, action, + discount_pct: discountPct, }, }, fee, diff --git a/scripts/types/generated/mars-perps/MarsPerps.react-query.ts b/scripts/types/generated/mars-perps/MarsPerps.react-query.ts index 33576519..5c0b1086 100644 --- a/scripts/types/generated/mars-perps/MarsPerps.react-query.ts +++ b/scripts/types/generated/mars-perps/MarsPerps.react-query.ts @@ -226,6 +226,7 @@ export function useMarsPerpsPositionFeesQuery({ export interface MarsPerpsOpeningFeeQuery extends MarsPerpsReactQuery { args: { denom: string + discountPct?: Decimal size: Int128 } } @@ -240,6 +241,7 @@ export function useMarsPerpsOpeningFeeQuery({ client ? client.openingFee({ denom: args.denom, + discountPct: args.discountPct, size: args.size, }) : Promise.reject(new Error('Invalid client')), @@ -630,6 +632,7 @@ export interface MarsPerpsCloseAllPositionsMutation { msg: { accountId: string action?: ActionKind + discountPct?: Decimal } args?: { fee?: number | StdFee | 'auto' @@ -654,6 +657,7 @@ export interface MarsPerpsExecuteOrderMutation { msg: { accountId: string denom: string + discountPct?: Decimal reduceOnly?: boolean size: Int128 } diff --git a/scripts/types/generated/mars-perps/MarsPerps.types.ts b/scripts/types/generated/mars-perps/MarsPerps.types.ts index 511ca899..e60a1e41 100644 --- a/scripts/types/generated/mars-perps/MarsPerps.types.ts +++ b/scripts/types/generated/mars-perps/MarsPerps.types.ts @@ -43,6 +43,7 @@ export type ExecuteMsg = execute_order: { account_id: string denom: string + discount_pct?: Decimal | null reduce_only?: boolean | null size: Int128 } @@ -51,6 +52,7 @@ export type ExecuteMsg = close_all_positions: { account_id: string action?: ActionKind | null + discount_pct?: Decimal | null } } | { @@ -185,6 +187,7 @@ export type QueryMsg = | { opening_fee: { denom: string + discount_pct?: Decimal | null size: Int128 } }