pallet_xcm_weight_trader/
lib.rs

1// Copyright 2024 Moonbeam foundation
2// This file is part of Moonbeam.
3
4// Moonbeam is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// Moonbeam is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with Moonbeam.  If not, see <http://www.gnu.org/licenses/>.
16
17//! # A pallet to trade weight for XCM execution
18
19#![allow(non_camel_case_types)]
20#![cfg_attr(not(feature = "std"), no_std)]
21
22#[cfg(feature = "runtime-benchmarks")]
23mod benchmarking;
24#[cfg(test)]
25mod mock;
26#[cfg(test)]
27mod tests;
28
29pub mod weights;
30
31pub use pallet::*;
32pub use weights::WeightInfo;
33
34use frame_support::pallet_prelude::*;
35use frame_support::traits::Contains;
36use frame_support::weights::WeightToFee;
37use frame_support::{pallet, Deserialize, Serialize};
38use frame_system::pallet_prelude::*;
39use sp_runtime::{
40	traits::{Convert, Zero},
41	DispatchError,
42};
43use sp_std::{vec, vec::Vec};
44use xcm::v5::{Asset, AssetId as XcmAssetId, Error as XcmError, Fungibility, Location, XcmContext};
45use xcm::{IntoVersion, VersionedAssetId};
46use xcm_executor::traits::{TransactAsset, WeightTrader};
47use xcm_primitives::XcmFeeTrader;
48use xcm_runtime_apis::fees::Error as XcmPaymentApiError;
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct XcmWeightTraderAssetInfo {
52	pub location: Location,
53	pub relative_price: u128,
54}
55
56pub const RELATIVE_PRICE_DECIMALS: u32 = 18;
57
58#[pallet]
59pub mod pallet {
60	use super::*;
61
62	/// Pallet for multi block migrations
63	#[pallet::pallet]
64	pub struct Pallet<T>(PhantomData<T>);
65
66	/// Configuration trait of this pallet.
67	#[pallet::config]
68	pub trait Config: frame_system::Config<RuntimeEvent: From<Event<Self>>> {
69		/// Convert `T::AccountId` to `Location`.
70		type AccountIdToLocation: Convert<Self::AccountId, Location>;
71
72		/// Origin that is allowed to register a supported asset
73		type AddSupportedAssetOrigin: EnsureOrigin<Self::RuntimeOrigin>;
74
75		/// A filter to forbid some XCM Location to be supported for fees.
76		/// if you don't use it, put "Everything".
77		type AssetLocationFilter: Contains<Location>;
78
79		/// How to withdraw and deposit an asset.
80		type AssetTransactor: TransactAsset;
81
82		/// The native balance type.
83		type Balance: TryInto<u128>;
84
85		/// Origin that is allowed to edit a supported asset units per seconds
86		type EditSupportedAssetOrigin: EnsureOrigin<Self::RuntimeOrigin>;
87
88		/// XCM Location for native curreny
89		type NativeLocation: Get<Location>;
90
91		/// Origin that is allowed to pause a supported asset
92		type PauseSupportedAssetOrigin: EnsureOrigin<Self::RuntimeOrigin>;
93
94		/// Origin that is allowed to remove a supported asset
95		type RemoveSupportedAssetOrigin: EnsureOrigin<Self::RuntimeOrigin>;
96
97		/// Origin that is allowed to unpause a supported asset
98		type ResumeSupportedAssetOrigin: EnsureOrigin<Self::RuntimeOrigin>;
99
100		/// Weight information for extrinsics in this pallet.
101		type WeightInfo: WeightInfo;
102
103		/// Convert a weight value into deductible native balance.
104		type WeightToFee: WeightToFee<Balance = Self::Balance>;
105
106		/// Account that will receive xcm fees
107		type XcmFeesAccount: Get<Self::AccountId>;
108
109		/// The benchmarks need a location that pass the filter AssetLocationFilter
110		#[cfg(feature = "runtime-benchmarks")]
111		type NotFilteredLocation: Get<Location>;
112	}
113
114	/// Stores all supported assets per XCM Location.
115	/// The u128 is the asset price relative to native asset with 18 decimals
116	/// The boolean specify if the support for this asset is active
117	#[pallet::storage]
118	#[pallet::getter(fn supported_assets)]
119	pub type SupportedAssets<T: Config> = StorageMap<_, Blake2_128Concat, Location, (bool, u128)>;
120
121	#[pallet::error]
122	pub enum Error<T> {
123		/// The given asset was already added
124		AssetAlreadyAdded,
125		/// The given asset was already paused
126		AssetAlreadyPaused,
127		/// The given asset was not found
128		AssetNotFound,
129		/// The given asset is not paused
130		AssetNotPaused,
131		/// XCM location filtered
132		XcmLocationFiltered,
133		/// The relative price cannot be zero
134		PriceCannotBeZero,
135		/// The relative price calculation overflowed
136		PriceOverflow,
137	}
138
139	#[pallet::event]
140	#[pallet::generate_deposit(pub(crate) fn deposit_event)]
141	pub enum Event<T: Config> {
142		/// New supported asset is registered
143		SupportedAssetAdded {
144			location: Location,
145			relative_price: u128,
146		},
147		/// Changed the amount of units we are charging per execution second for a given asset
148		SupportedAssetEdited {
149			location: Location,
150			relative_price: u128,
151		},
152		/// Pause support for a given asset
153		PauseAssetSupport { location: Location },
154		/// Resume support for a given asset
155		ResumeAssetSupport { location: Location },
156		/// Supported asset type for fee payment removed
157		SupportedAssetRemoved { location: Location },
158	}
159
160	#[pallet::genesis_config]
161	pub struct GenesisConfig<T: Config> {
162		pub assets: Vec<XcmWeightTraderAssetInfo>,
163		pub _phantom: PhantomData<T>,
164	}
165
166	impl<T: Config> Default for GenesisConfig<T> {
167		fn default() -> Self {
168			Self {
169				assets: vec![],
170				_phantom: Default::default(),
171			}
172		}
173	}
174
175	#[pallet::genesis_build]
176	impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
177		fn build(&self) {
178			for asset in self.assets.clone() {
179				Pallet::<T>::do_add_asset(asset.location, asset.relative_price)
180					.expect("couldn't add asset");
181			}
182		}
183	}
184
185	#[pallet::call]
186	impl<T: Config> Pallet<T> {
187		#[pallet::call_index(0)]
188		#[pallet::weight(T::WeightInfo::add_asset())]
189		pub fn add_asset(
190			origin: OriginFor<T>,
191			location: Location,
192			relative_price: u128,
193		) -> DispatchResult {
194			T::AddSupportedAssetOrigin::ensure_origin(origin)?;
195
196			Self::do_add_asset(location, relative_price)
197		}
198
199		#[pallet::call_index(1)]
200		#[pallet::weight(<T as pallet::Config>::WeightInfo::edit_asset())]
201		pub fn edit_asset(
202			origin: OriginFor<T>,
203			location: Location,
204			relative_price: u128,
205		) -> DispatchResult {
206			T::EditSupportedAssetOrigin::ensure_origin(origin)?;
207
208			ensure!(relative_price != 0, Error::<T>::PriceCannotBeZero);
209
210			let enabled = SupportedAssets::<T>::get(&location)
211				.ok_or(Error::<T>::AssetNotFound)?
212				.0;
213
214			SupportedAssets::<T>::insert(&location, (enabled, relative_price));
215
216			Self::deposit_event(Event::SupportedAssetEdited {
217				location,
218				relative_price,
219			});
220
221			Ok(())
222		}
223
224		#[pallet::call_index(2)]
225		#[pallet::weight(<T as pallet::Config>::WeightInfo::pause_asset_support())]
226		pub fn pause_asset_support(origin: OriginFor<T>, location: Location) -> DispatchResult {
227			T::PauseSupportedAssetOrigin::ensure_origin(origin)?;
228
229			match SupportedAssets::<T>::get(&location) {
230				Some((true, relative_price)) => {
231					SupportedAssets::<T>::insert(&location, (false, relative_price));
232					Self::deposit_event(Event::PauseAssetSupport { location });
233					Ok(())
234				}
235				Some((false, _)) => Err(Error::<T>::AssetAlreadyPaused.into()),
236				None => Err(Error::<T>::AssetNotFound.into()),
237			}
238		}
239
240		#[pallet::call_index(3)]
241		#[pallet::weight(<T as pallet::Config>::WeightInfo::resume_asset_support())]
242		pub fn resume_asset_support(origin: OriginFor<T>, location: Location) -> DispatchResult {
243			T::ResumeSupportedAssetOrigin::ensure_origin(origin)?;
244
245			match SupportedAssets::<T>::get(&location) {
246				Some((false, relative_price)) => {
247					SupportedAssets::<T>::insert(&location, (true, relative_price));
248					Self::deposit_event(Event::ResumeAssetSupport { location });
249					Ok(())
250				}
251				Some((true, _)) => Err(Error::<T>::AssetNotPaused.into()),
252				None => Err(Error::<T>::AssetNotFound.into()),
253			}
254		}
255
256		#[pallet::call_index(4)]
257		#[pallet::weight(<T as pallet::Config>::WeightInfo::remove_asset())]
258		pub fn remove_asset(origin: OriginFor<T>, location: Location) -> DispatchResult {
259			T::RemoveSupportedAssetOrigin::ensure_origin(origin)?;
260
261			Self::do_remove_asset(location)
262		}
263	}
264
265	impl<T: Config> Pallet<T> {
266		pub fn do_add_asset(location: Location, relative_price: u128) -> DispatchResult {
267			ensure!(relative_price != 0, Error::<T>::PriceCannotBeZero);
268			ensure!(
269				!SupportedAssets::<T>::contains_key(&location),
270				Error::<T>::AssetAlreadyAdded
271			);
272			ensure!(
273				T::AssetLocationFilter::contains(&location),
274				Error::<T>::XcmLocationFiltered
275			);
276
277			SupportedAssets::<T>::insert(&location, (true, relative_price));
278
279			Self::deposit_event(Event::SupportedAssetAdded {
280				location,
281				relative_price,
282			});
283
284			Ok(())
285		}
286
287		pub fn do_remove_asset(location: Location) -> DispatchResult {
288			ensure!(
289				SupportedAssets::<T>::contains_key(&location),
290				Error::<T>::AssetNotFound
291			);
292
293			SupportedAssets::<T>::remove(&location);
294
295			Self::deposit_event(Event::SupportedAssetRemoved { location });
296
297			Ok(())
298		}
299
300		pub fn get_asset_relative_price(location: &Location) -> Option<u128> {
301			if let Some((true, ratio)) = SupportedAssets::<T>::get(location) {
302				Some(ratio)
303			} else {
304				None
305			}
306		}
307		pub fn query_acceptable_payment_assets(
308			xcm_version: xcm::Version,
309		) -> Result<Vec<VersionedAssetId>, XcmPaymentApiError> {
310			let v5_assets = [VersionedAssetId::from(XcmAssetId::from(
311				T::NativeLocation::get(),
312			))]
313			.into_iter()
314			.chain(
315				SupportedAssets::<T>::iter().filter_map(|(asset_location, (enabled, _))| {
316					enabled.then(|| VersionedAssetId::from(XcmAssetId(asset_location)))
317				}),
318			)
319			.collect::<Vec<_>>();
320
321			match xcm_version {
322				xcm::v3::VERSION => v5_assets
323					.into_iter()
324					.map(|v5_asset| v5_asset.into_version(xcm::v3::VERSION))
325					.collect::<Result<_, _>>()
326					.map_err(|_| XcmPaymentApiError::VersionedConversionFailed),
327				xcm::v4::VERSION => v5_assets
328					.into_iter()
329					.map(|v5_asset| v5_asset.into_version(xcm::v4::VERSION))
330					.collect::<Result<_, _>>()
331					.map_err(|_| XcmPaymentApiError::VersionedConversionFailed),
332				xcm::v5::VERSION => Ok(v5_assets),
333				_ => Err(XcmPaymentApiError::UnhandledXcmVersion),
334			}
335		}
336		pub fn query_weight_to_asset_fee(
337			weight: Weight,
338			asset: VersionedAssetId,
339		) -> Result<u128, XcmPaymentApiError> {
340			if let VersionedAssetId::V5(XcmAssetId(asset_location)) = asset
341				.into_version(xcm::latest::VERSION)
342				.map_err(|_| XcmPaymentApiError::VersionedConversionFailed)?
343			{
344				Trader::<T>::compute_amount_to_charge(&weight, &asset_location).map_err(|e| match e
345				{
346					XcmError::AssetNotFound => XcmPaymentApiError::AssetNotFound,
347					_ => XcmPaymentApiError::WeightNotComputable,
348				})
349			} else {
350				Err(XcmPaymentApiError::UnhandledXcmVersion)
351			}
352		}
353		pub fn set_asset_price(asset_location: Location, relative_price: u128) {
354			SupportedAssets::<T>::insert(&asset_location, (true, relative_price));
355		}
356	}
357}
358
359pub struct Trader<T: crate::Config>(Weight, Option<Asset>, core::marker::PhantomData<T>);
360
361impl<T: crate::Config> Trader<T> {
362	pub(crate) fn compute_amount_to_charge(
363		weight: &Weight,
364		asset_location: &Location,
365	) -> Result<u128, XcmError> {
366		if *asset_location == <T as crate::Config>::NativeLocation::get() {
367			<T as crate::Config>::WeightToFee::weight_to_fee(&weight)
368				.try_into()
369				.map_err(|_| XcmError::Overflow)
370		} else if let Some(relative_price) = Pallet::<T>::get_asset_relative_price(asset_location) {
371			if relative_price == 0u128 {
372				Ok(0u128)
373			} else {
374				let native_amount: u128 = <T as crate::Config>::WeightToFee::weight_to_fee(&weight)
375					.try_into()
376					.map_err(|_| XcmError::Overflow)?;
377				Ok(native_amount
378					.checked_mul(10u128.pow(RELATIVE_PRICE_DECIMALS))
379					.ok_or(XcmError::Overflow)?
380					.checked_div(relative_price)
381					.ok_or(XcmError::Overflow)?)
382			}
383		} else {
384			Err(XcmError::AssetNotFound)
385		}
386	}
387}
388
389impl<T: crate::Config> WeightTrader for Trader<T> {
390	fn new() -> Self {
391		Self(Weight::zero(), None, PhantomData)
392	}
393	fn buy_weight(
394		&mut self,
395		weight: Weight,
396		payment: xcm_executor::AssetsInHolding,
397		context: &XcmContext,
398	) -> Result<xcm_executor::AssetsInHolding, XcmError> {
399		log::trace!(
400			target: "xcm::weight",
401			"UsingComponents::buy_weight weight: {:?}, payment: {:?}, context: {:?}",
402			weight,
403			payment,
404			context
405		);
406
407		// Can only call one time
408		if self.1.is_some() {
409			return Err(XcmError::NotWithdrawable);
410		}
411
412		// Consistency check for tests only, we should never panic in release mode
413		debug_assert_eq!(self.0, Weight::zero());
414
415		// We support only one fee asset per buy, so we take the first one.
416		let first_asset = payment
417			.clone()
418			.fungible_assets_iter()
419			.next()
420			.ok_or(XcmError::AssetNotFound)?;
421
422		match (first_asset.id, first_asset.fun) {
423			(XcmAssetId(location), Fungibility::Fungible(_)) => {
424				let amount: u128 = Self::compute_amount_to_charge(&weight, &location)?;
425
426				// We don't need to proceed if the amount is 0
427				// For cases (specially tests) where the asset is very cheap with respect
428				// to the weight needed
429				if amount.is_zero() {
430					return Ok(payment);
431				}
432
433				let required = Asset {
434					fun: Fungibility::Fungible(amount),
435					id: XcmAssetId(location),
436				};
437				let unused = payment
438					.checked_sub(required.clone())
439					.map_err(|_| XcmError::TooExpensive)?;
440
441				self.0 = weight;
442				self.1 = Some(required);
443
444				Ok(unused)
445			}
446			_ => Err(XcmError::AssetNotFound),
447		}
448	}
449
450	fn refund_weight(&mut self, actual_weight: Weight, context: &XcmContext) -> Option<Asset> {
451		log::trace!(
452			target: "xcm-weight-trader",
453			"refund_weight weight: {:?}, context: {:?}, available weight: {:?}, asset: {:?}",
454			actual_weight,
455			context,
456			self.0,
457			self.1
458		);
459		if let Some(Asset {
460			fun: Fungibility::Fungible(initial_amount),
461			id: XcmAssetId(location),
462		}) = self.1.take()
463		{
464			if actual_weight == self.0 {
465				self.1 = Some(Asset {
466					fun: Fungibility::Fungible(initial_amount),
467					id: XcmAssetId(location),
468				});
469				None
470			} else {
471				let weight = actual_weight.min(self.0);
472				let amount: u128 =
473					Self::compute_amount_to_charge(&weight, &location).unwrap_or(u128::MAX);
474				let final_amount = amount.min(initial_amount);
475				let amount_to_refund = initial_amount.saturating_sub(final_amount);
476				self.0 -= weight;
477				self.1 = Some(Asset {
478					fun: Fungibility::Fungible(final_amount),
479					id: XcmAssetId(location.clone()),
480				});
481				log::trace!(
482					target: "xcm-weight-trader",
483					"refund_weight amount to refund: {:?}",
484					amount_to_refund
485				);
486				Some(Asset {
487					fun: Fungibility::Fungible(amount_to_refund),
488					id: XcmAssetId(location),
489				})
490			}
491		} else {
492			None
493		}
494	}
495}
496
497impl<T: crate::Config> Drop for Trader<T> {
498	fn drop(&mut self) {
499		log::trace!(
500			target: "xcm-weight-trader",
501			"Dropping `Trader` instance: (weight: {:?}, asset: {:?})",
502			&self.0,
503			&self.1
504		);
505		if let Some(asset) = self.1.take() {
506			let res = T::AssetTransactor::deposit_asset(
507				&asset,
508				&T::AccountIdToLocation::convert(T::XcmFeesAccount::get()),
509				None,
510			);
511			debug_assert!(res.is_ok());
512		}
513	}
514}
515
516/// Helper function to compute fee amount from weight and asset location.
517/// This is used by the XcmTransactorFeeTrader adapter implementation.
518pub fn compute_fee_amount<T: Config>(
519	weight: Weight,
520	asset_location: &Location,
521) -> Result<u128, xcm::v5::Error> {
522	Trader::<T>::compute_amount_to_charge(&weight, asset_location)
523}
524
525/// Implementation of XcmFeeTrader for pallet-xcm-weight-trader.
526/// This allows the pallet to be used directly as a fee trader.
527impl<T: Config> XcmFeeTrader for Pallet<T> {
528	fn compute_fee(
529		weight: frame_support::weights::Weight,
530		asset_location: &xcm::latest::Location,
531	) -> Result<u128, DispatchError> {
532		use xcm::v5::Error as XcmError;
533
534		// Convert xcm::latest::Location to xcm::v5::Location for internal computation
535		let asset_location_v5 = xcm::v5::Location::try_from(asset_location.clone())
536			.map_err(|_| DispatchError::Other("Failed to convert location"))?;
537
538		// Use the weight-trader's compute logic
539		let amount = Trader::<T>::compute_amount_to_charge(&weight, &asset_location_v5).map_err(
540			|e| match e {
541				XcmError::AssetNotFound => DispatchError::Other("Asset not found"),
542				XcmError::Overflow => DispatchError::Other("Overflow"),
543				_ => DispatchError::Other("Unable to compute fee"),
544			},
545		)?;
546
547		// Note: Reserve validation is done at the pallet-xcm-transactor level,
548		// as it requires access to the ReserveProvider which is configured there.
549		// This implementation just computes the fee amount.
550
551		Ok(amount)
552	}
553
554	fn get_asset_price(asset_location: &xcm::latest::Location) -> Option<u128> {
555		// Convert xcm::latest::Location to xcm::v5::Location for storage lookup
556		let asset_location_v5 = xcm::v5::Location::try_from(asset_location.clone()).ok()?;
557
558		// Return the relative_price if the asset is enabled
559		if let Some((true, relative_price)) = SupportedAssets::<T>::get(&asset_location_v5) {
560			Some(relative_price)
561		} else {
562			None
563		}
564	}
565
566	fn set_asset_price(
567		asset_location: xcm::latest::Location,
568		value: u128,
569	) -> Result<(), DispatchError> {
570		// Convert latest location into v5 for internal storage
571		let asset_location_v5 = xcm::v5::Location::try_from(asset_location)
572			.map_err(|_| DispatchError::Other("Invalid location"))?;
573
574		Pallet::<T>::set_asset_price(asset_location_v5, value);
575		Ok(())
576	}
577
578	fn remove_asset(asset_location: xcm::latest::Location) -> Result<(), DispatchError> {
579		// Convert latest location into v5 for internal storage
580		let asset_location_v5 = xcm::v5::Location::try_from(asset_location)
581			.map_err(|_| DispatchError::Other("Invalid location"))?;
582
583		Pallet::<T>::do_remove_asset(asset_location_v5)
584	}
585}