diff --git a/Cargo.lock b/Cargo.lock index a44324f8e..1a93f9b0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1540,16 +1540,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "ctor" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" -dependencies = [ - "quote", - "syn 1.0.109", -] - [[package]] name = "ctr" version = "0.8.0" @@ -3783,7 +3773,7 @@ dependencies = [ [[package]] name = "hydradx-adapters" -version = "0.4.1" +version = "0.4.2" dependencies = [ "cumulus-pallet-parachain-system", "cumulus-primitives-core", @@ -3793,19 +3783,28 @@ dependencies = [ "hydradx-traits", "lazy_static", "log", + "orml-tokens", "orml-traits", + "orml-utilities", "orml-xcm-support", + "pallet-balances", "pallet-circuit-breaker", + "pallet-currencies", "pallet-dynamic-fees", "pallet-ema-oracle", "pallet-liquidity-mining", "pallet-omnipool", "pallet-omnipool-liquidity-mining", + "pallet-route-executor", "pallet-transaction-multi-payment", "parity-scale-codec", "polkadot-parachain", + "pretty_assertions", "primitive-types", "primitives", + "scale-info", + "sp-core", + "sp-io", "sp-runtime", "sp-std", "xcm", @@ -6163,15 +6162,6 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d5d9eb14b174ee9aa2ef96dc2b94637a2d4b6e7cb873c7e171f0c20c6cf3eac" -[[package]] -name = "output_vt100" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" -dependencies = [ - "winapi", -] - [[package]] name = "p256" version = "0.11.1" @@ -9297,13 +9287,11 @@ dependencies = [ [[package]] name = "pretty_assertions" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a25e9bcb20aa780fd0bb16b72403a9064d6b3f22f026946029acb941a50af755" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" dependencies = [ - "ctor", "diff", - "output_vt100", "yansi", ] diff --git a/integration-tests/src/exchange_asset.rs b/integration-tests/src/exchange_asset.rs new file mode 100644 index 000000000..9e50f3bb3 --- /dev/null +++ b/integration-tests/src/exchange_asset.rs @@ -0,0 +1,569 @@ +#![cfg(test)] + +use crate::polkadot_test_net::*; +use frame_support::dispatch::GetDispatchInfo; +use frame_support::traits::fungible::Balanced; +use frame_support::weights::Weight; +use frame_support::{assert_ok, pallet_prelude::*}; +use orml_traits::currency::MultiCurrency; +use polkadot_xcm::{latest::prelude::*, VersionedXcm}; +use pretty_assertions::assert_eq; +use sp_runtime::traits::{Convert, Zero}; +use sp_runtime::{FixedU128, Permill}; +use xcm_emulator::TestExt; + +pub const SELL: bool = true; +pub const BUY: bool = false; + +pub const ACA: u32 = 1234; +pub const GLMR: u32 = 4567; +pub const IBTC: u32 = 7890; + +//TODO: Unignore these tests when we have the AssetExchange feature configured in hydra. +//For now they are ignored as first we want to try out AssetExchange in basilisk + +#[ignore] +#[test] +fn hydra_should_swap_assets_when_receiving_from_acala_with_sell() { + //Arrange + TestNet::reset(); + + let mut price = None; + Hydra::execute_with(|| { + register_aca(); + + add_currency_price(ACA, FixedU128::from(1)); + + init_omnipool(); + let omnipool_account = hydradx_runtime::Omnipool::protocol_account(); + + let token_price = FixedU128::from_float(1.0); + assert_ok!(hydradx_runtime::Tokens::deposit(ACA, &omnipool_account, 3000 * UNITS)); + + assert_ok!(hydradx_runtime::Omnipool::add_token( + hydradx_runtime::RuntimeOrigin::root(), + ACA, + token_price, + Permill::from_percent(100), + AccountId::from(BOB), + )); + use hydradx_traits::pools::SpotPriceProvider; + price = hydradx_runtime::Omnipool::spot_price(CORE_ASSET_ID, ACA); + }); + + Acala::execute_with(|| { + let xcm = craft_exchange_asset_xcm::<_, hydradx_runtime::RuntimeCall>( + MultiAsset::from((GeneralIndex(0), 50 * UNITS)), + MultiAsset::from((GeneralIndex(CORE_ASSET_ID.into()), 300 * UNITS)), + SELL, + ); + //Act + let res = hydradx_runtime::PolkadotXcm::execute( + hydradx_runtime::RuntimeOrigin::signed(ALICE.into()), + Box::new(xcm), + Weight::from_ref_time(399_600_000_000), + ); + assert_ok!(res); + + //Assert + assert_eq!( + hydradx_runtime::Balances::free_balance(AccountId::from(ALICE)), + ALICE_INITIAL_NATIVE_BALANCE - 100 * UNITS + ); + + assert!(matches!( + last_hydra_events(2).first(), + Some(hydradx_runtime::RuntimeEvent::XcmpQueue( + cumulus_pallet_xcmp_queue::Event::XcmpMessageSent { .. } + )) + )); + }); + + let fees = 500801282051; + Hydra::execute_with(|| { + assert_eq!( + hydradx_runtime::Tokens::free_balance(ACA, &AccountId::from(BOB)), + 50 * UNITS - fees + ); + // We receive about 39_101 HDX (HDX is super cheap in our test) + let received = 39_101 * UNITS + BOB_INITIAL_NATIVE_BALANCE + 207_131_554_396; + assert_eq!(hydradx_runtime::Balances::free_balance(&AccountId::from(BOB)), received); + assert_eq!( + hydradx_runtime::Tokens::free_balance(ACA, &hydradx_runtime::Treasury::account_id()), + fees + ); + }); +} + +#[ignore] +#[test] +fn hydra_should_swap_assets_when_receiving_from_acala_with_buy() { + //Arrange + TestNet::reset(); + + Hydra::execute_with(|| { + register_aca(); + + add_currency_price(ACA, FixedU128::from(1)); + + init_omnipool(); + let omnipool_account = hydradx_runtime::Omnipool::protocol_account(); + + let token_price = FixedU128::from_float(1.0); + assert_ok!(hydradx_runtime::Tokens::deposit(ACA, &omnipool_account, 3000 * UNITS)); + + assert_ok!(hydradx_runtime::Omnipool::add_token( + hydradx_runtime::RuntimeOrigin::root(), + ACA, + token_price, + Permill::from_percent(100), + AccountId::from(BOB), + )); + }); + + Acala::execute_with(|| { + let xcm = craft_exchange_asset_xcm::<_, hydradx_runtime::RuntimeCall>( + MultiAsset::from((GeneralIndex(0), 50 * UNITS)), + MultiAsset::from((GeneralIndex(CORE_ASSET_ID.into()), 300 * UNITS)), + BUY, + ); + //Act + let res = hydradx_runtime::PolkadotXcm::execute( + hydradx_runtime::RuntimeOrigin::signed(ALICE.into()), + Box::new(xcm), + Weight::from_ref_time(399_600_000_000), + ); + assert_ok!(res); + + //Assert + assert_eq!( + hydradx_runtime::Balances::free_balance(AccountId::from(ALICE)), + ALICE_INITIAL_NATIVE_BALANCE - 100 * UNITS + ); + + assert!(matches!( + last_hydra_events(2).first(), + Some(hydradx_runtime::RuntimeEvent::XcmpQueue( + cumulus_pallet_xcmp_queue::Event::XcmpMessageSent { .. } + )) + )); + }); + + let fees = 500801282051; + let swapped = 361693915942; // HDX is super cheap in our setup + Hydra::execute_with(|| { + assert_eq!( + hydradx_runtime::Tokens::free_balance(ACA, &AccountId::from(BOB)), + 100 * UNITS - swapped - fees + ); + assert_eq!( + hydradx_runtime::Balances::free_balance(&AccountId::from(BOB)), + BOB_INITIAL_NATIVE_BALANCE + 300 * UNITS + ); + assert_eq!( + hydradx_runtime::Tokens::free_balance(ACA, &hydradx_runtime::Treasury::account_id()), + fees + ); + }); +} + +//We swap GLMR for iBTC, sent from ACALA and executed on Hydradx, resultin in 4 hops +#[ignore] +#[test] +fn transfer_and_swap_should_work_with_4_hops() { + //Arrange + TestNet::reset(); + + Hydra::execute_with(|| { + register_glmr(); + register_ibtc(); + + add_currency_price(GLMR, FixedU128::from(1)); + + init_omnipool(); + let omnipool_account = hydradx_runtime::Omnipool::protocol_account(); + + let token_price = FixedU128::from_float(1.0); + assert_ok!(hydradx_runtime::Tokens::deposit(GLMR, &omnipool_account, 3000 * UNITS)); + assert_ok!(hydradx_runtime::Tokens::deposit(IBTC, &omnipool_account, 3000 * UNITS)); + + assert_ok!(hydradx_runtime::Omnipool::add_token( + hydradx_runtime::RuntimeOrigin::root(), + GLMR, + token_price, + Permill::from_percent(100), + AccountId::from(BOB), + )); + + assert_ok!(hydradx_runtime::Omnipool::add_token( + hydradx_runtime::RuntimeOrigin::root(), + IBTC, + token_price, + Permill::from_percent(100), + AccountId::from(BOB), + )); + }); + + Moonbeam::execute_with(|| { + use xcm_executor::traits::Convert; + let para_account = + hydradx_runtime::LocationToAccountId::convert((Parent, Parachain(ACALA_PARA_ID)).into()).unwrap(); + let _ = hydradx_runtime::Balances::deposit(¶_account, 1000 * UNITS).expect("Failed to deposit"); + }); + + Interlay::execute_with(|| { + use xcm_executor::traits::Convert; + let para_account = + hydradx_runtime::LocationToAccountId::convert((Parent, Parachain(HYDRA_PARA_ID)).into()).unwrap(); + let _ = hydradx_runtime::Balances::deposit(¶_account, 1000 * UNITS).expect("Failed to deposit"); + }); + + Acala::execute_with(|| { + register_glmr(); + register_ibtc(); + + add_currency_price(IBTC, FixedU128::from(1)); + + let alice_init_moon_balance = 3000 * UNITS; + assert_ok!(hydradx_runtime::Tokens::deposit( + GLMR, + &ALICE.into(), + alice_init_moon_balance + )); + + //Act + let give_amount = 1000 * UNITS; + let give = MultiAsset::from((hydradx_runtime::CurrencyIdConvert::convert(GLMR).unwrap(), give_amount)); + let want = MultiAsset::from((hydradx_runtime::CurrencyIdConvert::convert(IBTC).unwrap(), 550 * UNITS)); + + let xcm = craft_transfer_and_swap_xcm_with_4_hops::(give, want, SELL); + assert_ok!(hydradx_runtime::PolkadotXcm::execute( + hydradx_runtime::RuntimeOrigin::signed(ALICE.into()), + Box::new(xcm), + Weight::from_ref_time(399_600_000_000), + )); + + //Assert + assert_eq!( + hydradx_runtime::Tokens::free_balance(GLMR, &AccountId::from(ALICE)), + alice_init_moon_balance - give_amount + ); + + assert!(matches!( + last_hydra_events(2).first(), + Some(hydradx_runtime::RuntimeEvent::XcmpQueue( + cumulus_pallet_xcmp_queue::Event::XcmpMessageSent { .. } + )) + )); + }); + + let fees = 400641025641; + Acala::execute_with(|| { + assert_eq!( + hydradx_runtime::Currencies::free_balance(IBTC, &AccountId::from(BOB)), + 549198717948718 + ); + assert_eq!( + hydradx_runtime::Tokens::free_balance(IBTC, &hydradx_runtime::Treasury::account_id()), + fees + ); + }); +} + +fn register_glmr() { + assert_ok!(hydradx_runtime::AssetRegistry::register( + hydradx_runtime::RuntimeOrigin::root(), + b"GLRM".to_vec(), + pallet_asset_registry::AssetType::Token, + 1_000_000, + Some(GLMR), + None, + Some(hydradx_runtime::AssetLocation(MultiLocation::new( + 1, + X2(Parachain(MOONBEAM_PARA_ID), GeneralIndex(0)) + ))), + None + )); +} + +fn register_aca() { + assert_ok!(hydradx_runtime::AssetRegistry::register( + hydradx_runtime::RuntimeOrigin::root(), + b"ACAL".to_vec(), + pallet_asset_registry::AssetType::Token, + 1_000_000, + Some(ACA), + None, + Some(hydradx_runtime::AssetLocation(MultiLocation::new( + 1, + X2(Parachain(ACALA_PARA_ID), GeneralIndex(0)) + ))), + None + )); +} + +fn register_ibtc() { + assert_ok!(hydradx_runtime::AssetRegistry::register( + hydradx_runtime::RuntimeOrigin::root(), + b"iBTC".to_vec(), + pallet_asset_registry::AssetType::Token, + 1_000_000, + Some(IBTC), + None, + Some(hydradx_runtime::AssetLocation(MultiLocation::new( + 1, + X2(Parachain(INTERLAY_PARA_ID), GeneralIndex(0)) + ))), + None + )); +} + +fn add_currency_price(asset_id: u32, price: FixedU128) { + assert_ok!(hydradx_runtime::MultiTransactionPayment::add_currency( + hydradx_runtime::RuntimeOrigin::root(), + asset_id, + price, + )); + + // make sure the price is propagated + hydradx_runtime::MultiTransactionPayment::on_initialize(hydradx_runtime::System::block_number()); +} + +/// Returns amount if `asset` is fungible, or zero. +fn fungible_amount(asset: &MultiAsset) -> u128 { + if let Fungible(amount) = &asset.fun { + *amount + } else { + Zero::zero() + } +} + +fn half(asset: &MultiAsset) -> MultiAsset { + let half_amount = fungible_amount(asset) + .checked_div(2) + .expect("div 2 can't overflow; qed"); + MultiAsset { + fun: Fungible(half_amount), + id: asset.id, + } +} + +fn craft_transfer_and_swap_xcm_with_4_hops( + give_asset: MultiAsset, + want_asset: MultiAsset, + is_sell: bool, +) -> VersionedXcm { + use polkadot_runtime::xcm_config::BaseXcmWeight; + use xcm_builder::FixedWeightBounds; + use xcm_executor::traits::WeightBounds; + + type Weigher = FixedWeightBounds>; + + let give_reserve_chain = MultiLocation::new(1, Parachain(MOONBEAM_PARA_ID)); + let want_reserve_chain = MultiLocation::new(1, Parachain(INTERLAY_PARA_ID)); + let swap_chain = MultiLocation::new(1, Parachain(HYDRA_PARA_ID)); + let dest = MultiLocation::new(1, Parachain(ACALA_PARA_ID)); + let beneficiary = Junction::AccountId32 { id: BOB, network: None }.into(); + let assets: MultiAssets = MultiAsset::from((GeneralIndex(0), 100 * UNITS)).into(); // hardcoded + let max_assets = assets.len() as u32 + 1; + let origin_context = X2(GlobalConsensus(NetworkId::Polkadot), Parachain(ACALA_PARA_ID)); + let give = give_asset + .clone() + .reanchored(&dest, origin_context) + .expect("should reanchor give"); + let give: MultiAssetFilter = Definite(give.into()); + let want: MultiAssets = want_asset.clone().into(); + + let fees = give_asset + .clone() + .reanchored(&swap_chain, give_reserve_chain.interior) + .expect("should reanchor"); + + let reserve_fees = want_asset + .clone() + .reanchored(&want_reserve_chain, swap_chain.interior) + .expect("should reanchor"); + + let destination_fee = want_asset + .reanchored(&dest, want_reserve_chain.interior) + .expect("should reanchor"); + + let weight_limit = { + let fees = fees.clone(); + let mut remote_message = Xcm(vec![ + ReserveAssetDeposited::(assets), + ClearOrigin, + BuyExecution { + fees, + weight_limit: Limited(Weight::zero()), + }, + ExchangeAsset { + give: give.clone(), + want: want.clone(), + maximal: is_sell, + }, + InitiateReserveWithdraw { + assets: want.clone().into(), + reserve: want_reserve_chain, + xcm: Xcm(vec![ + BuyExecution { + fees: reserve_fees.clone(), //reserve fee + weight_limit: Limited(Weight::zero()), + }, + DepositReserveAsset { + assets: Wild(AllCounted(max_assets)), + dest, + xcm: Xcm(vec![ + BuyExecution { + fees: destination_fee.clone(), //destination fee + weight_limit: Limited(Weight::zero()), + }, + DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }, + ]), + }, + ]), + }, + ]); + // use local weight for remote message and hope for the best. + let remote_weight = Weigher::weight(&mut remote_message).expect("weighing should not fail"); + Limited(remote_weight) + }; + + // executed on remote (on hydra) + let xcm = Xcm(vec![ + BuyExecution { + fees: half(&fees), + weight_limit: weight_limit.clone(), + }, + ExchangeAsset { + give, + want: want.clone(), + maximal: is_sell, + }, + InitiateReserveWithdraw { + assets: want.into(), + reserve: want_reserve_chain, + xcm: Xcm(vec![ + //Executed on interlay + BuyExecution { + fees: half(&reserve_fees), + weight_limit: weight_limit.clone(), + }, + DepositReserveAsset { + assets: Wild(AllCounted(max_assets)), + dest, + xcm: Xcm(vec![ + //Executed on acala + BuyExecution { + fees: half(&destination_fee), + weight_limit: weight_limit.clone(), + }, + DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }, + ]), + }, + ]), + }, + ]); + + let give_reserve_fees = give_asset + .clone() + .reanchored(&give_reserve_chain, origin_context) + .expect("should reanchor"); + + // executed on local (acala) + let message = Xcm(vec![ + WithdrawAsset(give_asset.into()), + InitiateReserveWithdraw { + assets: All.into(), + reserve: give_reserve_chain, + xcm: Xcm(vec![ + //Executed on moonbeam + BuyExecution { + fees: half(&give_reserve_fees), + weight_limit, + }, + DepositReserveAsset { + assets: AllCounted(max_assets).into(), + dest: swap_chain, + xcm, + }, + ]), + }, + ]); + VersionedXcm::V3(message) +} + +fn craft_exchange_asset_xcm, RC: Decode + GetDispatchInfo>( + give: MultiAsset, + want: M, + is_sell: bool, +) -> VersionedXcm { + use polkadot_runtime::xcm_config::BaseXcmWeight; + use xcm_builder::FixedWeightBounds; + use xcm_executor::traits::WeightBounds; + + type Weigher = FixedWeightBounds>; + + let dest = MultiLocation::new(1, Parachain(HYDRA_PARA_ID)); + let beneficiary = Junction::AccountId32 { id: BOB, network: None }.into(); + let assets: MultiAssets = MultiAsset::from((GeneralIndex(0), 100 * UNITS)).into(); // hardcoded + let max_assets = assets.len() as u32 + 1; + let context = X2(GlobalConsensus(NetworkId::Polkadot), Parachain(ACALA_PARA_ID)); + let fees = assets + .get(0) + .expect("should have at least 1 asset") + .clone() + .reanchored(&dest, context) + .expect("should reanchor"); + let give = give.reanchored(&dest, context).expect("should reanchor give"); + let give: MultiAssetFilter = Definite(give.into()); + let want = want.into(); + let weight_limit = { + let fees = fees.clone(); + let mut remote_message = Xcm(vec![ + ReserveAssetDeposited::(assets.clone()), + ClearOrigin, + BuyExecution { + fees, + weight_limit: Limited(Weight::zero()), + }, + ExchangeAsset { + give: give.clone(), + want: want.clone(), + maximal: is_sell, + }, + DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }, + ]); + // use local weight for remote message and hope for the best. + let remote_weight = Weigher::weight(&mut remote_message).expect("weighing should not fail"); + Limited(remote_weight) + }; + // executed on remote (on hydra) + let xcm = Xcm(vec![ + BuyExecution { fees, weight_limit }, + ExchangeAsset { + give, + want, + maximal: is_sell, + }, + DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }, + ]); + // executed on local (acala) + let message = Xcm(vec![ + SetFeesMode { jit_withdraw: true }, + TransferReserveAsset { assets, dest, xcm }, + ]); + VersionedXcm::V3(message) +} diff --git a/integration-tests/src/lib.rs b/integration-tests/src/lib.rs index 1a7d9a85a..6e1b61bd0 100644 --- a/integration-tests/src/lib.rs +++ b/integration-tests/src/lib.rs @@ -5,6 +5,7 @@ mod dca; mod dust; mod dust_removal_whitelist; mod dynamic_fees; +mod exchange_asset; mod non_native_fee; mod omnipool_init; mod omnipool_liquidity_mining; diff --git a/integration-tests/src/polkadot_test_net.rs b/integration-tests/src/polkadot_test_net.rs index 405bab0b2..777753765 100644 --- a/integration-tests/src/polkadot_test_net.rs +++ b/integration-tests/src/polkadot_test_net.rs @@ -29,6 +29,8 @@ pub const UNITS: Balance = 1_000_000_000_000; pub const ACALA_PARA_ID: u32 = 2_000; pub const HYDRA_PARA_ID: u32 = 2_034; +pub const MOONBEAM_PARA_ID: u32 = 2_004; +pub const INTERLAY_PARA_ID: u32 = 2_032; pub const ALICE_INITIAL_NATIVE_BALANCE: Balance = 1_000 * UNITS; pub const ALICE_INITIAL_DAI_BALANCE: Balance = 200 * UNITS; @@ -73,7 +75,27 @@ decl_test_parachain! { RuntimeOrigin = hydradx_runtime::RuntimeOrigin, XcmpMessageHandler = hydradx_runtime::XcmpQueue, DmpMessageHandler = hydradx_runtime::DmpQueue, - new_ext = acala_ext(), + new_ext = para_ext(ACALA_PARA_ID), + } +} + +decl_test_parachain! { + pub struct Moonbeam{ + Runtime = hydradx_runtime::Runtime, + RuntimeOrigin = hydradx_runtime::RuntimeOrigin, + XcmpMessageHandler = hydradx_runtime::XcmpQueue, + DmpMessageHandler = hydradx_runtime::DmpQueue, + new_ext = para_ext(MOONBEAM_PARA_ID), + } +} + +decl_test_parachain! { + pub struct Interlay { + Runtime = hydradx_runtime::Runtime, + RuntimeOrigin = hydradx_runtime::RuntimeOrigin, + XcmpMessageHandler = hydradx_runtime::XcmpQueue, + DmpMessageHandler = hydradx_runtime::DmpQueue, + new_ext = para_ext(INTERLAY_PARA_ID), } } @@ -82,6 +104,8 @@ decl_test_network! { relay_chain = PolkadotRelay, parachains = vec![ (2000, Acala), + (2004, Moonbeam), + (2032, Interlay), (2034, Hydra), ], } @@ -276,7 +300,7 @@ pub fn hydra_ext() -> sp_io::TestExternalities { ext } -pub fn acala_ext() -> sp_io::TestExternalities { +pub fn para_ext(para_id: u32) -> sp_io::TestExternalities { use hydradx_runtime::{Runtime, System}; let mut t = frame_system::GenesisConfig::default() @@ -291,7 +315,7 @@ pub fn acala_ext() -> sp_io::TestExternalities { >::assimilate_storage( ¶chain_info::GenesisConfig { - parachain_id: ACALA_PARA_ID.into(), + parachain_id: para_id.into(), }, &mut t, ) diff --git a/runtime/adapters/Cargo.toml b/runtime/adapters/Cargo.toml index 6c0e162d0..1c48b38da 100644 --- a/runtime/adapters/Cargo.toml +++ b/runtime/adapters/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hydradx-adapters" -version = "0.4.1" +version = "0.4.2" description = "Structs and other generic types for building runtimes." authors = ["GalacticCouncil"] edition = "2021" @@ -10,6 +10,7 @@ repository = "https://github.com/galacticcouncil/warehouse/tree/master/adapters" [dependencies] codec = { default-features = false, features = ["derive"], package = "parity-scale-codec", version = "3.4.0" } log = { version = "0.4.17", default-features = false } +scale-info = { version = "2.3.1", default-features = false, features = ["derive"] } # HydraDX dependencies primitives = { workspace = true } @@ -22,6 +23,8 @@ pallet-circuit-breaker = { workspace = true } warehouse-liquidity-mining = { workspace = true } pallet-omnipool-liquidity-mining = { workspace = true } pallet-dynamic-fees = { workspace = true } +pallet-route-executor = { workspace = true } +pallet-currencies = { workspace = true } # Substrate dependencies frame-support = { workspace = true } @@ -29,6 +32,8 @@ frame-system = { workspace = true } sp-runtime = { workspace = true } sp-std = { workspace = true } primitive-types = { workspace = true } +sp-core = { workspace = true } +sp-io = { workspace = true } # Polkadot dependencies polkadot-parachain = { workspace = true } @@ -43,9 +48,15 @@ cumulus-primitives-core = { workspace = true } # ORML dependencies orml-xcm-support = { workspace = true } orml-traits = { workspace = true } +orml-utilities = { workspace = true } +orml-tokens = { workspace = true } + +# Pallets +pallet-balances = { workspace = true } [dev-dependencies] lazy_static = { features = ["spin_no_std"], version = "1.4.0" } +pretty_assertions = "1.4.0" [features] default = ["std"] diff --git a/runtime/adapters/src/lib.rs b/runtime/adapters/src/lib.rs index c3c00de20..cfe650eda 100644 --- a/runtime/adapters/src/lib.rs +++ b/runtime/adapters/src/lib.rs @@ -54,6 +54,8 @@ use xcm_executor::{ }; pub mod inspect; +pub mod xcm_exchange; +pub mod xcm_execute_filter; #[cfg(test)] mod tests; diff --git a/runtime/adapters/src/tests/mock.rs b/runtime/adapters/src/tests/mock.rs new file mode 100644 index 000000000..00f9d5d77 --- /dev/null +++ b/runtime/adapters/src/tests/mock.rs @@ -0,0 +1,659 @@ +// This file is part of HydraDX. + +// Copyright (C) 2020-2022 Intergalactic, Limited (GIB). +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Test environment for Assets pallet. + +use primitives::Amount; + +use crate::inspect::MultiInspectAdapter; +use frame_support::dispatch::Weight; +use frame_support::traits::{ConstU128, Everything, GenesisBuild}; +use frame_support::{ + assert_ok, construct_runtime, parameter_types, + traits::{ConstU32, ConstU64}, +}; +use frame_system::EnsureRoot; +use hydradx_traits::Registry; +use orml_traits::{parameter_type_with_key, GetByKey}; +use pallet_currencies::BasicCurrencyAdapter; +use pallet_omnipool; +use pallet_omnipool::traits::ExternalPriceProvider; +use primitive_types::{U128, U256}; +use sp_core::H256; +use sp_runtime::traits::Zero; +use sp_runtime::Permill; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + DispatchError, DispatchResult, FixedU128, +}; +use std::cell::RefCell; +use std::collections::HashMap; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +pub type AccountId = u64; +pub type Balance = u128; +pub type AssetId = u32; + +pub const HDX: AssetId = 0; +pub const LRNA: AssetId = 1; +pub const DAI: AssetId = 2; + +pub const REGISTERED_ASSET: AssetId = 1000; + +pub const ONE: Balance = 1_000_000_000_000; + +pub const NATIVE_AMOUNT: Balance = 10_000 * ONE; + +thread_local! { + pub static POSITIONS: RefCell> = RefCell::new(HashMap::default()); + pub static REGISTERED_ASSETS: RefCell> = RefCell::new(HashMap::default()); + pub static ASSET_WEIGHT_CAP: RefCell = RefCell::new(Permill::from_percent(100)); + pub static ASSET_FEE: RefCell = RefCell::new(Permill::from_percent(0)); + pub static PROTOCOL_FEE: RefCell = RefCell::new(Permill::from_percent(0)); + pub static MIN_ADDED_LIQUDIITY: RefCell = RefCell::new(1000u128); + pub static MIN_TRADE_AMOUNT: RefCell = RefCell::new(1000u128); + pub static MAX_IN_RATIO: RefCell = RefCell::new(1u128); + pub static MAX_OUT_RATIO: RefCell = RefCell::new(1u128); + pub static MAX_PRICE_DIFF: RefCell = RefCell::new(Permill::from_percent(0)); + pub static EXT_PRICE_ADJUSTMENT: RefCell<(u32,u32, bool)> = RefCell::new((0u32,0u32, false)); + pub static WITHDRAWAL_FEE: RefCell = RefCell::new(Permill::from_percent(0)); + pub static WITHDRAWAL_ADJUSTMENT: RefCell<(u32,u32, bool)> = RefCell::new((0u32,0u32, false)); +} + +construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system, + Balances: pallet_balances, + Omnipool: pallet_omnipool, + Tokens: orml_tokens, + RouteExecutor: pallet_route_executor, + Currencies: pallet_currencies, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = ConstU64<250>; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +impl pallet_balances::Config for Test { + type Balance = Balance; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = ConstU128<1>; + type AccountStore = System; + type WeightInfo = (); + type MaxLocks = (); + type MaxReserves = ConstU32<50>; + type ReserveIdentifier = (); +} + +parameter_type_with_key! { + pub ExistentialDeposits: |_currency_id: AssetId| -> Balance { + 0 + }; +} + +impl orml_tokens::Config for Test { + type RuntimeEvent = RuntimeEvent; + type Balance = Balance; + type Amount = i128; + type CurrencyId = AssetId; + type WeightInfo = (); + type ExistentialDeposits = ExistentialDeposits; + type MaxLocks = (); + type DustRemovalWhitelist = Everything; + type MaxReserves = (); + type ReserveIdentifier = (); + type CurrencyHooks = (); +} + +parameter_types! { + pub const HDXAssetId: AssetId = HDX; + pub const LRNAAssetId: AssetId = LRNA; + pub const DAIAssetId: AssetId = DAI; + pub const PosiitionCollectionId: u32= 1000; + + pub const MaxNumberOfTrades: u8 = 5; + pub ProtocolFee: Permill = PROTOCOL_FEE.with(|v| *v.borrow()); + pub AssetFee: Permill = ASSET_FEE.with(|v| *v.borrow()); + pub AssetWeightCap: Permill =ASSET_WEIGHT_CAP.with(|v| *v.borrow()); + pub MinAddedLiquidity: Balance = MIN_ADDED_LIQUDIITY.with(|v| *v.borrow()); + pub MinTradeAmount: Balance = MIN_TRADE_AMOUNT.with(|v| *v.borrow()); + pub MaxInRatio: Balance = MAX_IN_RATIO.with(|v| *v.borrow()); + pub MaxOutRatio: Balance = MAX_OUT_RATIO.with(|v| *v.borrow()); + pub const TVLCap: Balance = Balance::MAX; + pub MaxPriceDiff: Permill = MAX_PRICE_DIFF.with(|v| *v.borrow()); + pub FourPercentDiff: Permill = Permill::from_percent(4); + pub MinWithdrawFee: Permill = WITHDRAWAL_FEE.with(|v| *v.borrow()); +} + +impl pallet_omnipool::Config for Test { + type RuntimeEvent = RuntimeEvent; + type AssetId = AssetId; + type PositionItemId = u32; + type Currency = Currencies; + type AuthorityOrigin = EnsureRoot; + type HubAssetId = LRNAAssetId; + type Fee = FeeProvider; + type StableCoinAssetId = DAIAssetId; + type WeightInfo = (); + type HdxAssetId = HDXAssetId; + type NFTCollectionId = PosiitionCollectionId; + type NFTHandler = DummyNFT; + type AssetRegistry = DummyRegistry; + type MinimumTradingLimit = MinTradeAmount; + type MinimumPoolLiquidity = MinAddedLiquidity; + type TechnicalOrigin = EnsureRoot; + type MaxInRatio = MaxInRatio; + type MaxOutRatio = MaxOutRatio; + type CollectionId = u32; + type OmnipoolHooks = (); + type PriceBarrier = ( + EnsurePriceWithin, + EnsurePriceWithin, + ); + type MinWithdrawalFee = MinWithdrawFee; + type ExternalPriceOracle = WithdrawFeePriceOracle; +} + +pub struct FeeProvider; + +impl GetByKey for FeeProvider { + fn get(_: &AssetId) -> (Permill, Permill) { + (ASSET_FEE.with(|v| *v.borrow()), PROTOCOL_FEE.with(|v| *v.borrow())) + } +} + +impl pallet_currencies::Config for Test { + type RuntimeEvent = RuntimeEvent; + type MultiCurrency = Tokens; + type NativeCurrency = BasicCurrencyAdapter; + type GetNativeCurrencyId = NativeCurrencyId; + type WeightInfo = (); +} + +pub const ASSET_PAIR_ACCOUNT: AccountId = 12; +//pub const ASSET_PAIR_ACCOUNT: [u8; 32] = [4u8; 32]; + +type OriginForRuntime = OriginFor; + +pub struct OmniPoolForRouter; + +impl TradeExecution for OmniPoolForRouter { + type Error = DispatchError; + + fn calculate_sell( + pool_type: PoolType, + asset_in: AssetId, + asset_out: AssetId, + amount_in: Balance, + ) -> Result> { + Omnipool::calculate_sell(pool_type, asset_in, asset_out, amount_in) + } + + fn calculate_buy( + pool_type: PoolType, + asset_in: AssetId, + asset_out: AssetId, + amount_out: Balance, + ) -> Result> { + Omnipool::calculate_buy(pool_type, asset_in, asset_out, amount_out) + } + + fn execute_sell( + who: OriginForRuntime, + pool_type: PoolType, + asset_in: AssetId, + asset_out: AssetId, + amount_in: Balance, + min_limit: Balance, + ) -> Result<(), ExecutorError> { + Omnipool::execute_sell(who, pool_type, asset_in, asset_out, amount_in, min_limit) + } + + fn execute_buy( + who: OriginForRuntime, + pool_type: PoolType, + asset_in: AssetId, + asset_out: AssetId, + amount_out: Balance, + max_limit: Balance, + ) -> Result<(), ExecutorError> { + Omnipool::execute_buy(who, pool_type, asset_in, asset_out, amount_out, max_limit) + } +} + +parameter_types! { + pub NativeCurrencyId: AssetId = HDX; +} + +type Pools = OmniPoolForRouter; + +impl pallet_route_executor::Config for Test { + type RuntimeEvent = RuntimeEvent; + type AssetId = AssetId; + type Balance = Balance; + type MaxNumberOfTrades = MaxNumberOfTrades; + type Currency = MultiInspectAdapter; + type AMM = Pools; + type WeightInfo = (); +} + +pub struct ExtBuilder { + endowed_accounts: Vec<(u64, AssetId, Balance)>, + registered_assets: Vec, + asset_fee: Permill, + protocol_fee: Permill, + asset_weight_cap: Permill, + min_liquidity: u128, + min_trade_limit: u128, + register_stable_asset: bool, + max_in_ratio: Balance, + max_out_ratio: Balance, + tvl_cap: Balance, + init_pool: Option<(FixedU128, FixedU128)>, + pool_tokens: Vec<(AssetId, FixedU128, AccountId, Balance)>, +} + +impl Default for ExtBuilder { + fn default() -> Self { + // If eg. tests running on one thread only, this thread local is shared. + // let's make sure that it is empty for each test case + // or set to original default value + REGISTERED_ASSETS.with(|v| { + v.borrow_mut().clear(); + }); + POSITIONS.with(|v| { + v.borrow_mut().clear(); + }); + ASSET_WEIGHT_CAP.with(|v| { + *v.borrow_mut() = Permill::from_percent(100); + }); + ASSET_FEE.with(|v| { + *v.borrow_mut() = Permill::from_percent(0); + }); + PROTOCOL_FEE.with(|v| { + *v.borrow_mut() = Permill::from_percent(0); + }); + MIN_ADDED_LIQUDIITY.with(|v| { + *v.borrow_mut() = 1000u128; + }); + MIN_TRADE_AMOUNT.with(|v| { + *v.borrow_mut() = 1000u128; + }); + MAX_IN_RATIO.with(|v| { + *v.borrow_mut() = 1u128; + }); + MAX_OUT_RATIO.with(|v| { + *v.borrow_mut() = 1u128; + }); + MAX_PRICE_DIFF.with(|v| { + *v.borrow_mut() = Permill::from_percent(0); + }); + EXT_PRICE_ADJUSTMENT.with(|v| { + *v.borrow_mut() = (0, 0, false); + }); + WITHDRAWAL_FEE.with(|v| { + *v.borrow_mut() = Permill::from_percent(0); + }); + WITHDRAWAL_ADJUSTMENT.with(|v| { + *v.borrow_mut() = (0, 0, false); + }); + + Self { + endowed_accounts: vec![ + (Omnipool::protocol_account(), DAI, 1000 * ONE), + (Omnipool::protocol_account(), HDX, NATIVE_AMOUNT), + ], + asset_fee: Permill::from_percent(0), + protocol_fee: Permill::from_percent(0), + asset_weight_cap: Permill::from_percent(100), + min_liquidity: 0, + registered_assets: vec![], + min_trade_limit: 0, + init_pool: None, + register_stable_asset: true, + pool_tokens: vec![], + max_in_ratio: 1u128, + max_out_ratio: 1u128, + tvl_cap: u128::MAX, + } + } +} + +impl ExtBuilder { + pub fn with_endowed_accounts(mut self, accounts: Vec<(u64, AssetId, Balance)>) -> Self { + self.endowed_accounts = accounts; + self + } + pub fn with_initial_pool(mut self, stable_price: FixedU128, native_price: FixedU128) -> Self { + self.init_pool = Some((stable_price, native_price)); + self + } + + pub fn build(self) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::default().build_storage::().unwrap(); + + // Add DAi and HDX as pre-registered assets + REGISTERED_ASSETS.with(|v| { + if self.register_stable_asset { + v.borrow_mut().insert(DAI, DAI); + } + v.borrow_mut().insert(HDX, HDX); + v.borrow_mut().insert(REGISTERED_ASSET, REGISTERED_ASSET); + self.registered_assets.iter().for_each(|asset| { + v.borrow_mut().insert(*asset, *asset); + }); + }); + + ASSET_FEE.with(|v| { + *v.borrow_mut() = self.asset_fee; + }); + ASSET_WEIGHT_CAP.with(|v| { + *v.borrow_mut() = self.asset_weight_cap; + }); + + PROTOCOL_FEE.with(|v| { + *v.borrow_mut() = self.protocol_fee; + }); + + MIN_ADDED_LIQUDIITY.with(|v| { + *v.borrow_mut() = self.min_liquidity; + }); + + MIN_TRADE_AMOUNT.with(|v| { + *v.borrow_mut() = self.min_trade_limit; + }); + MAX_IN_RATIO.with(|v| { + *v.borrow_mut() = self.max_in_ratio; + }); + MAX_OUT_RATIO.with(|v| { + *v.borrow_mut() = self.max_out_ratio; + }); + + let mut initial_native_accounts: Vec<(AccountId, Balance)> = vec![(ASSET_PAIR_ACCOUNT, 10000 * ONE)]; + let additional_accounts: Vec<(AccountId, Balance)> = self + .endowed_accounts + .iter() + .filter(|a| a.1 == HDX) + .flat_map(|(x, _, amount)| vec![(*x, *amount)]) + .collect::<_>(); + + initial_native_accounts.extend(additional_accounts); + + pallet_balances::GenesisConfig:: { + balances: initial_native_accounts, + } + .assimilate_storage(&mut t) + .unwrap(); + + let mut initial_accounts = vec![ + (ASSET_PAIR_ACCOUNT, LRNA, 10000 * ONE), + (ASSET_PAIR_ACCOUNT, DAI, 10000 * ONE), + ]; + + initial_accounts.extend(self.endowed_accounts); + + orml_tokens::GenesisConfig:: { + balances: initial_accounts, + } + .assimilate_storage(&mut t) + .unwrap(); + + let mut r: sp_io::TestExternalities = t.into(); + + r.execute_with(|| { + assert_ok!(Omnipool::set_tvl_cap(RuntimeOrigin::root(), self.tvl_cap,)); + }); + + if let Some((stable_price, native_price)) = self.init_pool { + r.execute_with(|| { + assert_ok!(Omnipool::initialize_pool( + RuntimeOrigin::root(), + stable_price, + native_price, + Permill::from_percent(100), + Permill::from_percent(100) + )); + + for (asset_id, price, owner, amount) in self.pool_tokens { + assert_ok!(Tokens::transfer( + RuntimeOrigin::signed(owner), + Omnipool::protocol_account(), + asset_id, + amount + )); + assert_ok!(Omnipool::add_token( + RuntimeOrigin::root(), + asset_id, + price, + self.asset_weight_cap, + owner + )); + } + }); + } + + r + } +} + +use frame_support::traits::tokens::nonfungibles::{Create, Inspect, Mutate}; +use frame_system::pallet_prelude::OriginFor; +use hydra_dx_math::ema::EmaPrice; +use hydra_dx_math::support::rational::Rounding; +use hydra_dx_math::to_u128_wrapper; +use hydradx_traits::router::{ExecutorError, PoolType, TradeExecution}; +use pallet_omnipool::traits::EnsurePriceWithin; + +pub struct DummyNFT; + +impl> Inspect for DummyNFT { + type ItemId = u32; + type CollectionId = u32; + + fn owner(_class: &Self::CollectionId, instance: &Self::ItemId) -> Option { + let mut owner: Option = None; + + POSITIONS.with(|v| { + if let Some(o) = v.borrow().get(instance) { + owner = Some((*o).into()); + } + }); + owner + } +} + +impl> Create for DummyNFT { + fn create_collection(_class: &Self::CollectionId, _who: &AccountId, _admin: &AccountId) -> DispatchResult { + Ok(()) + } +} + +impl + Into + Copy> Mutate for DummyNFT { + fn mint_into(_class: &Self::CollectionId, _instance: &Self::ItemId, _who: &AccountId) -> DispatchResult { + POSITIONS.with(|v| { + let mut m = v.borrow_mut(); + m.insert(*_instance, (*_who).into()); + }); + Ok(()) + } + + fn burn( + _class: &Self::CollectionId, + instance: &Self::ItemId, + _maybe_check_owner: Option<&AccountId>, + ) -> DispatchResult { + POSITIONS.with(|v| { + let mut m = v.borrow_mut(); + m.remove(instance); + }); + Ok(()) + } +} + +pub struct DummyRegistry(sp_std::marker::PhantomData); + +impl Registry, Balance, DispatchError> for DummyRegistry +where + T::AssetId: Into + From, +{ + fn exists(asset_id: T::AssetId) -> bool { + let asset = REGISTERED_ASSETS.with(|v| v.borrow().get(&(asset_id.into())).copied()); + matches!(asset, Some(_)) + } + + fn retrieve_asset(_name: &Vec) -> Result { + Ok(T::AssetId::default()) + } + + fn create_asset(_name: &Vec, _existential_deposit: Balance) -> Result { + let assigned = REGISTERED_ASSETS.with(|v| { + let l = v.borrow().len(); + v.borrow_mut().insert(l as u32, l as u32); + l as u32 + }); + Ok(T::AssetId::from(assigned)) + } +} + +pub struct MockOracle; + +impl ExternalPriceProvider for MockOracle { + type Error = DispatchError; + + fn get_price(asset_a: AssetId, asset_b: AssetId) -> Result { + assert_eq!(asset_a, LRNA); + let asset_state = Omnipool::load_asset_state(asset_b)?; + let price = EmaPrice::new(asset_state.hub_reserve, asset_state.reserve); + let adjusted_price = EXT_PRICE_ADJUSTMENT.with(|v| { + let (n, d, neg) = *v.borrow(); + let adjustment = EmaPrice::new(price.n * n as u128, price.d * d as u128); + if neg { + saturating_sub(price, adjustment) + } else { + saturating_add(price, adjustment) + } + }); + + Ok(adjusted_price) + } + + fn get_price_weight() -> Weight { + todo!() + } +} + +pub struct WithdrawFeePriceOracle; + +impl ExternalPriceProvider for WithdrawFeePriceOracle { + type Error = DispatchError; + + fn get_price(asset_a: AssetId, asset_b: AssetId) -> Result { + assert_eq!(asset_a, LRNA); + let asset_state = Omnipool::load_asset_state(asset_b)?; + let price = EmaPrice::new(asset_state.hub_reserve, asset_state.reserve); + + let adjusted_price = WITHDRAWAL_ADJUSTMENT.with(|v| { + let (n, d, neg) = *v.borrow(); + let adjustment = EmaPrice::new(price.n * n as u128, price.d * d as u128); + if neg { + saturating_sub(price, adjustment) + } else { + saturating_add(price, adjustment) + } + }); + + Ok(adjusted_price) + } + + fn get_price_weight() -> Weight { + todo!() + } +} + +// Helper methods to work with Ema Price +pub(super) fn round_to_rational((n, d): (U256, U256), rounding: Rounding) -> EmaPrice { + let shift = n.bits().max(d.bits()).saturating_sub(128); + let (n, d) = if shift > 0 { + let min_n = u128::from(!n.is_zero()); + let (bias_n, bias_d) = rounding.to_bias(1); + let shifted_n = (n >> shift).low_u128(); + let shifted_d = (d >> shift).low_u128(); + ( + shifted_n.saturating_add(bias_n).max(min_n), + shifted_d.saturating_add(bias_d).max(1), + ) + } else { + (n.low_u128(), d.low_u128()) + }; + EmaPrice::new(n, d) +} + +pub(super) fn saturating_add(l: EmaPrice, r: EmaPrice) -> EmaPrice { + if l.n.is_zero() || r.n.is_zero() { + return EmaPrice::new(l.n, l.d); + } + let (l_n, l_d, r_n, r_d) = to_u128_wrapper!(l.n, l.d, r.n, r.d); + // n = l.n * r.d - r.n * l.d + let n = l_n.full_mul(r_d).saturating_add(r_n.full_mul(l_d)); + // d = l.d * r.d + let d = l_d.full_mul(r_d); + round_to_rational((n, d), Rounding::Nearest) +} + +pub(super) fn saturating_sub(l: EmaPrice, r: EmaPrice) -> EmaPrice { + if l.n.is_zero() || r.n.is_zero() { + return EmaPrice::new(l.n, l.d); + } + let (l_n, l_d, r_n, r_d) = to_u128_wrapper!(l.n, l.d, r.n, r.d); + // n = l.n * r.d - r.n * l.d + let n = l_n.full_mul(r_d).saturating_sub(r_n.full_mul(l_d)); + // d = l.d * r.d + let d = l_d.full_mul(r_d); + round_to_rational((n, d), Rounding::Nearest) +} diff --git a/runtime/adapters/src/tests/mod.rs b/runtime/adapters/src/tests/mod.rs new file mode 100644 index 000000000..504f68420 --- /dev/null +++ b/runtime/adapters/src/tests/mod.rs @@ -0,0 +1,4 @@ +pub mod mock; +pub mod trader; +pub mod xcm_exchange; +pub mod xcm_execute_filter; diff --git a/runtime/adapters/src/tests.rs b/runtime/adapters/src/tests/trader.rs similarity index 99% rename from runtime/adapters/src/tests.rs rename to runtime/adapters/src/tests/trader.rs index 885c6ea94..d5fe83e2c 100644 --- a/runtime/adapters/src/tests.rs +++ b/runtime/adapters/src/tests/trader.rs @@ -15,7 +15,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::*; +use crate::*; use codec::{Decode, Encode}; use frame_support::{weights::IdentityFee, BoundedVec}; use sp_runtime::{traits::One, DispatchResult, FixedU128}; diff --git a/runtime/adapters/src/tests/xcm_exchange.rs b/runtime/adapters/src/tests/xcm_exchange.rs new file mode 100644 index 000000000..0a5b75a3e --- /dev/null +++ b/runtime/adapters/src/tests/xcm_exchange.rs @@ -0,0 +1,169 @@ +use crate::tests::mock::AccountId; +use crate::tests::mock::AssetId as CurrencyId; +use crate::tests::mock::*; +use crate::tests::mock::{DAI, HDX, NATIVE_AMOUNT}; +use crate::xcm_exchange::XcmAssetExchanger; +use frame_support::{assert_noop, assert_ok, parameter_types}; +use hydradx_traits::router::PoolType; +use orml_traits::MultiCurrency; +use polkadot_xcm::latest::prelude::*; +use pretty_assertions::assert_eq; +use sp_runtime::traits::Convert; +use sp_runtime::{FixedU128, SaturatedConversion}; +use xcm_executor::traits::AssetExchange; +use xcm_executor::Assets; + +parameter_types! { + pub ExchangeTempAccount: AccountId = 12345; + pub DefaultPoolType: PoolType = PoolType::Omnipool; +} + +const BUY: bool = false; +const SELL: bool = true; +const UNITS: u128 = 1_000_000_000_000; + +pub struct CurrencyIdConvert; + +impl Convert> for CurrencyIdConvert { + fn convert(location: MultiLocation) -> Option { + match location { + MultiLocation { + parents: 0, + interior: X1(GeneralIndex(index)), + } => Some(index.saturated_into()), + _ => None, + } + } +} + +impl Convert> for CurrencyIdConvert { + fn convert(asset: MultiAsset) -> Option { + if let MultiAsset { + id: Concrete(location), .. + } = asset + { + Self::convert(location) + } else { + None + } + } +} + +#[test] +fn omni_exchanger_allows_selling_supported_assets() { + // Arrange + ExtBuilder::default() + .with_endowed_accounts(vec![ + (Omnipool::protocol_account(), DAI, 1000 * ONE), + (Omnipool::protocol_account(), HDX, NATIVE_AMOUNT), + ]) + .with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1)) + .build() + .execute_with(|| { + let give = MultiAsset::from((GeneralIndex(DAI.into()), 100 * UNITS)).into(); + let wanted_amount = 45 * UNITS; // 50 - 5 to cover fees + let want: MultiAssets = MultiAsset::from((GeneralIndex(HDX.into()), wanted_amount)).into(); + + // Act + let received = exchange_asset(None, give, &want, SELL).expect("should return ok"); + + // Assert + let mut iter = received.fungible_assets_iter(); + let asset_received = iter.next().expect("there should be at least one asset"); + assert!(iter.next().is_none(), "there should only be one asset returned"); + let Fungible(received_amount) = asset_received.fun else { panic!("should be fungible")}; + assert!(received_amount >= wanted_amount); + assert_eq!(Tokens::free_balance(DAI, &ExchangeTempAccount::get()), 0); + assert_eq!(Balances::free_balance(&ExchangeTempAccount::get()), 0); + }); +} + +#[test] +fn omni_exchanger_allows_buying_supported_assets() { + // Arrange + ExtBuilder::default() + .with_endowed_accounts(vec![ + (Omnipool::protocol_account(), DAI, 1000 * ONE), + (Omnipool::protocol_account(), HDX, NATIVE_AMOUNT), + ]) + .with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1)) + .build() + .execute_with(|| { + let given_amount = 100 * UNITS; + let give_asset = MultiAsset::from((GeneralIndex(DAI.into()), given_amount)); + let give = give_asset.into(); + let wanted_amount = 45 * UNITS; // 50 - 5 to cover fees + let want_asset = MultiAsset::from((GeneralIndex(HDX.into()), wanted_amount)); + let want: MultiAssets = want_asset.clone().into(); + + // Act + let received = exchange_asset(None, give, &want, BUY).expect("should return ok"); + + // Assert + let mut iter = received.fungible_assets_iter(); + let asset_received = iter.next().expect("there should be at least one asset"); + let left_over = iter.next().expect("there should be at least some left_over asset_in"); + assert!(iter.next().is_none(), "there should only be two assets returned"); + let Fungible(left_over_amount) = left_over.fun else { panic!("should be fungible")}; + assert_eq!(left_over, (GeneralIndex(DAI.into()), left_over_amount).into()); + assert!(left_over_amount < given_amount); + assert_eq!(asset_received, want_asset); + let Fungible(received_amount) = asset_received.fun else { panic!("should be fungible")}; + assert!(received_amount == wanted_amount); + assert_eq!(Tokens::free_balance(DAI, &ExchangeTempAccount::get()), 0); + assert_eq!(Balances::free_balance(&ExchangeTempAccount::get()), 0); + }); +} + +#[test] +fn omni_exchanger_should_not_allow_trading_for_multiple_assets() { + // Arrange + ExtBuilder::default() + .with_endowed_accounts(vec![ + (Omnipool::protocol_account(), DAI, 1000 * ONE), + (Omnipool::protocol_account(), HDX, NATIVE_AMOUNT), + ]) + .with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1)) + .build() + .execute_with(|| { + let give: MultiAssets = MultiAsset::from((GeneralIndex(DAI.into()), 100 * UNITS)).into(); + let wanted_amount = 45 * UNITS; // 50 - 5 to cover fees + let want1: MultiAsset = MultiAsset::from((GeneralIndex(HDX.into()), wanted_amount)); + let want2: MultiAsset = MultiAsset::from((GeneralIndex(DAI.into()), wanted_amount)); + let want: MultiAssets = vec![want1, want2].into(); + + // Act and assert + assert_noop!(exchange_asset(None, give.clone().into(), &want, SELL), give); + }); +} + +#[test] +fn omni_exchanger_works_with_specified_origin() { + // Arrange + ExtBuilder::default() + .with_endowed_accounts(vec![ + (Omnipool::protocol_account(), DAI, 1000 * ONE), + (Omnipool::protocol_account(), HDX, NATIVE_AMOUNT), + ]) + .with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1)) + .build() + .execute_with(|| { + let give = MultiAsset::from((GeneralIndex(DAI.into()), 100 * UNITS)).into(); + let wanted_amount = 45 * UNITS; // 50 - 5 to cover fees + let want = MultiAsset::from((GeneralIndex(HDX.into()), wanted_amount)).into(); + + // Act and assert + assert_ok!(exchange_asset(Some(&MultiLocation::here()), give, &want, SELL)); + }); +} + +fn exchange_asset( + origin: Option<&MultiLocation>, + give: Assets, + want: &MultiAssets, + is_sell: bool, +) -> Result { + XcmAssetExchanger::::exchange_asset( + origin, give, want, is_sell, + ) +} diff --git a/runtime/adapters/src/tests/xcm_execute_filter.rs b/runtime/adapters/src/tests/xcm_execute_filter.rs new file mode 100644 index 000000000..4f3b568ee --- /dev/null +++ b/runtime/adapters/src/tests/xcm_execute_filter.rs @@ -0,0 +1,277 @@ +use crate::tests::mock::*; +use crate::xcm_execute_filter::AllowTransferAndSwap; +use codec::Encode; +use frame_support::pallet_prelude::Weight; +use frame_support::traits::Contains; +use polkadot_xcm::prelude::*; +use sp_runtime::traits::ConstU16; + +//TODO: consider what others needs to be filtered out then add them to this test +#[test] +fn xcm_execute_filter_should_not_allow_transact() { + let call = RuntimeCall::System(frame_system::Call::remark { remark: Vec::new() }).encode(); + let xcm = Xcm(vec![Transact { + origin_kind: OriginKind::Native, + require_weight_at_most: Weight::from_parts(1, 1), + call: call.into(), + }]); + let loc = MultiLocation::new( + 0, + AccountId32 { + network: None, + id: [1; 32], + }, + ); + assert!(xcm_execute_filter_does_not_allow(&(loc, xcm))); +} + +#[test] +fn xcm_execute_filter_should_allow_a_transfer_and_swap() { + //Arrange + let fees = MultiAsset::from((MultiLocation::here(), 10)); + let weight_limit = WeightLimit::Unlimited; + let give: MultiAssetFilter = fees.clone().into(); + let want: MultiAssets = fees.clone().into(); + let assets: MultiAssets = fees.clone().into(); + + let max_assets = 2; + let beneficiary = Junction::AccountId32 { + id: [3; 32], + network: None, + } + .into(); + let dest = MultiLocation::new(1, Parachain(2047)); + + let xcm = Xcm(vec![ + BuyExecution { fees, weight_limit }, + ExchangeAsset { + give, + want, + maximal: true, + }, + DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }, + ]); + + let message = Xcm(vec![ + SetFeesMode { jit_withdraw: true }, + TransferReserveAsset { assets, dest, xcm }, + ]); + + let loc = MultiLocation::new( + 0, + AccountId32 { + network: None, + id: [1; 32], + }, + ); + + //Act and assert + assert!(xcm_execute_filter_allows(&(loc, message))); +} + +#[test] +fn xcm_execute_filter_should_filter_too_deep_xcm() { + //Arrange + let fees = MultiAsset::from((MultiLocation::here(), 10)); + let assets: MultiAssets = fees.into(); + + let max_assets = 2; + let beneficiary = Junction::AccountId32 { + id: [3; 32], + network: None, + } + .into(); + let dest = MultiLocation::new(1, Parachain(2047)); + + let deposit = Xcm(vec![DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }]); + + let mut message = Xcm(vec![TransferReserveAsset { + assets: assets.clone(), + dest, + xcm: deposit, + }]); + + for _ in 0..5 { + let xcm = message.clone(); + message = Xcm(vec![TransferReserveAsset { + assets: assets.clone(), + dest, + xcm, + }]); + } + + let loc = MultiLocation::new( + 0, + AccountId32 { + network: None, + id: [1; 32], + }, + ); + + //Act and assert + assert!(xcm_execute_filter_does_not_allow(&(loc, message))); +} + +#[test] +fn xcm_execute_filter_should_not_filter_message_with_max_deep() { + //Arrange + let fees = MultiAsset::from((MultiLocation::here(), 10)); + let assets: MultiAssets = fees.into(); + + let max_assets = 2; + let beneficiary = Junction::AccountId32 { + id: [3; 32], + network: None, + } + .into(); + let dest = MultiLocation::new(1, Parachain(2047)); + + let deposit = Xcm(vec![DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }]); + + let mut message = Xcm(vec![TransferReserveAsset { + assets: assets.clone(), + dest, + xcm: deposit, + }]); + + for _ in 0..4 { + let xcm = message.clone(); + message = Xcm(vec![TransferReserveAsset { + assets: assets.clone(), + dest, + xcm, + }]); + } + + let loc = MultiLocation::new( + 0, + AccountId32 { + network: None, + id: [1; 32], + }, + ); + + //Act and assert + assert!(AllowTransferAndSwap::, ConstU16<100>, ()>::contains(&( + loc, message + ))); +} + +#[test] +fn xcm_execute_filter_should_filter_messages_with_one_more_instruction_than_allowed_in_depth() { + //Arrange + let fees = MultiAsset::from((MultiLocation::here(), 10)); + let assets: MultiAssets = fees.into(); + + let max_assets = 2; + let beneficiary = Junction::AccountId32 { + id: [3; 32], + network: None, + } + .into(); + let dest = MultiLocation::new(1, Parachain(2047)); + + let deposit = Xcm(vec![DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }]); + + let mut message = Xcm(vec![TransferReserveAsset { + assets: assets.clone(), + dest, + xcm: deposit, + }]); + + for _ in 0..2 { + let xcm = message.clone(); + message = Xcm(vec![TransferReserveAsset { + assets: assets.clone(), + dest, + xcm: xcm.clone(), + }]); + } + + //It has 5 instruction + let mut instructions_with_inner_xcms: Vec> = vec![TransferReserveAsset { + assets: assets.clone(), + dest, + xcm: message.clone(), + }]; + + let mut rest: Vec> = vec![ + DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }; + 95 + ]; + + instructions_with_inner_xcms.append(&mut rest); + + message = Xcm(vec![TransferReserveAsset { + assets, + dest, + xcm: Xcm(instructions_with_inner_xcms.clone()), + }]); + + let loc = MultiLocation::new( + 0, + AccountId32 { + network: None, + id: [1; 32], + }, + ); + + //Act and assert + assert!(xcm_execute_filter_does_not_allow(&(loc, message))); +} + +#[test] +fn xcm_execute_filter_should_filter_messages_with_one_more_instruction_than_allowed_in_one_level() { + //Arrange + let max_assets = 2; + let beneficiary = Junction::AccountId32 { + id: [3; 32], + network: None, + } + .into(); + + let message_with_more_instructions_than_allowed = Xcm(vec![ + DepositAsset { + assets: Wild(AllCounted(max_assets)), + beneficiary, + }; + 101 + ]); + + let loc = MultiLocation::new( + 0, + AccountId32 { + network: None, + id: [1; 32], + }, + ); + + //Act and assert + assert!(xcm_execute_filter_does_not_allow(&( + loc, + message_with_more_instructions_than_allowed + ))); +} + +fn xcm_execute_filter_allows(loc_and_message: &(MultiLocation, Xcm)) -> bool { + AllowTransferAndSwap::, ConstU16<100>, RuntimeCall>::contains(loc_and_message) +} + +fn xcm_execute_filter_does_not_allow(loc_and_message: &(MultiLocation, Xcm<()>)) -> bool { + !AllowTransferAndSwap::, ConstU16<100>, ()>::contains(loc_and_message) +} diff --git a/runtime/adapters/src/xcm_exchange.rs b/runtime/adapters/src/xcm_exchange.rs new file mode 100644 index 000000000..fb0a508da --- /dev/null +++ b/runtime/adapters/src/xcm_exchange.rs @@ -0,0 +1,131 @@ +use hydradx_traits::router::PoolType; +use orml_traits::MultiCurrency; +use pallet_route_executor::Trade; +use polkadot_xcm::latest::prelude::*; +use sp_core::Get; +use sp_runtime::traits::{Convert, Zero}; +use sp_std::marker::PhantomData; +use sp_std::vec; +use xcm_executor::traits::AssetExchange; + +/// Implements `AssetExchange` to support the `ExchangeAsset` XCM instruction. +/// +/// Uses pallet-route-executor to execute trades. +/// +/// Will map exchange instructions with `maximal = true` to sell (selling all of `give` asset) and `false` to buy +/// (buying exactly `want` amount of asset). +/// +/// NOTE: Currenty limited to one asset each for `give` and `want`. +pub struct XcmAssetExchanger( + PhantomData<(Runtime, TempAccount, CurrencyIdConvert, Currency, Pool)>, +); + +impl AssetExchange + for XcmAssetExchanger +where + Runtime: pallet_route_executor::Config, + TempAccount: Get, + CurrencyIdConvert: Convert>, + Currency: MultiCurrency, + Runtime::Balance: From + Zero + Into, + Pool: Get>, +{ + fn exchange_asset( + _origin: Option<&MultiLocation>, + give: xcm_executor::Assets, + want: &MultiAssets, + maximal: bool, + ) -> Result { + use orml_utilities::with_transaction_result; + + let account = TempAccount::get(); + let origin = Runtime::RuntimeOrigin::from(frame_system::RawOrigin::Signed(account.clone())); + + if give.len() != 1 { + log::warn!(target: "xcm::exchange-asset", "Only one give asset is supported."); + return Err(give); + }; + + //We assume only one asset wanted as translating into buy and sell is ambigous for multiple want assets + if want.len() != 1 { + log::warn!(target: "xcm::exchange-asset", "Only one want asset is supported."); + return Err(give); + }; + let Some(given) = give.fungible_assets_iter().next() else { + return Err(give); + }; + + let Some(asset_in) = CurrencyIdConvert::convert(given.clone()) else { return Err(give) }; + let Some(wanted) = want.get(0) else { return Err(give) }; + let Some(asset_out) = CurrencyIdConvert::convert(wanted.clone()) else { return Err(give) }; + + if maximal { + // sell + let Fungible(amount) = given.fun else { return Err(give) }; + let Fungible(min_buy_amount) = wanted.fun else { return Err(give) }; + + with_transaction_result(|| { + Currency::deposit(asset_in, &account, amount.into())?; // mint the incoming tokens + pallet_route_executor::Pallet::::sell( + origin, + asset_in, + asset_out, + amount.into(), + min_buy_amount.into(), + vec![Trade { + pool: Pool::get(), + asset_in, + asset_out, + }], + )?; + debug_assert!( + Currency::free_balance(asset_in, &account) == Runtime::Balance::zero(), + "Sell should not leave any of the incoming asset." + ); + let amount_received = Currency::free_balance(asset_out, &account); + debug_assert!( + amount_received >= min_buy_amount.into(), + "Sell should return more than mininum buy amount." + ); + Currency::withdraw(asset_out, &account, amount_received)?; // burn the received tokens + Ok(MultiAsset::from((wanted.id, amount_received.into())).into()) + }) + .map_err(|_| give) + } else { + // buy + let Fungible(amount) = wanted.fun else { return Err(give) }; + let Fungible(max_sell_amount) = given.fun else { return Err(give) }; + + with_transaction_result(|| { + Currency::deposit(asset_in, &account, max_sell_amount.into())?; // mint the incoming tokens + pallet_route_executor::Pallet::::buy( + origin, + asset_in, + asset_out, + amount.into(), + max_sell_amount.into(), + vec![Trade { + pool: Pool::get(), + asset_in, + asset_out, + }], + )?; + let mut assets = sp_std::vec::Vec::with_capacity(2); + let left_over = Currency::free_balance(asset_in, &account); + if left_over > Runtime::Balance::zero() { + Currency::withdraw(asset_in, &account, left_over)?; // burn left over tokens + assets.push(MultiAsset::from((given.id, left_over.into()))); + } + let amount_received = Currency::free_balance(asset_out, &account); + debug_assert!( + amount_received == amount.into(), + "Buy should return exactly the amount we specified." + ); + Currency::withdraw(asset_out, &account, amount_received)?; // burn the received tokens + assets.push(MultiAsset::from((wanted.id, amount_received.into()))); + Ok(assets.into()) + }) + .map_err(|_| give) + } + } +} diff --git a/runtime/adapters/src/xcm_execute_filter.rs b/runtime/adapters/src/xcm_execute_filter.rs new file mode 100644 index 000000000..24a010350 --- /dev/null +++ b/runtime/adapters/src/xcm_execute_filter.rs @@ -0,0 +1,83 @@ +use sp_std::cell::Cell; +use sp_std::marker::PhantomData; + +use frame_support::traits::Contains; +use polkadot_xcm::v3::prelude::*; +use sp_core::Get; +use sp_runtime::Either; + +/// Meant to serve as an `XcmExecuteFilter` for `pallet_xcm` by allowing XCM instructions related to transferring and +/// exchanging assets while disallowing e.g. `Transact`. +pub struct AllowTransferAndSwap( + PhantomData<(MaxXcmDepth, MaxInstructions, RuntimeCall)>, +); + +impl Contains<(MultiLocation, Xcm)> + for AllowTransferAndSwap +where + MaxXcmDepth: Get, + MaxInstructions: Get, +{ + fn contains((loc, xcm): &(MultiLocation, Xcm)) -> bool { + // allow root to execute XCM + if loc == &MultiLocation::here() { + return true; + } + + let instructions_count = Cell::new(0u16); + check_instructions_recursively::(xcm, 0, &instructions_count) + } +} + +/// Recurses depth-first through the instructions of an XCM and checks whether they are allowed, limiting both recursion +/// depth (via `MaxXcmDepth`) and instructions (`MaxInstructions`). +/// See [`allowed_or_recurse`] for the filter list. +fn check_instructions_recursively( + xcm: &Xcm, + depth: u16, + instructions: &Cell, +) -> bool +where + MaxXcmDepth: Get, + MaxInstructions: Get, +{ + if depth > MaxXcmDepth::get() { + return false; + } + for inst in xcm.inner().iter() { + instructions.set(instructions.get() + 1); + if instructions.get() > MaxInstructions::get() { + return false; + } + + match allowed_or_recurse(inst) { + Either::Left(true) => continue, + Either::Left(false) => return false, + Either::Right(xcm) => { + if !check_instructions_recursively::(xcm, depth + 1, instructions) { + return false; + } + } + } + } + true +} + +/// Check if an XCM instruction is allowed (returning `Left(true)`), disallowed (`Left(false)`) or needs recursion to +/// determine whether it is allowed (`Right(xcm)`). +fn allowed_or_recurse(inst: &Instruction) -> Either> { + match inst { + ClearOrigin + | ExchangeAsset { .. } + | WithdrawAsset(..) + | TransferAsset { .. } + | DepositAsset { .. } + | ExpectAsset(..) + | SetFeesMode { .. } + | BuyExecution { .. } => Either::Left(true), + InitiateReserveWithdraw { xcm, .. } | DepositReserveAsset { xcm, .. } | TransferReserveAsset { xcm, .. } => { + Either::Right(xcm) + } + _ => Either::Left(false), + } +}