diff --git a/contracts/tokenomics/maker/src/contract.rs b/contracts/tokenomics/maker/src/contract.rs index 2be118d6..92ca71a1 100644 --- a/contracts/tokenomics/maker/src/contract.rs +++ b/contracts/tokenomics/maker/src/contract.rs @@ -3,9 +3,9 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; use cosmwasm_std::{ - attr, ensure, entry_point, to_json_binary, to_json_string, Addr, Attribute, Binary, Decimal, - Deps, DepsMut, Env, MessageInfo, Order, ReplyOn, Response, StdError, StdResult, SubMsg, - Uint128, Uint64, + attr, ensure, ensure_eq, entry_point, to_json_binary, to_json_string, Addr, Attribute, Binary, + Decimal, Deps, DepsMut, Env, MessageInfo, Order, ReplyOn, Response, StdError, StdResult, + SubMsg, Uint128, Uint64, }; use cw2::{get_contract_version, set_contract_version}; @@ -14,14 +14,15 @@ use astroport::common::{claim_ownership, drop_ownership_proposal, propose_new_ow use astroport::factory::UpdateAddr; use astroport::maker::{ AssetWithLimit, BalancesResponse, Config, ConfigResponse, ExecuteMsg, InstantiateMsg, - MigrateMsg, QueryMsg, SecondReceiverConfig, SecondReceiverParams, UpdateDevFundConfig, + MigrateMsg, QueryMsg, SecondReceiverConfig, SecondReceiverParams, SeizeConfig, + UpdateDevFundConfig, }; use astroport::pair::MAX_ALLOWED_SLIPPAGE; use crate::error::ContractError; use crate::migration::migrate_from_v120_plus; use crate::reply::PROCESS_DEV_FUND_REPLY_ID; -use crate::state::{BRIDGES, CONFIG, LAST_COLLECT_TS, OWNERSHIP_PROPOSAL}; +use crate::state::{BRIDGES, CONFIG, LAST_COLLECT_TS, OWNERSHIP_PROPOSAL, SEIZE_CONFIG}; use crate::utils::{ build_distribute_msg, build_send_msg, build_swap_msg, get_pool, try_build_swap_msg, update_second_receiver_cfg, validate_bridge, validate_cooldown, BRIDGES_EXECUTION_MAX_DEPTH, @@ -120,6 +121,16 @@ pub fn instantiate( (String::from("none"), String::from("0")) }; + SEIZE_CONFIG.save( + deps.storage, + &SeizeConfig { + // set to invalid address initially + // governance must update this explicitly + receiver: Addr::unchecked(""), + seizable_assets: vec![], + }, + )?; + Ok(Response::default().add_attributes([ attr("owner", msg.owner), attr( @@ -270,6 +281,25 @@ pub fn execute( Ok(Response::default().add_attribute("action", "enable_rewards")) } + ExecuteMsg::Seize { assets } => seize(deps, env, assets), + ExecuteMsg::UpdateSeizeConfig { + receiver, + seizable_assets, + } => { + let config = CONFIG.load(deps.storage)?; + + ensure_eq!(info.sender, config.owner, ContractError::Unauthorized {}); + + SEIZE_CONFIG.update::<_, StdError>(deps.storage, |mut seize_config| { + if let Some(receiver) = receiver { + seize_config.receiver = deps.api.addr_validate(&receiver)?; + } + seize_config.seizable_assets = seizable_assets; + Ok(seize_config) + })?; + + Ok(Response::new().add_attribute("action", "update_seize_config")) + } } } @@ -870,6 +900,61 @@ fn update_bridges( Ok(Response::default().add_attribute("action", "update_bridges")) } +fn seize(deps: DepsMut, env: Env, assets: Vec) -> Result { + ensure!( + !assets.is_empty(), + StdError::generic_err("assets vector is empty") + ); + + let conf = SEIZE_CONFIG.load(deps.storage)?; + + ensure!( + !conf.seizable_assets.is_empty(), + StdError::generic_err("No seizable assets found") + ); + + let input_set = assets + .iter() + .map(|a| a.info.to_string()) + .collect::>(); + let seizable_set = conf + .seizable_assets + .iter() + .map(|a| a.to_string()) + .collect::>(); + + ensure!( + input_set.is_subset(&seizable_set), + StdError::generic_err("Input vector contains assets that are not seizable") + ); + + let send_msgs = assets + .into_iter() + .filter_map(|asset| { + let balance = asset + .info + .query_pool(&deps.querier, &env.contract.address) + .ok()?; + + let limit = asset + .limit + .map(|limit| limit.min(balance)) + .unwrap_or(balance); + + // Filter assets with empty balances + if limit.is_zero() { + None + } else { + Some(asset.info.with_balance(limit).into_msg(&conf.receiver)) + } + }) + .collect::>>()?; + + Ok(Response::new() + .add_messages(send_msgs) + .add_attribute("action", "seize")) +} + /// Exposes all the queries available in the contract. /// /// ## Queries @@ -886,6 +971,7 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { QueryMsg::Config {} => to_json_binary(&query_get_config(deps)?), QueryMsg::Balances { assets } => to_json_binary(&query_get_balances(deps, env, assets)?), QueryMsg::Bridges {} => to_json_binary(&query_bridges(deps)?), + QueryMsg::QuerySeizeConfig {} => to_json_binary(&SEIZE_CONFIG.load(deps.storage)?), } } @@ -952,13 +1038,32 @@ pub fn migrate(mut deps: DepsMut, env: Env, msg: MigrateMsg) -> Result { migrate_from_v120_plus(deps.branch(), msg)?; LAST_COLLECT_TS.save(deps.storage, &env.block.time.seconds())?; + + SEIZE_CONFIG.save( + deps.storage, + &SeizeConfig { + // set to invalid address initially + // governance must update this explicitly + receiver: Addr::unchecked(""), + seizable_assets: vec![], + }, + )?; } - "1.4.0" => {} - "1.5.0" => { + "1.4.0" | "1.5.0" => { // It is enough to load and save config // as we added only one optional field config.dev_fund_conf let config = CONFIG.load(deps.storage)?; CONFIG.save(deps.storage, &config)?; + + SEIZE_CONFIG.save( + deps.storage, + &SeizeConfig { + // set to invalid address initially + // governance must update this explicitly + receiver: Addr::unchecked(""), + seizable_assets: vec![], + }, + )?; } _ => return Err(ContractError::MigrationError {}), }, diff --git a/contracts/tokenomics/maker/src/state.rs b/contracts/tokenomics/maker/src/state.rs index e60ebba4..75eec725 100644 --- a/contracts/tokenomics/maker/src/state.rs +++ b/contracts/tokenomics/maker/src/state.rs @@ -1,6 +1,6 @@ use astroport::asset::AssetInfo; use astroport::common::OwnershipProposal; -use astroport::maker::Config; +use astroport::maker::{Config, SeizeConfig}; use cw_storage_plus::{Item, Map}; /// Stores the contract configuration at the given key @@ -13,3 +13,5 @@ pub const OWNERSHIP_PROPOSAL: Item = Item::new("ownership_pro pub const BRIDGES: Map = Map::new("bridges"); /// Stores the latest timestamp when fees were collected pub const LAST_COLLECT_TS: Item = Item::new("last_collect_ts"); +/// Stores seize config +pub const SEIZE_CONFIG: Item = Item::new("seize_config"); diff --git a/contracts/tokenomics/maker/tests/maker_integration.rs b/contracts/tokenomics/maker/tests/maker_integration.rs index a219bed2..c641ba85 100644 --- a/contracts/tokenomics/maker/tests/maker_integration.rs +++ b/contracts/tokenomics/maker/tests/maker_integration.rs @@ -2,17 +2,14 @@ use std::str::FromStr; -use astroport_test::cw_multi_test::{ - next_block, AppBuilder, AppResponse, BankSudo, Contract, ContractWrapper, Executor, -}; -use astroport_test::modules::stargate::{MockStargate, StargateApp as TestApp}; +use anyhow::Result as AnyResult; use cosmwasm_std::{ attr, coin, to_json_binary, Addr, Binary, Coin, Decimal, Deps, DepsMut, Empty, Env, MessageInfo, QueryRequest, Response, StdResult, Uint128, Uint64, WasmQuery, }; use cw20::{BalanceResponse, Cw20QueryMsg, MinterResponse}; +use cw20_base::msg::InstantiateMsg as TokenInstantiateMsg; -use anyhow::Result as AnyResult; use astroport::asset::{ native_asset, native_asset_info, token_asset, token_asset_info, Asset, AssetInfo, AssetInfoExt, PairInfo, @@ -20,10 +17,14 @@ use astroport::asset::{ use astroport::factory::{PairConfig, PairType, UpdateAddr}; use astroport::maker::{ AssetWithLimit, BalancesResponse, ConfigResponse, DevFundConfig, ExecuteMsg, InstantiateMsg, - QueryMsg, SecondReceiverConfig, SecondReceiverParams, UpdateDevFundConfig, COOLDOWN_LIMITS, + QueryMsg, SecondReceiverConfig, SecondReceiverParams, SeizeConfig, UpdateDevFundConfig, + COOLDOWN_LIMITS, }; use astroport_maker::error::ContractError; -use cw20_base::msg::InstantiateMsg as TokenInstantiateMsg; +use astroport_test::cw_multi_test::{ + next_block, AppBuilder, AppResponse, BankSudo, Contract, ContractWrapper, Executor, +}; +use astroport_test::modules::stargate::{MockStargate, StargateApp as TestApp}; const OWNER: &str = "owner"; @@ -2356,6 +2357,208 @@ fn test_dev_fund_fee() { ); } +#[test] +fn test_seize() { + let owner = Addr::unchecked("owner"); + let mut app = mock_app(owner.clone(), vec![]); + + let (_, _, maker_instance, _) = instantiate_contracts( + &mut app, + owner.clone(), + Addr::unchecked("staking"), + 0u64.into(), + Some(Decimal::from_str("0.5").unwrap()), + None, + None, + None, + ); + + // Try to seize before config is set + let err = app + .execute_contract( + owner.clone(), + maker_instance.clone(), + &ExecuteMsg::Seize { assets: vec![] }, + &[], + ) + .unwrap_err(); + assert_eq!( + err.root_cause().to_string(), + "Generic error: assets vector is empty" + ); + + // Unauthorized check + let err = app + .execute_contract( + Addr::unchecked("anyone"), + maker_instance.clone(), + &ExecuteMsg::UpdateSeizeConfig { + receiver: None, + seizable_assets: vec![], + }, + &[], + ) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + let receiver = Addr::unchecked("seize"); + + let usdc = "uusdc"; + let luna = "uluna"; + + // Set valid config + app.execute_contract( + owner.clone(), + maker_instance.clone(), + &ExecuteMsg::UpdateSeizeConfig { + receiver: Some(receiver.to_string()), + seizable_assets: vec![AssetInfo::native(usdc), AssetInfo::native(luna)], + }, + &[], + ) + .unwrap(); + + // Assert that the config is set + let config: SeizeConfig = app + .wrap() + .query_wasm_smart(&maker_instance, &QueryMsg::QuerySeizeConfig {}) + .unwrap(); + assert_eq!( + config, + SeizeConfig { + receiver: receiver.clone(), + seizable_assets: vec![AssetInfo::native(usdc), AssetInfo::native(luna)] + } + ); + + // Try to seize non-seizable asset + let err = app + .execute_contract( + owner.clone(), + maker_instance.clone(), + &ExecuteMsg::Seize { + assets: vec![AssetWithLimit { + info: AssetInfo::native("utest"), + limit: None, + }], + }, + &[], + ) + .unwrap_err(); + assert_eq!( + err.root_cause().to_string(), + "Generic error: Input vector contains assets that are not seizable" + ); + + // Try to seize asset with empty balance + // This does nothing and doesn't throw an error + app.execute_contract( + owner.clone(), + maker_instance.clone(), + &ExecuteMsg::Seize { + assets: vec![AssetWithLimit { + info: AssetInfo::native(luna), + limit: None, + }], + }, + &[], + ) + .unwrap(); + + mint_coins( + &mut app, + &maker_instance, + &[coin(1000_000000u128, usdc), coin(3000_000000u128, luna)], + ); + + // Seize 100 USDC + app.execute_contract( + owner.clone(), + maker_instance.clone(), + &ExecuteMsg::Seize { + assets: vec![AssetWithLimit { + info: AssetInfo::native(usdc), + limit: Some(100_000000u128.into()), + }], + }, + &[], + ) + .unwrap(); + + // Check balances + assert_eq!( + app.wrap() + .query_balance(&maker_instance, usdc) + .unwrap() + .amount + .u128(), + 900_000000 + ); + assert_eq!( + app.wrap() + .query_balance(&receiver, usdc) + .unwrap() + .amount + .u128(), + 100_000000 + ); + + // Seize all + app.execute_contract( + owner.clone(), + maker_instance.clone(), + &ExecuteMsg::Seize { + assets: vec![ + AssetWithLimit { + info: AssetInfo::native(usdc), + // seizing more than available doesn't throw an error + limit: Some(10000_000000u128.into()), + }, + AssetWithLimit { + info: AssetInfo::native(luna), + limit: Some(3000_000000u128.into()), + }, + ], + }, + &[], + ) + .unwrap(); + + // Check balances + assert_eq!( + app.wrap() + .query_balance(&maker_instance, usdc) + .unwrap() + .amount + .u128(), + 0 + ); + assert_eq!( + app.wrap() + .query_balance(&maker_instance, luna) + .unwrap() + .amount + .u128(), + 0 + ); + assert_eq!( + app.wrap() + .query_balance(&receiver, usdc) + .unwrap() + .amount + .u128(), + 1000_000000 + ); + assert_eq!( + app.wrap() + .query_balance(&receiver, luna) + .unwrap() + .amount + .u128(), + 3000_000000 + ); +} + struct CheckDistributedAstro { maker_amount: Uint128, governance_amount: Uint128, diff --git a/packages/astroport/src/maker.rs b/packages/astroport/src/maker.rs index 8924443f..e1083364 100644 --- a/packages/astroport/src/maker.rs +++ b/packages/astroport/src/maker.rs @@ -139,6 +139,21 @@ pub enum ExecuteMsg { ClaimOwnership {}, /// Enables the distribution of current fees accrued in the contract over "blocks" number of blocks EnableRewards { blocks: u64 }, + /// Permissionless endpoint that sends certain assets to predefined seizing address + Seize { + /// The assets to seize + assets: Vec, + }, + /// Sets parameters for seizing assets. + /// Permissioned to a contract owner. + /// If governance wants to stop seizing assets, it can set an empty list of seizable assets. + UpdateSeizeConfig { + /// The address that will receive the seized tokens + receiver: Option, + /// The assets that can be seized. Resets the list to this one every time it is executed + #[serde(default)] + seizable_assets: Vec, + }, } /// This structure describes the query functions available in the contract. @@ -153,6 +168,9 @@ pub enum QueryMsg { Balances { assets: Vec }, #[returns(Vec<(String, String)>)] Bridges {}, + /// Returns the seize config + #[returns(SeizeConfig)] + QuerySeizeConfig {}, } /// A custom struct that holds contract parameters and is used to retrieve them. @@ -224,5 +242,13 @@ pub struct SecondReceiverConfig { pub second_receiver_cut: Uint64, } +#[cw_serde] +pub struct SeizeConfig { + /// The address of the contract that will receive the seized tokens + pub receiver: Addr, + /// The assets that can be seized + pub seizable_assets: Vec, +} + /// The maximum allowed second receiver share (percents) pub const MAX_SECOND_RECEIVER_CUT: Uint64 = Uint64::new(50); diff --git a/schemas/astroport-maker/astroport-maker.json b/schemas/astroport-maker/astroport-maker.json index 19c3fdf3..41f6e812 100644 --- a/schemas/astroport-maker/astroport-maker.json +++ b/schemas/astroport-maker/astroport-maker.json @@ -502,6 +502,63 @@ } }, "additionalProperties": false + }, + { + "description": "Permissionless endpoint that sends certain assets to predefined seizing address", + "type": "object", + "required": [ + "seize" + ], + "properties": { + "seize": { + "type": "object", + "required": [ + "assets" + ], + "properties": { + "assets": { + "description": "The assets to seize", + "type": "array", + "items": { + "$ref": "#/definitions/AssetWithLimit" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets parameters for seizing assets. Permissioned to a contract owner. If governance wants to stop seizing assets, it can set an empty list of seizable assets.", + "type": "object", + "required": [ + "update_seize_config" + ], + "properties": { + "update_seize_config": { + "type": "object", + "properties": { + "receiver": { + "description": "The address that will receive the seized tokens", + "type": [ + "string", + "null" + ] + }, + "seizable_assets": { + "description": "The assets that can be seized. Resets the list to this one every time it is executed", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AssetInfo" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { @@ -760,6 +817,20 @@ } }, "additionalProperties": false + }, + { + "description": "Returns the seize config", + "type": "object", + "required": [ + "query_seize_config" + ], + "properties": { + "query_seize_config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { @@ -1251,6 +1322,88 @@ "type": "string" } } + }, + "query_seize_config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SeizeConfig", + "type": "object", + "required": [ + "receiver", + "seizable_assets" + ], + "properties": { + "receiver": { + "description": "The address of the contract that will receive the seized tokens", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "seizable_assets": { + "description": "The assets that can be seized", + "type": "array", + "items": { + "$ref": "#/definitions/AssetInfo" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "AssetInfo": { + "description": "This enum describes available Token types. ## Examples ``` # use cosmwasm_std::Addr; # use astroport::asset::AssetInfo::{NativeToken, Token}; Token { contract_addr: Addr::unchecked(\"stake...\") }; NativeToken { denom: String::from(\"uluna\") }; ```", + "oneOf": [ + { + "description": "Non-native Token", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Native token", + "type": "object", + "required": [ + "native_token" + ], + "properties": { + "native_token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } } } } diff --git a/schemas/astroport-maker/raw/execute.json b/schemas/astroport-maker/raw/execute.json index 028d6fab..d14e4cfb 100644 --- a/schemas/astroport-maker/raw/execute.json +++ b/schemas/astroport-maker/raw/execute.json @@ -315,6 +315,63 @@ } }, "additionalProperties": false + }, + { + "description": "Permissionless endpoint that sends certain assets to predefined seizing address", + "type": "object", + "required": [ + "seize" + ], + "properties": { + "seize": { + "type": "object", + "required": [ + "assets" + ], + "properties": { + "assets": { + "description": "The assets to seize", + "type": "array", + "items": { + "$ref": "#/definitions/AssetWithLimit" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets parameters for seizing assets. Permissioned to a contract owner. If governance wants to stop seizing assets, it can set an empty list of seizable assets.", + "type": "object", + "required": [ + "update_seize_config" + ], + "properties": { + "update_seize_config": { + "type": "object", + "properties": { + "receiver": { + "description": "The address that will receive the seized tokens", + "type": [ + "string", + "null" + ] + }, + "seizable_assets": { + "description": "The assets that can be seized. Resets the list to this one every time it is executed", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AssetInfo" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { diff --git a/schemas/astroport-maker/raw/query.json b/schemas/astroport-maker/raw/query.json index 74f087ea..c1602afb 100644 --- a/schemas/astroport-maker/raw/query.json +++ b/schemas/astroport-maker/raw/query.json @@ -54,6 +54,20 @@ } }, "additionalProperties": false + }, + { + "description": "Returns the seize config", + "type": "object", + "required": [ + "query_seize_config" + ], + "properties": { + "query_seize_config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { diff --git a/schemas/astroport-maker/raw/response_to_query_seize_config.json b/schemas/astroport-maker/raw/response_to_query_seize_config.json new file mode 100644 index 00000000..874f5257 --- /dev/null +++ b/schemas/astroport-maker/raw/response_to_query_seize_config.json @@ -0,0 +1,82 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SeizeConfig", + "type": "object", + "required": [ + "receiver", + "seizable_assets" + ], + "properties": { + "receiver": { + "description": "The address of the contract that will receive the seized tokens", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "seizable_assets": { + "description": "The assets that can be seized", + "type": "array", + "items": { + "$ref": "#/definitions/AssetInfo" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "AssetInfo": { + "description": "This enum describes available Token types. ## Examples ``` # use cosmwasm_std::Addr; # use astroport::asset::AssetInfo::{NativeToken, Token}; Token { contract_addr: Addr::unchecked(\"stake...\") }; NativeToken { denom: String::from(\"uluna\") }; ```", + "oneOf": [ + { + "description": "Non-native Token", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Native token", + "type": "object", + "required": [ + "native_token" + ], + "properties": { + "native_token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } +}