referrerpolicy=no-referrer-when-downgrade

pallet_asset_conversion_precompiles/
lib.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Precompile exposing `pallet-asset-conversion` (Asset Hub DEX) to Solidity contracts.
19//!
20//! Allows smart contracts to swap tokens through Asset Hub's on-chain DEX and query
21//! swap prices. The primary use case is contracts that accept payment in one asset
22//! (e.g. USDC) and convert it to DOT or PUSD before using it.
23//!
24//! Assets are identified by their SCALE-encoded `AssetKind` passed as `bytes`.
25
26#![cfg_attr(not(feature = "std"), no_std)]
27
28extern crate alloc;
29
30use alloc::vec::Vec;
31use codec::Decode;
32use core::marker::PhantomData;
33use frame_support::traits::Get;
34use pallet_asset_conversion::{
35	weights::WeightInfo as _, AddLiquidityAsset, MutateLiquidity, QuotePrice, Swap,
36};
37use pallet_revive::precompiles::{
38	alloy::{
39		self,
40		sol_types::{Revert, SolCall},
41	},
42	AddressMatcher, Error, Ext, Precompile, H160,
43};
44
45#[cfg(test)]
46mod mock;
47#[cfg(test)]
48mod tests;
49
50alloy::sol! {
51	/// Precompile interface for asset-conversion (DEX) operations.
52	///
53	/// Assets are identified by their SCALE-encoded AssetKind (e.g. xcm::v5::Location)
54	/// passed as `bytes`. Contracts can hardcode these as constants or obtain them
55	/// off-chain.
56	interface IAssetConversion {
57		/// Swap an exact amount of input tokens for as many output tokens as possible.
58		/// @param path Ordered list of SCALE-encoded asset identifiers defining the swap route.
59		/// @param amountIn Exact amount of the first asset to swap.
60		/// @param amountOutMin Minimum acceptable amount of the last asset to receive.
61		/// @param sendTo Address to receive the output tokens.
62		/// @param keepAlive If true, ensures the sender account stays above existential deposit.
63		/// @return amountOut The amount of output tokens received.
64		function swapExactTokensForTokens(
65			bytes[] calldata path,
66			uint256 amountIn,
67			uint256 amountOutMin,
68			address sendTo,
69			bool keepAlive
70		) external returns (uint256 amountOut);
71
72		/// Swap tokens to receive an exact amount of output tokens.
73		/// @param path Ordered list of SCALE-encoded asset identifiers defining the swap route.
74		/// @param amountOut Exact amount of the last asset to receive.
75		/// @param amountInMax Maximum acceptable amount of the first asset to spend.
76		/// @param sendTo Address to receive the output tokens.
77		/// @param keepAlive If true, ensures the sender account stays above existential deposit.
78		/// @return amountIn The amount of input tokens spent.
79		function swapTokensForExactTokens(
80			bytes[] calldata path,
81			uint256 amountOut,
82			uint256 amountInMax,
83			address sendTo,
84			bool keepAlive
85		) external returns (uint256 amountIn);
86
87		/// Quote the expected output for a given exact input swap.
88		/// @param asset1 SCALE-encoded identifier of the input asset.
89		/// @param asset2 SCALE-encoded identifier of the output asset.
90		/// @param amount The input amount to quote for.
91		/// @param includeFee Whether to include the pool's LP fee in the quote.
92		/// @return The expected output amount.
93		function quoteExactTokensForTokens(
94			bytes calldata asset1,
95			bytes calldata asset2,
96			uint256 amount,
97			bool includeFee
98		) external view returns (uint256);
99
100		/// Quote the required input for a given exact output swap.
101		/// @param asset1 SCALE-encoded identifier of the input asset.
102		/// @param asset2 SCALE-encoded identifier of the output asset.
103		/// @param amount The desired output amount to quote for.
104		/// @param includeFee Whether to include the pool's LP fee in the quote.
105		/// @return The required input amount.
106		function quoteTokensForExactTokens(
107			bytes calldata asset1,
108			bytes calldata asset2,
109			uint256 amount,
110			bool includeFee
111		) external view returns (uint256);
112
113		/// Create an empty liquidity pool for the given asset pair.
114		/// @param asset1 SCALE-encoded identifier of the first asset.
115		/// @param asset2 SCALE-encoded identifier of the second asset.
116		function createPool(
117			bytes calldata asset1,
118			bytes calldata asset2
119		) external;
120
121		/// Add liquidity to an existing pool.
122		/// @param asset1 SCALE-encoded identifier of the first asset.
123		/// @param asset2 SCALE-encoded identifier of the second asset.
124		/// @param amount1Desired Desired amount of the first asset to add.
125		/// @param amount2Desired Desired amount of the second asset to add.
126		/// @param amount1Min Minimum acceptable amount of the first asset.
127		/// @param amount2Min Minimum acceptable amount of the second asset.
128		/// @param mintTo Address to receive the LP tokens.
129		/// @return lpTokensMinted The amount of LP tokens minted.
130		function addLiquidity(
131			bytes calldata asset1,
132			bytes calldata asset2,
133			uint256 amount1Desired,
134			uint256 amount2Desired,
135			uint256 amount1Min,
136			uint256 amount2Min,
137			address mintTo
138		) external returns (uint256 lpTokensMinted);
139
140		/// Remove liquidity from a pool.
141		/// @param asset1 SCALE-encoded identifier of the first asset.
142		/// @param asset2 SCALE-encoded identifier of the second asset.
143		/// @param lpTokenBurn Amount of LP tokens to burn.
144		/// @param amount1MinReceive Minimum amount of the first asset to receive.
145		/// @param amount2MinReceive Minimum amount of the second asset to receive.
146		/// @param withdrawTo Address to receive the withdrawn assets.
147		/// @return amount1 The amount of the first asset withdrawn.
148		/// @return amount2 The amount of the second asset withdrawn.
149		function removeLiquidity(
150			bytes calldata asset1,
151			bytes calldata asset2,
152			uint256 lpTokenBurn,
153			uint256 amount1MinReceive,
154			uint256 amount2MinReceive,
155			address withdrawTo
156		) external returns (uint256 amount1, uint256 amount2);
157
158		/// Get the reserves (token balances) of a liquidity pool.
159		/// @param asset1 SCALE-encoded identifier of the first asset.
160		/// @param asset2 SCALE-encoded identifier of the second asset.
161		/// @return reserve1 The balance of asset1 in the pool.
162		/// @return reserve2 The balance of asset2 in the pool.
163		function getReserves(
164			bytes calldata asset1,
165			bytes calldata asset2
166		) external view returns (uint256 reserve1, uint256 reserve2);
167	}
168}
169
170/// Asset conversion precompile exposing DEX swap and quote operations.
171///
172/// `ADDRESS` is the `u16` identifier embedded at bytes [16..18] of the precompile's H160 address.
173pub struct AssetConversion<const ADDRESS: u16, Runtime> {
174	_phantom: PhantomData<Runtime>,
175}
176
177impl<const ADDRESS: u16, Runtime> Precompile for AssetConversion<ADDRESS, Runtime>
178where
179	Runtime: pallet_asset_conversion::Config + pallet_revive::Config,
180	alloy::primitives::U256: TryInto<<Runtime as pallet_asset_conversion::Config>::Balance>,
181	alloy::primitives::U256: TryFrom<<Runtime as pallet_asset_conversion::Config>::Balance>,
182{
183	type T = Runtime;
184	type Interface = IAssetConversion::IAssetConversionCalls;
185	const MATCHER: AddressMatcher =
186		AddressMatcher::Fixed(core::num::NonZero::new(ADDRESS).unwrap());
187	const HAS_CONTRACT_INFO: bool = false;
188
189	fn call(
190		_address: &[u8; 20],
191		input: &Self::Interface,
192		env: &mut impl Ext<T = Self::T>,
193	) -> Result<Vec<u8>, Error> {
194		use IAssetConversion::IAssetConversionCalls;
195
196		frame_support::ensure!(
197			!env.is_delegate_call(),
198			pallet_revive::Error::<Self::T>::PrecompileDelegateDenied,
199		);
200
201		match input {
202			IAssetConversionCalls::swapExactTokensForTokens(_) |
203			IAssetConversionCalls::swapTokensForExactTokens(_) |
204			IAssetConversionCalls::createPool(_) |
205			IAssetConversionCalls::addLiquidity(_) |
206			IAssetConversionCalls::removeLiquidity(_)
207				if env.is_read_only() =>
208			{
209				Err(Error::Error(pallet_revive::Error::<Self::T>::StateChangeDenied.into()))
210			},
211			IAssetConversionCalls::swapExactTokensForTokens(call) => {
212				Self::swap_exact_tokens_for_tokens(call, env)
213			},
214			IAssetConversionCalls::swapTokensForExactTokens(call) => {
215				Self::swap_tokens_for_exact_tokens(call, env)
216			},
217			IAssetConversionCalls::quoteExactTokensForTokens(call) => {
218				Self::quote_exact_tokens_for_tokens(call, env)
219			},
220			IAssetConversionCalls::quoteTokensForExactTokens(call) => {
221				Self::quote_tokens_for_exact_tokens(call, env)
222			},
223			IAssetConversionCalls::createPool(call) => Self::create_pool(call, env),
224			IAssetConversionCalls::addLiquidity(call) => Self::add_liquidity(call, env),
225			IAssetConversionCalls::removeLiquidity(call) => Self::remove_liquidity(call, env),
226			IAssetConversionCalls::getReserves(call) => Self::get_reserves(call, env),
227		}
228	}
229}
230
231const ERR_INVALID_CALLER: &str = "Invalid caller";
232const ERR_BALANCE_CONVERSION_FAILED: &str = "Balance conversion failed";
233const ERR_INVALID_ASSET_PAIR: &str = "Invalid asset pair";
234const ERR_POOL_NOT_FOUND: &str = "Pool does not exist or has no liquidity";
235const ERR_POOL_EMPTY: &str = "Pool exists but has no liquidity";
236const ERR_UNEXPECTED: &str = "Unexpected error";
237const ERR_PATH_TOO_LONG: &str = "Swap path exceeds MaxSwapPathLength";
238const ERR_INVALID_ASSET_ENCODING: &str = "Failed to SCALE-decode asset kind";
239
240impl<const ADDRESS: u16, Runtime> AssetConversion<ADDRESS, Runtime>
241where
242	Runtime: pallet_asset_conversion::Config + pallet_revive::Config,
243	alloy::primitives::U256: TryInto<<Runtime as pallet_asset_conversion::Config>::Balance>,
244	alloy::primitives::U256: TryFrom<<Runtime as pallet_asset_conversion::Config>::Balance>,
245{
246	/// Returns the caller's account ID.
247	fn caller_account_id(
248		env: &impl Ext<T = Runtime>,
249	) -> Result<<Runtime as frame_system::Config>::AccountId, Error> {
250		env.caller()
251			.account_id()
252			.map_err(|_| Error::Revert(Revert { reason: ERR_INVALID_CALLER.into() }))
253			.cloned()
254	}
255
256	/// SCALE-decode a single asset kind from raw bytes.
257	fn decode_asset_kind(
258		data: &[u8],
259	) -> Result<<Runtime as pallet_asset_conversion::Config>::AssetKind, Error> {
260		<Runtime as pallet_asset_conversion::Config>::AssetKind::decode(&mut &data[..])
261			.map_err(|_| Error::Revert(Revert { reason: ERR_INVALID_ASSET_ENCODING.into() }))
262	}
263
264	/// Validates that the path length does not exceed `MaxSwapPathLength` and returns it as u32.
265	fn validated_path_len<T>(path: &[T]) -> Result<u32, Error> {
266		let len = path.len() as u32;
267		let max = <Runtime as pallet_asset_conversion::Config>::MaxSwapPathLength::get();
268		if len > max {
269			return Err(Error::Revert(Revert { reason: ERR_PATH_TOO_LONG.into() }));
270		}
271		Ok(len)
272	}
273
274	fn to_balance(
275		value: alloy::primitives::U256,
276	) -> Result<<Runtime as pallet_asset_conversion::Config>::Balance, Error> {
277		value
278			.try_into()
279			.map_err(|_| Error::Revert(Revert { reason: ERR_BALANCE_CONVERSION_FAILED.into() }))
280	}
281
282	fn to_u256(
283		value: <Runtime as pallet_asset_conversion::Config>::Balance,
284	) -> Result<alloy::primitives::U256, Error> {
285		alloy::primitives::U256::try_from(value)
286			.map_err(|_| Error::Revert(Revert { reason: ERR_BALANCE_CONVERSION_FAILED.into() }))
287	}
288
289	fn swap_exact_tokens_for_tokens(
290		call: &IAssetConversion::swapExactTokensForTokensCall,
291		env: &mut impl Ext<T = Runtime>,
292	) -> Result<Vec<u8>, Error> {
293		let path_len = Self::validated_path_len(&call.path)?;
294		env.charge(
295			<Runtime as pallet_asset_conversion::Config>::WeightInfo::swap_exact_tokens_for_tokens(
296				path_len,
297			),
298		)?;
299		let path: Vec<_> =
300			call.path.iter().map(|e| Self::decode_asset_kind(e)).collect::<Result<_, _>>()?;
301
302		let sender = Self::caller_account_id(env)?;
303		let send_to = env.to_account_id(&H160(call.sendTo.0 .0));
304
305		let amount_out = <pallet_asset_conversion::Pallet<Runtime> as Swap<
306			<Runtime as frame_system::Config>::AccountId,
307		>>::swap_exact_tokens_for_tokens(
308			sender,
309			path,
310			Self::to_balance(call.amountIn)?,
311			Some(Self::to_balance(call.amountOutMin)?),
312			send_to,
313			call.keepAlive,
314		)?;
315
316		Ok(IAssetConversion::swapExactTokensForTokensCall::abi_encode_returns(&Self::to_u256(
317			amount_out,
318		)?))
319	}
320
321	fn swap_tokens_for_exact_tokens(
322		call: &IAssetConversion::swapTokensForExactTokensCall,
323		env: &mut impl Ext<T = Runtime>,
324	) -> Result<Vec<u8>, Error> {
325		let path_len = Self::validated_path_len(&call.path)?;
326		env.charge(
327			<Runtime as pallet_asset_conversion::Config>::WeightInfo::swap_tokens_for_exact_tokens(
328				path_len,
329			),
330		)?;
331		let path: Vec<_> =
332			call.path.iter().map(|e| Self::decode_asset_kind(e)).collect::<Result<_, _>>()?;
333
334		let sender = Self::caller_account_id(env)?;
335		let send_to = env.to_account_id(&H160(call.sendTo.0 .0));
336
337		let amount_in = <pallet_asset_conversion::Pallet<Runtime> as Swap<
338			<Runtime as frame_system::Config>::AccountId,
339		>>::swap_tokens_for_exact_tokens(
340			sender,
341			path,
342			Self::to_balance(call.amountOut)?,
343			Some(Self::to_balance(call.amountInMax)?),
344			send_to,
345			call.keepAlive,
346		)?;
347
348		Ok(IAssetConversion::swapTokensForExactTokensCall::abi_encode_returns(&Self::to_u256(
349			amount_in,
350		)?))
351	}
352
353	fn quote_exact_tokens_for_tokens(
354		call: &IAssetConversion::quoteExactTokensForTokensCall,
355		env: &mut impl Ext<T = Runtime>,
356	) -> Result<Vec<u8>, Error> {
357		// Quote is always a single-pair operation (the Solidity interface takes two assets,
358		// not a path). The actual cost is just reserve reads + arithmetic, but no dedicated
359		// benchmark exists yet. Charging the swap weight for path length 2 is a safe
360		// overestimate since swaps include transfer costs that quotes do not.
361		env.charge(
362			<Runtime as pallet_asset_conversion::Config>::WeightInfo::swap_exact_tokens_for_tokens(
363				2,
364			),
365		)?;
366
367		let asset1 = Self::decode_asset_kind(&call.asset1)?;
368		let asset2 = Self::decode_asset_kind(&call.asset2)?;
369
370		let quoted =
371			<pallet_asset_conversion::Pallet<Runtime> as QuotePrice>::quote_price_exact_tokens_for_tokens(
372				asset1,
373				asset2,
374				Self::to_balance(call.amount)?,
375				call.includeFee,
376			)
377			.ok_or(Error::Revert(Revert { reason: ERR_POOL_NOT_FOUND.into() }))?;
378
379		Ok(IAssetConversion::quoteExactTokensForTokensCall::abi_encode_returns(&Self::to_u256(
380			quoted,
381		)?))
382	}
383
384	fn quote_tokens_for_exact_tokens(
385		call: &IAssetConversion::quoteTokensForExactTokensCall,
386		env: &mut impl Ext<T = Runtime>,
387	) -> Result<Vec<u8>, Error> {
388		// See comment in quote_exact_tokens_for_tokens for weight rationale.
389		env.charge(
390			<Runtime as pallet_asset_conversion::Config>::WeightInfo::swap_tokens_for_exact_tokens(
391				2,
392			),
393		)?;
394
395		let asset1 = Self::decode_asset_kind(&call.asset1)?;
396		let asset2 = Self::decode_asset_kind(&call.asset2)?;
397
398		let quoted =
399			<pallet_asset_conversion::Pallet<Runtime> as QuotePrice>::quote_price_tokens_for_exact_tokens(
400				asset1,
401				asset2,
402				Self::to_balance(call.amount)?,
403				call.includeFee,
404			)
405			.ok_or(Error::Revert(Revert { reason: ERR_POOL_NOT_FOUND.into() }))?;
406
407		Ok(IAssetConversion::quoteTokensForExactTokensCall::abi_encode_returns(&Self::to_u256(
408			quoted,
409		)?))
410	}
411
412	fn create_pool(
413		call: &IAssetConversion::createPoolCall,
414		env: &mut impl Ext<T = Runtime>,
415	) -> Result<Vec<u8>, Error> {
416		env.charge(<Runtime as pallet_asset_conversion::Config>::WeightInfo::create_pool())?;
417
418		let asset1 = Self::decode_asset_kind(&call.asset1)?;
419		let asset2 = Self::decode_asset_kind(&call.asset2)?;
420
421		let sender = Self::caller_account_id(env)?;
422
423		<pallet_asset_conversion::Pallet<Runtime> as MutateLiquidity<
424			<Runtime as frame_system::Config>::AccountId,
425		>>::create_pool(&sender, asset1, asset2)?;
426
427		Ok(Vec::new())
428	}
429
430	fn add_liquidity(
431		call: &IAssetConversion::addLiquidityCall,
432		env: &mut impl Ext<T = Runtime>,
433	) -> Result<Vec<u8>, Error> {
434		env.charge(<Runtime as pallet_asset_conversion::Config>::WeightInfo::add_liquidity())?;
435
436		let asset1 = Self::decode_asset_kind(&call.asset1)?;
437		let asset2 = Self::decode_asset_kind(&call.asset2)?;
438
439		let sender = Self::caller_account_id(env)?;
440		let mint_to = env.to_account_id(&H160(call.mintTo.0 .0));
441
442		let lp_tokens = <pallet_asset_conversion::Pallet<Runtime> as MutateLiquidity<
443			<Runtime as frame_system::Config>::AccountId,
444		>>::add_liquidity(
445			&sender,
446			AddLiquidityAsset {
447				asset: asset1,
448				amount_desired: Self::to_balance(call.amount1Desired)?,
449				amount_min: Self::to_balance(call.amount1Min)?,
450			},
451			AddLiquidityAsset {
452				asset: asset2,
453				amount_desired: Self::to_balance(call.amount2Desired)?,
454				amount_min: Self::to_balance(call.amount2Min)?,
455			},
456			&mint_to,
457		)?;
458
459		Ok(IAssetConversion::addLiquidityCall::abi_encode_returns(&Self::to_u256(lp_tokens)?))
460	}
461
462	fn remove_liquidity(
463		call: &IAssetConversion::removeLiquidityCall,
464		env: &mut impl Ext<T = Runtime>,
465	) -> Result<Vec<u8>, Error> {
466		env.charge(<Runtime as pallet_asset_conversion::Config>::WeightInfo::remove_liquidity())?;
467
468		let asset1 = Self::decode_asset_kind(&call.asset1)?;
469		let asset2 = Self::decode_asset_kind(&call.asset2)?;
470
471		let sender = Self::caller_account_id(env)?;
472		let withdraw_to = env.to_account_id(&H160(call.withdrawTo.0 .0));
473
474		let (amount1, amount2) = <pallet_asset_conversion::Pallet<Runtime> as MutateLiquidity<
475			<Runtime as frame_system::Config>::AccountId,
476		>>::remove_liquidity(
477			&sender,
478			asset1,
479			asset2,
480			Self::to_balance(call.lpTokenBurn)?,
481			Self::to_balance(call.amount1MinReceive)?,
482			Self::to_balance(call.amount2MinReceive)?,
483			&withdraw_to,
484		)?;
485
486		Ok(IAssetConversion::removeLiquidityCall::abi_encode_returns(
487			&IAssetConversion::removeLiquidityReturn {
488				amount1: Self::to_u256(amount1)?,
489				amount2: Self::to_u256(amount2)?,
490			},
491		))
492	}
493
494	fn get_reserves(
495		call: &IAssetConversion::getReservesCall,
496		env: &mut impl Ext<T = Runtime>,
497	) -> Result<Vec<u8>, Error> {
498		env.charge(<Runtime as pallet_asset_conversion::Config>::WeightInfo::get_reserves())?;
499
500		let asset1 = Self::decode_asset_kind(&call.asset1)?;
501		let asset2 = Self::decode_asset_kind(&call.asset2)?;
502
503		let (reserve1, reserve2) = pallet_asset_conversion::Pallet::<Runtime>::get_reserves(
504			asset1, asset2,
505		)
506		.map_err(|e| match e {
507			pallet_asset_conversion::Error::InvalidAssetPair => {
508				Error::Revert(Revert { reason: ERR_INVALID_ASSET_PAIR.into() })
509			},
510			pallet_asset_conversion::Error::PoolEmpty => {
511				Error::Revert(Revert { reason: ERR_POOL_EMPTY.into() })
512			},
513			// get_reserves only produces the two variants above; list the rest
514			// exhaustively so adding a new Error variant triggers a compile error.
515			pallet_asset_conversion::Error::PoolExists |
516			pallet_asset_conversion::Error::WrongDesiredAmount |
517			pallet_asset_conversion::Error::AmountOneLessThanMinimal |
518			pallet_asset_conversion::Error::AmountTwoLessThanMinimal |
519			pallet_asset_conversion::Error::ReserveLeftLessThanMinimal |
520			pallet_asset_conversion::Error::AmountOutTooHigh |
521			pallet_asset_conversion::Error::PoolNotFound |
522			pallet_asset_conversion::Error::Overflow |
523			pallet_asset_conversion::Error::AssetOneDepositDidNotMeetMinimum |
524			pallet_asset_conversion::Error::AssetTwoDepositDidNotMeetMinimum |
525			pallet_asset_conversion::Error::AssetOneWithdrawalDidNotMeetMinimum |
526			pallet_asset_conversion::Error::AssetTwoWithdrawalDidNotMeetMinimum |
527			pallet_asset_conversion::Error::OptimalAmountLessThanDesired |
528			pallet_asset_conversion::Error::InsufficientLiquidityMinted |
529			pallet_asset_conversion::Error::ZeroLiquidity |
530			pallet_asset_conversion::Error::ZeroAmount |
531			pallet_asset_conversion::Error::ProvidedMinimumNotSufficientForSwap |
532			pallet_asset_conversion::Error::ProvidedMaximumNotSufficientForSwap |
533			pallet_asset_conversion::Error::InvalidPath |
534			pallet_asset_conversion::Error::NonUniquePath |
535			pallet_asset_conversion::Error::IncorrectPoolAssetId |
536			pallet_asset_conversion::Error::BelowMinimum => {
537				frame_support::defensive!("get_reserves returned unexpected error");
538				Error::Revert(Revert { reason: ERR_UNEXPECTED.into() })
539			},
540		})?;
541
542		Ok(IAssetConversion::getReservesCall::abi_encode_returns(
543			&IAssetConversion::getReservesReturn {
544				reserve1: Self::to_u256(reserve1)?,
545				reserve2: Self::to_u256(reserve2)?,
546			},
547		))
548	}
549}