diff --git a/Cargo.toml b/Cargo.toml index 52cf6e823b..e1bfe21981 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "pallets/precompile-benchmarks", "pallets/proxy-genesis-companion", "pallets/xcm-transactor", + "pallets/xcm-weight-trader", "precompiles/balances-erc20", "precompiles/batch", "precompiles/call-permit", @@ -105,6 +106,7 @@ pallet-precompile-benchmarks = { path = "pallets/precompile-benchmarks", default pallet-proxy-genesis-companion = { path = "pallets/proxy-genesis-companion", default-features = false } pallet-xcm-transactor = { path = "pallets/xcm-transactor", default-features = false } pallet-moonbeam-lazy-migrations = { path = "pallets/moonbeam-lazy-migrations", default-features = false } +pallet-xcm-weight-trader = { path = "pallets/xcm-weight-trader", default-features = false } precompile-utils = { path = "precompiles/utils", default-features = false } xcm-primitives = { path = "primitives/xcm", default-features = false } diff --git a/pallets/xcm-weight-trader/Cargo.toml b/pallets/xcm-weight-trader/Cargo.toml new file mode 100644 index 0000000000..680c3c234f --- /dev/null +++ b/pallets/xcm-weight-trader/Cargo.toml @@ -0,0 +1,54 @@ +[package] +authors = {workspace = true} +description = "A pallet to trade weight for XCM execution" +edition = "2021" +name = "pallet-xcm-weight-trader" +version = "0.1.0" + +[dependencies] +log = {workspace = true} + +# Substrate +frame-support = {workspace = true} +frame-system = {workspace = true} +pallet-balances = {workspace = true} +parity-scale-codec = {workspace = true} +scale-info = {workspace = true, features = ["derive"]} +sp-core = {workspace = true} +sp-io = {workspace = true} +sp-runtime = {workspace = true} +sp-std = {workspace = true} + +# Polkadot +xcm = { workspace = true } +xcm-executor = { workspace = true } +xcm-fee-payment-runtime-api = { workspace = true } + +# Benchmarks +frame-benchmarking = {workspace = true, optional = true} + +[dev-dependencies] +frame-benchmarking = {workspace = true, features = ["std"]} +pallet-balances = {workspace = true, features = ["std", "insecure_zero_ed"]} +sp-tracing = {workspace = true, features = ["std"] } + +[features] +default = ["std"] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-system/runtime-benchmarks" +] +std = [ + "frame-support/std", + "frame-system/std", + "pallet-balances/std", + "scale-info/std", + "sp-core/std", + "sp-io/std", + "sp-runtime/std", + "sp-std/std", + "xcm/std", + "xcm-executor/std", + "xcm-fee-payment-runtime-api/std", +] +try-runtime = ["frame-support/try-runtime"] \ No newline at end of file diff --git a/pallets/xcm-weight-trader/src/benchmarking.rs b/pallets/xcm-weight-trader/src/benchmarking.rs new file mode 100644 index 0000000000..1d711289eb --- /dev/null +++ b/pallets/xcm-weight-trader/src/benchmarking.rs @@ -0,0 +1,139 @@ +// Copyright 2024 Moonbeam foundation +// This file is part of Moonbeam. + +// Moonbeam is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Moonbeam is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Moonbeam. If not, see . + +#![cfg(feature = "runtime-benchmarks")] + +use super::*; + +use frame_benchmarking::{v2::*, BenchmarkError}; +use frame_support::traits::EnsureOrigin; +use frame_system::EventRecord; + +fn assert_last_event(generic_event: ::RuntimeEvent) { + let events = frame_system::Pallet::::events(); + let system_event: ::RuntimeEvent = generic_event.into(); + // compare to the last event record + let EventRecord { event, .. } = &events[events.len() - 1]; + assert_eq!(event, &system_event); +} + +fn setup_one_asset() -> Result { + let origin = T::AddSupportedAssetOrigin::try_successful_origin() + .map_err(|_| BenchmarkError::Weightless)?; + + let location = T::NotFilteredLocation::get(); + + Pallet::::add_asset(origin, location.clone(), 1_000).expect("fail to setup asset"); + + Ok(location) +} + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn add_asset() -> Result<(), BenchmarkError> { + let origin = T::AddSupportedAssetOrigin::try_successful_origin() + .map_err(|_| BenchmarkError::Weightless)?; + + let location = T::NotFilteredLocation::get(); + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, location.clone(), 1_000); + + assert_last_event::( + Event::SupportedAssetAdded { + location, + relative_price: 1_000, + } + .into(), + ); + Ok(()) + } + + #[benchmark] + fn edit_asset() -> Result<(), BenchmarkError> { + // Setup one asset + let location = setup_one_asset::()?; + + let origin = T::EditSupportedAssetOrigin::try_successful_origin() + .map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, location.clone(), 2_000); + + assert_last_event::( + Event::SupportedAssetEdited { + location, + relative_price: 2_000, + } + .into(), + ); + Ok(()) + } + + #[benchmark] + fn resume_asset_support() -> Result<(), BenchmarkError> { + // Setup one asset + let location = setup_one_asset::()?; + let pause_origin = T::PauseSupportedAssetOrigin::try_successful_origin() + .map_err(|_| BenchmarkError::Weightless)?; + Pallet::::pause_asset_support(pause_origin, location.clone()) + .expect("fail to pause asset"); + + let origin = T::ResumeSupportedAssetOrigin::try_successful_origin() + .map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, location.clone()); + + assert_last_event::(Event::ResumeAssetSupport { location }.into()); + Ok(()) + } + + #[benchmark] + fn pause_asset_support() -> Result<(), BenchmarkError> { + // Setup one asset + let location = setup_one_asset::()?; + + let origin = T::PauseSupportedAssetOrigin::try_successful_origin() + .map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, location.clone()); + + assert_last_event::(Event::PauseAssetSupport { location }.into()); + Ok(()) + } + + #[benchmark] + fn remove_asset() -> Result<(), BenchmarkError> { + // Setup one asset + let location = setup_one_asset::()?; + + let origin = T::RemoveSupportedAssetOrigin::try_successful_origin() + .map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, location.clone()); + + assert_last_event::(Event::SupportedAssetRemoved { location }.into()); + Ok(()) + } + + impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test,); +} diff --git a/pallets/xcm-weight-trader/src/lib.rs b/pallets/xcm-weight-trader/src/lib.rs new file mode 100644 index 0000000000..6fef42f452 --- /dev/null +++ b/pallets/xcm-weight-trader/src/lib.rs @@ -0,0 +1,472 @@ +// Copyright 2024 Moonbeam foundation +// This file is part of Moonbeam. + +// Moonbeam is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Moonbeam is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Moonbeam. If not, see . + +//! # A pallet to trade weight for XCM execution + +#![allow(non_camel_case_types)] +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +pub mod weights; + +pub use pallet::*; +pub use weights::WeightInfo; + +use frame_support::pallet; +use frame_support::pallet_prelude::*; +use frame_support::traits::Contains; +use frame_support::weights::WeightToFee; +use frame_system::pallet_prelude::*; +use sp_runtime::traits::{Convert, Zero}; +use sp_std::vec::Vec; +use xcm::v4::{Asset, AssetId as XcmAssetId, Error as XcmError, Fungibility, Location, XcmContext}; +use xcm::{IntoVersion, VersionedAssetId}; +use xcm_executor::traits::{TransactAsset, WeightTrader}; +use xcm_fee_payment_runtime_api::Error as XcmPaymentApiError; + +pub const RELATIVE_PRICE_DECIMALS: u32 = 18; + +#[pallet] +pub mod pallet { + use super::*; + + /// Pallet for multi block migrations + #[pallet::pallet] + pub struct Pallet(PhantomData); + + /// Configuration trait of this pallet. + #[pallet::config] + pub trait Config: frame_system::Config { + /// Convert `T::AccountId` to `Location`. + type AccountIdToLocation: Convert; + + /// Origin that is allowed to register a supported asset + type AddSupportedAssetOrigin: EnsureOrigin; + + /// A filter to forbid some XCM Location to be supported for fees. + /// if you don't use it, put "Everything". + type AssetLocationFilter: Contains; + + /// How to withdraw and deposit an asset. + type AssetTransactor: TransactAsset; + + /// The native balance type. + type Balance: TryInto; + + /// Origin that is allowed to edit a supported asset units per seconds + type EditSupportedAssetOrigin: EnsureOrigin; + + /// XCM Location for native curreny + type NativeLocation: Get; + + /// Origin that is allowed to pause a supported asset + type PauseSupportedAssetOrigin: EnsureOrigin; + + /// Origin that is allowed to remove a supported asset + type RemoveSupportedAssetOrigin: EnsureOrigin; + + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// Origin that is allowed to unpause a supported asset + type ResumeSupportedAssetOrigin: EnsureOrigin; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; + + /// Convert a weight value into deductible native balance. + type WeightToFee: WeightToFee; + + /// Account that will receive xcm fees + type XcmFeesAccount: Get; + + /// The benchmarks need a location that pass the filter AssetLocationFilter + #[cfg(feature = "runtime-benchmarks")] + type NotFilteredLocation: Get; + } + + /// Stores all supported assets per XCM Location. + /// The u128 is the asset price relative to native asset with 18 decimals + /// The boolean specify if the support for this asset is active + #[pallet::storage] + #[pallet::getter(fn supported_assets)] + pub type SupportedAssets = StorageMap<_, Blake2_128Concat, Location, (bool, u128)>; + + #[pallet::error] + pub enum Error { + /// The given asset was already added + AssetAlreadyAdded, + /// The given asset was already paused + AssetAlreadyPaused, + /// The given asset was not found + AssetNotFound, + /// The given asset is not paused + AssetNotPaused, + /// XCM location filtered + XcmLocationFiltered, + /// The relative price cannot be zero + PriceCannotBeZero, + } + + #[pallet::event] + #[pallet::generate_deposit(pub(crate) fn deposit_event)] + pub enum Event { + /// New supported asset is registered + SupportedAssetAdded { + location: Location, + relative_price: u128, + }, + /// Changed the amount of units we are charging per execution second for a given asset + SupportedAssetEdited { + location: Location, + relative_price: u128, + }, + /// Pause support for a given asset + PauseAssetSupport { location: Location }, + /// Resume support for a given asset + ResumeAssetSupport { location: Location }, + /// Supported asset type for fee payment removed + SupportedAssetRemoved { location: Location }, + } + + #[pallet::call] + impl Pallet { + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::add_asset())] + pub fn add_asset( + origin: OriginFor, + location: Location, + relative_price: u128, + ) -> DispatchResult { + T::AddSupportedAssetOrigin::ensure_origin(origin)?; + + ensure!(relative_price != 0, Error::::PriceCannotBeZero); + ensure!( + !SupportedAssets::::contains_key(&location), + Error::::AssetAlreadyAdded + ); + ensure!( + T::AssetLocationFilter::contains(&location), + Error::::XcmLocationFiltered + ); + + SupportedAssets::::insert(&location, (true, relative_price)); + + Self::deposit_event(Event::SupportedAssetAdded { + location, + relative_price, + }); + + Ok(()) + } + + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::edit_asset())] + pub fn edit_asset( + origin: OriginFor, + location: Location, + relative_price: u128, + ) -> DispatchResult { + T::EditSupportedAssetOrigin::ensure_origin(origin)?; + + ensure!(relative_price != 0, Error::::PriceCannotBeZero); + + let enabled = SupportedAssets::::get(&location) + .ok_or(Error::::AssetNotFound)? + .0; + + SupportedAssets::::insert(&location, (enabled, relative_price)); + + Self::deposit_event(Event::SupportedAssetEdited { + location, + relative_price, + }); + + Ok(()) + } + + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::pause_asset_support())] + pub fn pause_asset_support(origin: OriginFor, location: Location) -> DispatchResult { + T::PauseSupportedAssetOrigin::ensure_origin(origin)?; + + match SupportedAssets::::get(&location) { + Some((true, relative_price)) => { + SupportedAssets::::insert(&location, (false, relative_price)); + Self::deposit_event(Event::PauseAssetSupport { location }); + Ok(()) + } + Some((false, _)) => Err(Error::::AssetAlreadyPaused.into()), + None => Err(Error::::AssetNotFound.into()), + } + } + + #[pallet::call_index(3)] + #[pallet::weight(T::WeightInfo::resume_asset_support())] + pub fn resume_asset_support(origin: OriginFor, location: Location) -> DispatchResult { + T::ResumeSupportedAssetOrigin::ensure_origin(origin)?; + + match SupportedAssets::::get(&location) { + Some((false, relative_price)) => { + SupportedAssets::::insert(&location, (true, relative_price)); + Self::deposit_event(Event::ResumeAssetSupport { location }); + Ok(()) + } + Some((true, _)) => Err(Error::::AssetNotPaused.into()), + None => Err(Error::::AssetNotFound.into()), + } + } + + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::remove_asset())] + pub fn remove_asset(origin: OriginFor, location: Location) -> DispatchResult { + T::RemoveSupportedAssetOrigin::ensure_origin(origin)?; + + ensure!( + SupportedAssets::::contains_key(&location), + Error::::AssetNotFound + ); + + SupportedAssets::::remove(&location); + + Self::deposit_event(Event::SupportedAssetRemoved { location }); + + Ok(()) + } + } + + impl Pallet { + pub fn get_asset_relative_price(location: &Location) -> Option { + if let Some((true, ratio)) = SupportedAssets::::get(location) { + Some(ratio) + } else { + None + } + } + pub fn query_acceptable_payment_assets( + xcm_version: xcm::Version, + ) -> Result, XcmPaymentApiError> { + if !matches!(xcm_version, 3 | 4) { + return Err(XcmPaymentApiError::UnhandledXcmVersion); + } + + let v4_assets = [VersionedAssetId::V4(XcmAssetId::from( + T::NativeLocation::get(), + ))] + .into_iter() + .chain( + SupportedAssets::::iter().filter_map(|(asset_location, (enabled, _))| { + enabled.then(|| VersionedAssetId::V4(XcmAssetId(asset_location))) + }), + ) + .collect::>(); + + if xcm_version == 3 { + v4_assets + .into_iter() + .map(|v4_asset| v4_asset.into_version(3)) + .collect::>() + .map_err(|_| XcmPaymentApiError::VersionedConversionFailed) + } else { + Ok(v4_assets) + } + } + pub fn query_weight_to_asset_fee( + weight: Weight, + asset: VersionedAssetId, + ) -> Result { + if let VersionedAssetId::V4(XcmAssetId(asset_location)) = asset + .into_version(4) + .map_err(|_| XcmPaymentApiError::VersionedConversionFailed)? + { + Trader::::compute_amount_to_charge(&weight, &asset_location).map_err(|e| match e + { + XcmError::AssetNotFound => XcmPaymentApiError::AssetNotFound, + _ => XcmPaymentApiError::WeightNotComputable, + }) + } else { + Err(XcmPaymentApiError::UnhandledXcmVersion) + } + } + #[cfg(any(feature = "std", feature = "runtime-benchmarks"))] + pub fn set_asset_price(asset_location: Location, relative_price: u128) { + SupportedAssets::::insert(&asset_location, (true, relative_price)); + } + } +} + +pub struct Trader(Weight, Option, core::marker::PhantomData); + +impl Trader { + fn compute_amount_to_charge( + weight: &Weight, + asset_location: &Location, + ) -> Result { + if *asset_location == ::NativeLocation::get() { + ::WeightToFee::weight_to_fee(&weight) + .try_into() + .map_err(|_| XcmError::Overflow) + } else if let Some(relative_price) = Pallet::::get_asset_relative_price(asset_location) { + if relative_price == 0u128 { + Ok(0u128) + } else { + let native_amount: u128 = ::WeightToFee::weight_to_fee(&weight) + .try_into() + .map_err(|_| XcmError::Overflow)?; + Ok(native_amount + .checked_mul(10u128.pow(RELATIVE_PRICE_DECIMALS)) + .ok_or(XcmError::Overflow)? + .checked_div(relative_price) + .ok_or(XcmError::Overflow)?) + } + } else { + Err(XcmError::AssetNotFound) + } + } +} + +impl WeightTrader for Trader { + fn new() -> Self { + Self(Weight::zero(), None, PhantomData) + } + fn buy_weight( + &mut self, + weight: Weight, + payment: xcm_executor::AssetsInHolding, + context: &XcmContext, + ) -> Result { + log::trace!( + target: "xcm::weight", + "UsingComponents::buy_weight weight: {:?}, payment: {:?}, context: {:?}", + weight, + payment, + context + ); + + // Can only call one time + if self.1.is_some() { + return Err(XcmError::NotWithdrawable); + } + + // Consistency check for tests only, we should never panic in release mode + debug_assert_eq!(self.0, Weight::zero()); + + // We support only one fee asset per buy, so we take the first one. + let first_asset = payment + .clone() + .fungible_assets_iter() + .next() + .ok_or(XcmError::AssetNotFound)?; + + match (first_asset.id, first_asset.fun) { + (XcmAssetId(location), Fungibility::Fungible(_)) => { + let amount: u128 = Self::compute_amount_to_charge(&weight, &location)?; + + // We don't need to proceed if the amount is 0 + // For cases (specially tests) where the asset is very cheap with respect + // to the weight needed + if amount.is_zero() { + return Ok(payment); + } + + let required = Asset { + fun: Fungibility::Fungible(amount), + id: XcmAssetId(location), + }; + let unused = payment + .checked_sub(required.clone()) + .map_err(|_| XcmError::TooExpensive)?; + + self.0 = weight; + self.1 = Some(required); + + Ok(unused) + } + _ => Err(XcmError::AssetNotFound), + } + } + + fn refund_weight(&mut self, actual_weight: Weight, context: &XcmContext) -> Option { + log::trace!( + target: "xcm-weight-trader", + "refund_weight weight: {:?}, context: {:?}, available weight: {:?}, asset: {:?}", + actual_weight, + context, + self.0, + self.1 + ); + if let Some(Asset { + fun: Fungibility::Fungible(initial_amount), + id: XcmAssetId(location), + }) = self.1.take() + { + if actual_weight == self.0 { + self.1 = Some(Asset { + fun: Fungibility::Fungible(initial_amount), + id: XcmAssetId(location), + }); + None + } else { + let weight = actual_weight.min(self.0); + let amount: u128 = + Self::compute_amount_to_charge(&weight, &location).unwrap_or(u128::MAX); + let final_amount = amount.min(initial_amount); + let amount_to_refund = initial_amount.saturating_sub(final_amount); + self.0 -= weight; + self.1 = Some(Asset { + fun: Fungibility::Fungible(final_amount), + id: XcmAssetId(location.clone()), + }); + log::trace!( + target: "xcm-weight-trader", + "refund_weight amount to refund: {:?}", + amount_to_refund + ); + Some(Asset { + fun: Fungibility::Fungible(amount_to_refund), + id: XcmAssetId(location), + }) + } + } else { + None + } + } +} + +impl Drop for Trader { + fn drop(&mut self) { + log::trace!( + target: "xcm-weight-trader", + "Dropping `Trader` instance: (weight: {:?}, asset: {:?})", + &self.0, + &self.1 + ); + if let Some(asset) = self.1.take() { + let res = T::AssetTransactor::deposit_asset( + &asset, + &T::AccountIdToLocation::convert(T::XcmFeesAccount::get()), + None, + ); + debug_assert!(res.is_ok()); + } + } +} diff --git a/pallets/xcm-weight-trader/src/mock.rs b/pallets/xcm-weight-trader/src/mock.rs new file mode 100644 index 0000000000..b1ce8166eb --- /dev/null +++ b/pallets/xcm-weight-trader/src/mock.rs @@ -0,0 +1,200 @@ +// Copyright 2024 Moonbeam foundation +// This file is part of Moonbeam. + +// Moonbeam is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Moonbeam is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Moonbeam. If not, see . + +//! A minimal runtime including the multi block migrations pallet + +use super::*; +use crate as pallet_xcm_weight_trader; +use frame_support::{ + construct_runtime, ord_parameter_types, parameter_types, + traits::{Currency, Everything}, + weights::{constants::RocksDbWeight, IdentityFee}, +}; +use frame_system::EnsureSignedBy; +use sp_core::H256; +use sp_runtime::{ + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, +}; +use xcm::v4::{Asset, Error as XcmError, Junction, Location, Result as XcmResult, XcmContext}; + +type AccountId = u64; +type Balance = u128; +type Block = frame_system::mocking::MockBlock; + +// Configure a mock runtime to test the pallet. +construct_runtime!( + pub enum Test + { + System: frame_system::{Pallet, Call, Config, Storage, Event}, + Balances: pallet_balances::{Pallet, Call, Storage, Config, Event}, + XcmWeightTrader: pallet_xcm_weight_trader::{Pallet, Call, Storage, Event}, + } +); + +parameter_types! { + pub const BlockHashCount: u32 = 250; + pub const SS58Prefix: u8 = 42; +} +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type DbWeight = RocksDbWeight; + type RuntimeOrigin = RuntimeOrigin; + type RuntimeTask = RuntimeTask; + type Nonce = u64; + type Block = Block; + type RuntimeCall = RuntimeCall; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = BlockHashCount; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type BlockWeights = (); + type BlockLength = (); + type SS58Prefix = SS58Prefix; + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; + type SingleBlockMigrations = (); + type MultiBlockMigrator = (); + type PreInherents = (); + type PostInherents = (); + type PostTransactions = (); +} + +parameter_types! { + pub const ExistentialDeposit: u128 = 0; +} +impl pallet_balances::Config for Test { + type MaxReserves = (); + type ReserveIdentifier = (); + type MaxLocks = (); + type Balance = Balance; + type RuntimeEvent = RuntimeEvent; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); + type RuntimeHoldReason = (); + type FreezeIdentifier = (); + type MaxFreezes = (); + type RuntimeFreezeReason = (); +} + +pub struct AccountIdToLocation; +impl Convert for AccountIdToLocation { + fn convert(account: AccountId) -> Location { + Location::new( + 0, + [Junction::AccountIndex64 { + network: None, + index: account, + }], + ) + } +} + +pub struct AssetLocationFilter; +impl Contains for AssetLocationFilter { + fn contains(location: &Location) -> bool { + *location == ::NativeLocation::get() || *location == Location::parent() + } +} + +pub fn get_parent_asset_deposited() -> Option<(AccountId, Balance)> { + storage::unhashed::get_raw(b"____parent_asset_deposited") + .map(|output| Decode::decode(&mut output.as_slice()).expect("Decoding should work")) +} + +pub struct MockAssetTransactor; +impl TransactAsset for MockAssetTransactor { + fn deposit_asset(asset: &Asset, who: &Location, _context: Option<&XcmContext>) -> XcmResult { + match (asset.id.clone(), asset.fun.clone()) { + (XcmAssetId(location), Fungibility::Fungible(amount)) => { + let who = match who.interior.iter().next() { + Some(Junction::AccountIndex64 { index, .. }) => index, + _ => panic!("invalid location"), + }; + if location == ::NativeLocation::get() { + let _ = Balances::deposit_creating(who, amount); + Ok(()) + } else if location == Location::parent() { + storage::unhashed::put_raw( + b"____parent_asset_deposited", + (who, amount).encode().as_slice(), + ); + Ok(()) + } else { + Err(XcmError::AssetNotFound) + } + } + _ => Err(XcmError::AssetNotFound), + } + } +} + +ord_parameter_types! { + pub const AddAccount: u64 = 1; + pub const EditAccount: u64 = 2; + pub const PauseAccount: u64 = 3; + pub const ResumeAccount: u64 = 4; + pub const RemoveAccount: u64 = 5; +} + +parameter_types! { + pub NativeLocation: Location = Location::here(); + pub XcmFeesAccount: AccountId = 101; + pub NotFilteredLocation: Location = Location::parent(); +} + +impl Config for Test { + type AccountIdToLocation = AccountIdToLocation; + type AddSupportedAssetOrigin = EnsureSignedBy; + type AssetLocationFilter = AssetLocationFilter; + type AssetTransactor = MockAssetTransactor; + type Balance = Balance; + type EditSupportedAssetOrigin = EnsureSignedBy; + type NativeLocation = NativeLocation; + type PauseSupportedAssetOrigin = EnsureSignedBy; + type RemoveSupportedAssetOrigin = EnsureSignedBy; + type RuntimeEvent = RuntimeEvent; + type ResumeSupportedAssetOrigin = EnsureSignedBy; + type WeightInfo = (); + type WeightToFee = IdentityFee; + type XcmFeesAccount = XcmFeesAccount; + #[cfg(feature = "runtime-benchmarks")] + type NotFilteredLocation = NotFilteredLocation; +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + sp_tracing::try_init_simple(); + let mut t = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap(); + + let balances = vec![(1, 100), (2, 100), (3, 100), (4, 100), (5, 100)]; + pallet_balances::GenesisConfig:: { balances } + .assimilate_storage(&mut t) + .unwrap(); + + t.into() +} diff --git a/pallets/xcm-weight-trader/src/tests.rs b/pallets/xcm-weight-trader/src/tests.rs new file mode 100644 index 0000000000..daceed2445 --- /dev/null +++ b/pallets/xcm-weight-trader/src/tests.rs @@ -0,0 +1,704 @@ +// Copyright 2024 Moonbeam foundation +// This file is part of Moonbeam. + +// Moonbeam is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Moonbeam is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Moonbeam. If not, see . + +//! Unit testing +use { + crate::mock::*, + crate::{Error, Trader, XcmPaymentApiError}, + frame_support::pallet_prelude::Weight, + frame_support::{assert_noop, assert_ok}, + sp_runtime::DispatchError, + xcm::v4::{ + Asset, AssetId as XcmAssetId, Error as XcmError, Fungibility, Location, XcmContext, XcmHash, + }, + xcm::{IntoVersion, VersionedAssetId}, + xcm_executor::traits::WeightTrader, +}; + +fn xcm_fees_account() -> ::AccountId { + ::XcmFeesAccount::get() +} + +#[test] +fn test_add_supported_asset() { + new_test_ext().execute_with(|| { + // Call with bad origin + assert_noop!( + XcmWeightTrader::add_asset( + RuntimeOrigin::signed(EditAccount::get()), + Location::parent(), + 1_000, + ), + DispatchError::BadOrigin + ); + + // Call with invalid location + assert_noop!( + XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::new(2, []), + 1_000, + ), + Error::::XcmLocationFiltered + ); + + // Call with invalid price + assert_noop!( + XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 0, + ), + Error::::PriceCannotBeZero + ); + + // Call with the right origin + assert_ok!(XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 1_000, + )); + + // The account should be supported + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(1_000), + ); + + // Check storage + assert_eq!( + crate::pallet::SupportedAssets::::get(&Location::parent()), + Some((true, 1_000)) + ); + + // Try to add the same asset twice (should fail) + assert_noop!( + XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 1_000, + ), + Error::::AssetAlreadyAdded + ); + }) +} + +#[test] +fn test_edit_supported_asset() { + new_test_ext().execute_with(|| { + // Should not be able to edit an asset not added yet + assert_noop!( + XcmWeightTrader::edit_asset( + RuntimeOrigin::signed(EditAccount::get()), + Location::parent(), + 2_000, + ), + Error::::AssetNotFound + ); + + // Setup (add a supported asset) + assert_ok!(XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 1_000, + )); + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(1_000), + ); + + // Call with bad origin + assert_noop!( + XcmWeightTrader::edit_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 2_000, + ), + DispatchError::BadOrigin + ); + + // Call with invalid price + assert_noop!( + XcmWeightTrader::edit_asset( + RuntimeOrigin::signed(EditAccount::get()), + Location::parent(), + 0, + ), + Error::::PriceCannotBeZero + ); + + // Call with right origin and valid params + assert_ok!(XcmWeightTrader::edit_asset( + RuntimeOrigin::signed(EditAccount::get()), + Location::parent(), + 2_000, + ),); + + // The account should be supported + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(2_000), + ); + + // Check storage + assert_eq!( + crate::pallet::SupportedAssets::::get(&Location::parent()), + Some((true, 2_000)) + ); + }) +} + +#[test] +fn test_pause_asset_support() { + new_test_ext().execute_with(|| { + // Should not be able to pause an asset not added yet + assert_noop!( + XcmWeightTrader::pause_asset_support( + RuntimeOrigin::signed(PauseAccount::get()), + Location::parent(), + ), + Error::::AssetNotFound + ); + + // Setup (add a supported asset) + assert_ok!(XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 1_000, + )); + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(1_000), + ); + + // Call with bad origin + assert_noop!( + XcmWeightTrader::pause_asset_support( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + ), + DispatchError::BadOrigin + ); + + // Call with right origin and valid params + assert_ok!(XcmWeightTrader::pause_asset_support( + RuntimeOrigin::signed(PauseAccount::get()), + Location::parent(), + )); + + // The asset should be paused + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + None, + ); + + // Check storage + assert_eq!( + crate::pallet::SupportedAssets::::get(&Location::parent()), + Some((false, 1_000)) + ); + + // Should not be able to pause an asset already paused + assert_noop!( + XcmWeightTrader::pause_asset_support( + RuntimeOrigin::signed(PauseAccount::get()), + Location::parent(), + ), + Error::::AssetAlreadyPaused + ); + + // Should be able to udpate relative price of paused asset + assert_ok!(XcmWeightTrader::edit_asset( + RuntimeOrigin::signed(EditAccount::get()), + Location::parent(), + 500 + )); + + // The asset should still be paused + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + None, + ); + + // Check storage + assert_eq!( + crate::pallet::SupportedAssets::::get(&Location::parent()), + Some((false, 500)) + ); + }) +} + +#[test] +fn test_resume_asset_support() { + new_test_ext().execute_with(|| { + // Setup (add a supported asset and pause it) + assert_ok!(XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 1_000, + )); + assert_ok!(XcmWeightTrader::pause_asset_support( + RuntimeOrigin::signed(PauseAccount::get()), + Location::parent(), + )); + assert_eq!( + crate::pallet::SupportedAssets::::get(&Location::parent()), + Some((false, 1_000)) + ); + + // Call with bad origin + assert_noop!( + XcmWeightTrader::resume_asset_support( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + ), + DispatchError::BadOrigin + ); + + // Call with invalid location + assert_noop!( + XcmWeightTrader::resume_asset_support( + RuntimeOrigin::signed(ResumeAccount::get()), + Location::new(2, []), + ), + Error::::AssetNotFound + ); + + // Call with right origin and valid params + assert_ok!(XcmWeightTrader::resume_asset_support( + RuntimeOrigin::signed(ResumeAccount::get()), + Location::parent(), + )); + + // The asset should be supported again + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(1_000), + ); + + // Check storage + assert_eq!( + crate::pallet::SupportedAssets::::get(&Location::parent()), + Some((true, 1_000)) + ); + + // Should not be able to resume an asset already active + assert_noop!( + XcmWeightTrader::resume_asset_support( + RuntimeOrigin::signed(ResumeAccount::get()), + Location::parent(), + ), + Error::::AssetNotPaused + ); + }) +} + +#[test] +fn test_remove_asset_support() { + new_test_ext().execute_with(|| { + // Should not be able to remove an asset not added yet + assert_noop!( + XcmWeightTrader::remove_asset( + RuntimeOrigin::signed(RemoveAccount::get()), + Location::parent(), + ), + Error::::AssetNotFound + ); + + // Setup (add a supported asset) + assert_ok!(XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 1_000, + )); + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(1_000), + ); + + // Call with bad origin + assert_noop!( + XcmWeightTrader::remove_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + ), + DispatchError::BadOrigin + ); + + // Call with right origin and valid params + assert_ok!(XcmWeightTrader::remove_asset( + RuntimeOrigin::signed(RemoveAccount::get()), + Location::parent(), + )); + + // The account should be removed + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + None, + ); + + // Check storage + assert_eq!( + crate::pallet::SupportedAssets::::get(&Location::parent()), + None + ); + + // Should not be able to pause an asset already removed + assert_noop!( + XcmWeightTrader::remove_asset( + RuntimeOrigin::signed(RemoveAccount::get()), + Location::parent(), + ), + Error::::AssetNotFound + ); + }) +} + +#[test] +fn test_trader_native_asset() { + new_test_ext().execute_with(|| { + let weight_to_buy = Weight::from_parts(10_000, 0); + let dummy_xcm_context = XcmContext::with_message_id(XcmHash::default()); + + // Should not be able to buy weight with too low asset balance + assert_eq!( + Trader::::new().buy_weight( + weight_to_buy, + Asset { + fun: Fungibility::Fungible(9_999), + id: XcmAssetId(Location::here()), + } + .into(), + &dummy_xcm_context + ), + Err(XcmError::TooExpensive) + ); + + // Should not be able to buy weight with unsupported asset + assert_eq!( + Trader::::new().buy_weight( + weight_to_buy, + Asset { + fun: Fungibility::Fungible(10_000), + id: XcmAssetId(Location::parent()), + } + .into(), + &dummy_xcm_context + ), + Err(XcmError::AssetNotFound) + ); + + // Should not be able to buy weight without asset + assert_eq!( + Trader::::new().buy_weight(weight_to_buy, Default::default(), &dummy_xcm_context), + Err(XcmError::AssetNotFound) + ); + + // Should be able to buy weight with just enough native asset + let mut trader = Trader::::new(); + assert_eq!( + trader.buy_weight( + weight_to_buy, + Asset { + fun: Fungibility::Fungible(10_000), + id: XcmAssetId(Location::here()), + } + .into(), + &dummy_xcm_context + ), + Ok(Default::default()) + ); + + // Should not refund any funds + let actual_weight = weight_to_buy; + assert_eq!( + trader.refund_weight(actual_weight, &dummy_xcm_context), + None + ); + + // Should not be able to buy weight again with the same trader + assert_eq!( + trader.buy_weight( + weight_to_buy, + Asset { + fun: Fungibility::Fungible(10_000), + id: XcmAssetId(Location::here()), + } + .into(), + &dummy_xcm_context + ), + Err(XcmError::NotWithdrawable) + ); + + // Fees asset should be deposited into XcmFeesAccount + drop(trader); + assert_eq!(Balances::free_balance(&xcm_fees_account()), 10_000); + + // Should be able to buy weight with more native asset (and get back unused amount) + let mut trader = Trader::::new(); + assert_eq!( + trader.buy_weight( + weight_to_buy, + Asset { + fun: Fungibility::Fungible(11_000), + id: XcmAssetId(Location::here()), + } + .into(), + &dummy_xcm_context + ), + Ok(Asset { + fun: Fungibility::Fungible(1_000), + id: XcmAssetId(Location::here()), + } + .into()) + ); + + // Should be able to refund unused weights + let actual_weight = weight_to_buy.saturating_sub(Weight::from_parts(2_000, 0)); + assert_eq!( + trader.refund_weight(actual_weight, &dummy_xcm_context), + Some(Asset { + fun: Fungibility::Fungible(2_000), + id: XcmAssetId(Location::here()), + }) + ); + + // Fees asset should be deposited again into XcmFeesAccount (2 times cost minus one refund) + drop(trader); + assert_eq!( + Balances::free_balance(&xcm_fees_account()), + (2 * 10_000) - 2_000 + ); + }) +} + +#[test] +fn test_trader_parent_asset() { + new_test_ext().execute_with(|| { + let weight_to_buy = Weight::from_parts(10_000, 0); + let dummy_xcm_context = XcmContext::with_message_id(XcmHash::default()); + + // Setup (add a supported asset) + assert_ok!(XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 500_000_000, + )); + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(500_000_000), + ); + + // Should be able to pay fees with registered asset + let mut trader = Trader::::new(); + assert_eq!( + trader.buy_weight( + weight_to_buy, + Asset { + fun: Fungibility::Fungible(22_000_000_000_000), + id: XcmAssetId(Location::parent()), + } + .into(), + &dummy_xcm_context + ), + Ok(Asset { + fun: Fungibility::Fungible(2_000_000_000_000), + id: XcmAssetId(Location::parent()), + } + .into()) + ); + + // Should be able to refund unused weights + let actual_weight = weight_to_buy.saturating_sub(Weight::from_parts(2_000, 0)); + assert_eq!( + trader.refund_weight(actual_weight, &dummy_xcm_context), + Some(Asset { + fun: Fungibility::Fungible(4_000_000_000_000), + id: XcmAssetId(Location::parent()), + }) + ); + + // Fees asset should be deposited into XcmFeesAccount + drop(trader); + assert_eq!( + get_parent_asset_deposited(), + Some((xcm_fees_account(), 20_000_000_000_000 - 4_000_000_000_000)) + ); + + // Should not be able to buy weight if the asset is not a first position + assert_eq!( + Trader::::new().buy_weight( + weight_to_buy, + vec![ + Asset { + fun: Fungibility::Fungible(10), + id: XcmAssetId(Location::here()), + }, + Asset { + fun: Fungibility::Fungible(30_000), + id: XcmAssetId(Location::parent()), + } + ] + .into(), + &dummy_xcm_context + ), + Err(XcmError::TooExpensive) + ); + }) +} + +#[test] +fn test_query_acceptable_payment_assets() { + new_test_ext().execute_with(|| { + // By default, only native asset should be supported + assert_eq!( + XcmWeightTrader::query_acceptable_payment_assets(4), + Ok(vec![VersionedAssetId::V4(XcmAssetId( + ::NativeLocation::get() + ))]) + ); + + // We should support XCMv3 + assert_eq!( + XcmWeightTrader::query_acceptable_payment_assets(3), + Ok(vec![VersionedAssetId::V4(XcmAssetId( + ::NativeLocation::get() + )) + .into_version(3) + .expect("native location should be convertible to v3")]) + ); + + // We should not support XCMv2 + assert_eq!( + XcmWeightTrader::query_acceptable_payment_assets(2), + Err(XcmPaymentApiError::UnhandledXcmVersion) + ); + + // Setup (add a supported asset) + assert_ok!(XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 500_000_000, + )); + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(500_000_000), + ); + + // We should support parent asset now + assert_eq!( + XcmWeightTrader::query_acceptable_payment_assets(4), + Ok(vec![ + VersionedAssetId::V4(XcmAssetId(::NativeLocation::get())), + VersionedAssetId::V4(XcmAssetId(Location::parent())) + ]) + ); + + // Setup: pause parent asset + assert_ok!(XcmWeightTrader::pause_asset_support( + RuntimeOrigin::signed(PauseAccount::get()), + Location::parent(), + )); + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + None + ); + + // We should not support paused assets + assert_eq!( + XcmWeightTrader::query_acceptable_payment_assets(4), + Ok(vec![VersionedAssetId::V4(XcmAssetId( + ::NativeLocation::get() + )),]) + ); + }) +} + +#[test] +fn test_query_weight_to_asset_fee() { + new_test_ext().execute_with(|| { + let native_asset = + VersionedAssetId::V4(XcmAssetId(::NativeLocation::get())); + let parent_asset = VersionedAssetId::V4(XcmAssetId(Location::parent())); + let weight_to_buy = Weight::from_parts(10_000, 0); + + // Native asset price should be 1:1 + assert_eq!( + XcmWeightTrader::query_weight_to_asset_fee(weight_to_buy, native_asset.clone()), + Ok(10_000) + ); + + // Should not be able to query fees for an unsupported asset + assert_eq!( + XcmWeightTrader::query_weight_to_asset_fee(weight_to_buy, parent_asset.clone()), + Err(XcmPaymentApiError::AssetNotFound) + ); + + // Setup (add a supported asset) + assert_ok!(XcmWeightTrader::add_asset( + RuntimeOrigin::signed(AddAccount::get()), + Location::parent(), + 500_000_000, + )); + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(500_000_000), + ); + + // Parent asset price should be 0.5 + assert_eq!( + XcmWeightTrader::query_weight_to_asset_fee(weight_to_buy, parent_asset.clone()), + Ok(2 * 10_000_000_000_000) + ); + + // Setup: pause parent asset + assert_ok!(XcmWeightTrader::pause_asset_support( + RuntimeOrigin::signed(PauseAccount::get()), + Location::parent(), + )); + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + None + ); + + // We should not support paused assets + assert_eq!( + XcmWeightTrader::query_weight_to_asset_fee(weight_to_buy, parent_asset.clone()), + Err(XcmPaymentApiError::AssetNotFound) + ); + + // Setup: unpause parent asset and edit price + assert_ok!(XcmWeightTrader::resume_asset_support( + RuntimeOrigin::signed(ResumeAccount::get()), + Location::parent(), + )); + assert_ok!(XcmWeightTrader::edit_asset( + RuntimeOrigin::signed(EditAccount::get()), + Location::parent(), + 2_000_000_000, + )); + assert_eq!( + XcmWeightTrader::get_asset_relative_price(&Location::parent()), + Some(2_000_000_000), + ); + + // We should support unpaused asset with new price + assert_eq!( + XcmWeightTrader::query_weight_to_asset_fee(weight_to_buy, parent_asset), + Ok(10_000_000_000_000 / 2) + ); + }) +} diff --git a/pallets/xcm-weight-trader/src/weights.rs b/pallets/xcm-weight-trader/src/weights.rs new file mode 100644 index 0000000000..972a946eba --- /dev/null +++ b/pallets/xcm-weight-trader/src/weights.rs @@ -0,0 +1,50 @@ +// Copyright 2024 Moonbeam foundation +// This file is part of Moonbeam. + +// Moonbeam is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Moonbeam is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Moonbeam. If not, see . + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use sp_std::marker::PhantomData; + +/// Weight functions needed for pallet_xcm_weight_trader +pub trait WeightInfo { + fn add_asset() -> Weight; + fn edit_asset() -> Weight; + fn pause_asset_support() -> Weight; + fn resume_asset_support() -> Weight; + fn remove_asset() -> Weight; +} + +// For tests only +impl WeightInfo for () { + fn add_asset() -> Weight { + Weight::default() + } + fn edit_asset() -> Weight { + Weight::default() + } + fn pause_asset_support() -> Weight { + Weight::default() + } + fn resume_asset_support() -> Weight { + Weight::default() + } + fn remove_asset() -> Weight { + Weight::default() + } +} \ No newline at end of file