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, weight_to_refund: Weight, context: &XcmContext) -> Option<Asset> {
451		log::trace!(
452			target: "xcm-weight-trader",
453			"refund_weight weight: {:?}, context: {:?}, available weight: {:?}, asset: {:?}",
454			weight_to_refund,
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			let weight_to_refund = weight_to_refund.min(self.0);
465			// `xcm-executor` passes the *surplus* weight to refund here (not the weight used).
466			// We therefore refund the proportional amount that was originally charged for
467			// `weight_to_refund`, and keep the remainder to be deposited to the fees account.
468			let computed_refund_amount: u128 =
469				Self::compute_amount_to_charge(&weight_to_refund, &location).unwrap_or(u128::MAX);
470			let refund_amount = computed_refund_amount.min(initial_amount);
471			let final_amount = initial_amount.saturating_sub(refund_amount);
472			self.0 -= weight_to_refund;
473			self.1 = Some(Asset {
474				fun: Fungibility::Fungible(final_amount),
475				id: XcmAssetId(location.clone()),
476			});
477			log::trace!(
478				target: "xcm-weight-trader",
479				"refund_weight amount to refund: {:?}",
480				refund_amount
481			);
482			if refund_amount > 0 {
483				Some(Asset {
484					fun: Fungibility::Fungible(refund_amount),
485					id: XcmAssetId(location),
486				})
487			} else {
488				None
489			}
490		} else {
491			None
492		}
493	}
494}
495
496impl<T: crate::Config> Drop for Trader<T> {
497	fn drop(&mut self) {
498		log::trace!(
499			target: "xcm-weight-trader",
500			"Dropping `Trader` instance: (weight: {:?}, asset: {:?})",
501			&self.0,
502			&self.1
503		);
504		if let Some(asset) = self.1.take() {
505			let res = T::AssetTransactor::deposit_asset(
506				&asset,
507				&T::AccountIdToLocation::convert(T::XcmFeesAccount::get()),
508				None,
509			);
510			debug_assert!(res.is_ok());
511		}
512	}
513}
514
515/// Helper function to compute fee amount from weight and asset location.
516/// This is used by the XcmTransactorFeeTrader adapter implementation.
517pub fn compute_fee_amount<T: Config>(
518	weight: Weight,
519	asset_location: &Location,
520) -> Result<u128, xcm::v5::Error> {
521	Trader::<T>::compute_amount_to_charge(&weight, asset_location)
522}
523
524/// Implementation of XcmFeeTrader for pallet-xcm-weight-trader.
525/// This allows the pallet to be used directly as a fee trader.
526impl<T: Config> XcmFeeTrader for Pallet<T> {
527	fn compute_fee(
528		weight: frame_support::weights::Weight,
529		asset_location: &xcm::latest::Location,
530	) -> Result<u128, DispatchError> {
531		use xcm::v5::Error as XcmError;
532
533		// Convert xcm::latest::Location to xcm::v5::Location for internal computation
534		let asset_location_v5 = xcm::v5::Location::try_from(asset_location.clone())
535			.map_err(|_| DispatchError::Other("Failed to convert location"))?;
536
537		// Use the weight-trader's compute logic
538		let amount = Trader::<T>::compute_amount_to_charge(&weight, &asset_location_v5).map_err(
539			|e| match e {
540				XcmError::AssetNotFound => DispatchError::Other("Asset not found"),
541				XcmError::Overflow => DispatchError::Other("Overflow"),
542				_ => DispatchError::Other("Unable to compute fee"),
543			},
544		)?;
545
546		// Note: Reserve validation is done at the pallet-xcm-transactor level,
547		// as it requires access to the ReserveProvider which is configured there.
548		// This implementation just computes the fee amount.
549
550		Ok(amount)
551	}
552
553	fn get_asset_price(asset_location: &xcm::latest::Location) -> Option<u128> {
554		// Convert xcm::latest::Location to xcm::v5::Location for storage lookup
555		let asset_location_v5 = xcm::v5::Location::try_from(asset_location.clone()).ok()?;
556
557		// Return the relative_price if the asset is enabled
558		if let Some((true, relative_price)) = SupportedAssets::<T>::get(&asset_location_v5) {
559			Some(relative_price)
560		} else {
561			None
562		}
563	}
564
565	fn set_asset_price(
566		asset_location: xcm::latest::Location,
567		value: u128,
568	) -> Result<(), DispatchError> {
569		// Convert latest location into v5 for internal storage
570		let asset_location_v5 = xcm::v5::Location::try_from(asset_location)
571			.map_err(|_| DispatchError::Other("Invalid location"))?;
572
573		Pallet::<T>::set_asset_price(asset_location_v5, value);
574		Ok(())
575	}
576
577	fn remove_asset(asset_location: xcm::latest::Location) -> Result<(), DispatchError> {
578		// Convert latest location into v5 for internal storage
579		let asset_location_v5 = xcm::v5::Location::try_from(asset_location)
580			.map_err(|_| DispatchError::Other("Invalid location"))?;
581
582		Pallet::<T>::do_remove_asset(asset_location_v5)
583	}
584}