pallet_erc20_xcm_bridge/
lib.rs

1// Copyright 2019-2025 PureStake Inc.
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//! Pallet that allow to transact erc20 tokens through xcm directly.
18
19#![cfg_attr(not(feature = "std"), no_std)]
20
21#[cfg(test)]
22mod mock;
23#[cfg(test)]
24mod tests;
25
26mod erc20_matcher;
27mod erc20_trap;
28mod errors;
29mod xcm_holding_ext;
30
31use frame_support::pallet;
32
33pub use erc20_trap::AssetTrapWrapper;
34pub use pallet::*;
35pub use xcm_holding_ext::XcmExecutorWrapper;
36
37#[pallet]
38pub mod pallet {
39
40	use crate::erc20_matcher::*;
41	use crate::errors::*;
42	use crate::xcm_holding_ext::*;
43	use ethereum_types::BigEndianHash;
44	use fp_evm::{ExitReason, ExitSucceed};
45	use frame_support::pallet_prelude::*;
46	use pallet_evm::{GasWeightMapping, Runner};
47	use sp_core::{H160, H256, U256};
48	use sp_std::vec::Vec;
49	use xcm::latest::{
50		Asset, AssetId, Error as XcmError, Junction, Location, Result as XcmResult, XcmContext,
51	};
52	use xcm_executor::traits::ConvertLocation;
53	use xcm_executor::traits::{Error as MatchError, MatchesFungibles};
54	use xcm_executor::AssetsInHolding;
55
56	const ERC20_TRANSFER_CALL_DATA_SIZE: usize = 4 + 32 + 32; // selector + from + amount
57	const ERC20_TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb];
58
59	#[pallet::pallet]
60	pub struct Pallet<T>(PhantomData<T>);
61
62	#[pallet::config]
63	pub trait Config: frame_system::Config + pallet_evm::Config {
64		type AccountIdConverter: ConvertLocation<H160>;
65		type Erc20MultilocationPrefix: Get<Location>;
66		type Erc20TransferGasLimit: Get<u64>;
67		type EvmRunner: Runner<Self>;
68	}
69
70	impl<T: Config> Pallet<T> {
71		pub fn is_erc20_asset(asset: &Asset) -> bool {
72			Erc20Matcher::<T::Erc20MultilocationPrefix>::is_erc20_asset(asset)
73		}
74		pub fn gas_limit_of_erc20_transfer(asset_id: &AssetId) -> u64 {
75			let location = &asset_id.0;
76			if let Some(Junction::GeneralKey {
77				length: _,
78				ref data,
79			}) = location.interior().into_iter().next_back()
80			{
81				// As GeneralKey definition might change in future versions of XCM, this is meant
82				// to throw a compile error as a warning that data type has changed.
83				// If that happens, a new check is needed to ensure that data has at least 18
84				// bytes (size of b"gas_limit:" + u64)
85				let data: &[u8; 32] = &data;
86				if let Ok(content) = core::str::from_utf8(&data[0..10]) {
87					if content == "gas_limit:" {
88						let mut bytes: [u8; 8] = Default::default();
89						bytes.copy_from_slice(&data[10..18]);
90						return u64::from_le_bytes(bytes);
91					}
92				}
93			}
94			T::Erc20TransferGasLimit::get()
95		}
96		pub fn weight_of_erc20_transfer(asset_id: &AssetId) -> Weight {
97			T::GasWeightMapping::gas_to_weight(Self::gas_limit_of_erc20_transfer(asset_id), true)
98		}
99		fn erc20_transfer(
100			erc20_contract_address: H160,
101			from: H160,
102			to: H160,
103			amount: U256,
104			gas_limit: u64,
105		) -> Result<(), Erc20TransferError> {
106			let mut input = Vec::with_capacity(ERC20_TRANSFER_CALL_DATA_SIZE);
107			// ERC20.transfer method hash
108			input.extend_from_slice(&ERC20_TRANSFER_SELECTOR);
109			// append receiver address
110			input.extend_from_slice(H256::from(to).as_bytes());
111			// append amount to be transferred
112			input.extend_from_slice(H256::from_uint(&amount).as_bytes());
113
114			let weight_limit: Weight = T::GasWeightMapping::gas_to_weight(gas_limit, true);
115
116			let exec_info = T::EvmRunner::call(
117				from,
118				erc20_contract_address,
119				input,
120				U256::default(),
121				gas_limit,
122				None,
123				None,
124				None,
125				Default::default(),
126				Default::default(),
127				false,
128				false,
129				Some(weight_limit),
130				Some(0),
131				&<T as pallet_evm::Config>::config(),
132			)
133			.map_err(|_| Erc20TransferError::EvmCallFail)?;
134
135			ensure!(
136				matches!(
137					exec_info.exit_reason,
138					ExitReason::Succeed(ExitSucceed::Returned | ExitSucceed::Stopped)
139				),
140				Erc20TransferError::ContractTransferFail
141			);
142
143			// return value is true.
144			let bytes: [u8; 32] = U256::from(1).to_big_endian();
145
146			// Check return value to make sure not calling on empty contracts.
147			ensure!(
148				!exec_info.value.is_empty() && exec_info.value == bytes,
149				Erc20TransferError::ContractReturnInvalidValue
150			);
151
152			Ok(())
153		}
154	}
155
156	impl<T: Config> xcm_executor::traits::TransactAsset for Pallet<T> {
157		// For optimization reasons, the asset we want to deposit has not really been withdrawn,
158		// we have just traced from which account it should have been withdrawn.
159		// So we will retrieve these information and make the transfer from the origin account.
160		fn deposit_asset(what: &Asset, who: &Location, _context: Option<&XcmContext>) -> XcmResult {
161			let (contract_address, amount) =
162				Erc20Matcher::<T::Erc20MultilocationPrefix>::matches_fungibles(what)?;
163
164			let beneficiary = T::AccountIdConverter::convert_location(who)
165				.ok_or(MatchError::AccountIdConversionFailed)?;
166
167			let gas_limit = Self::gas_limit_of_erc20_transfer(&what.id);
168
169			// Get the global context to recover accounts origins.
170			XcmHoldingErc20sOrigins::with(|erc20s_origins| {
171				match erc20s_origins.drain(contract_address, amount) {
172					// We perform the evm transfers in a storage transaction to ensure that if one
173					// of them fails all the changes of the previous evm calls are rolled back.
174					Ok(tokens_to_transfer) => frame_support::storage::with_storage_layer(|| {
175						tokens_to_transfer
176							.into_iter()
177							.try_for_each(|(from, subamount)| {
178								Self::erc20_transfer(
179									contract_address,
180									from,
181									beneficiary,
182									subamount,
183									gas_limit,
184								)
185							})
186					})
187					.map_err(Into::into),
188					Err(DrainError::AssetNotFound) => Err(XcmError::AssetNotFound),
189					Err(DrainError::NotEnoughFounds) => Err(XcmError::FailedToTransactAsset(
190						"not enough founds in xcm holding",
191					)),
192					Err(DrainError::SplitError) => Err(XcmError::FailedToTransactAsset(
193						"SplitError: each withdrawal of erc20 tokens must be deposited at once",
194					)),
195				}
196			})
197			.ok_or(XcmError::FailedToTransactAsset(
198				"missing erc20 executor context",
199			))?
200		}
201
202		fn internal_transfer_asset(
203			asset: &Asset,
204			from: &Location,
205			to: &Location,
206			_context: &XcmContext,
207		) -> Result<AssetsInHolding, XcmError> {
208			let (contract_address, amount) =
209				Erc20Matcher::<T::Erc20MultilocationPrefix>::matches_fungibles(asset)?;
210
211			let from = T::AccountIdConverter::convert_location(from)
212				.ok_or(MatchError::AccountIdConversionFailed)?;
213
214			let to = T::AccountIdConverter::convert_location(to)
215				.ok_or(MatchError::AccountIdConversionFailed)?;
216
217			let gas_limit = Self::gas_limit_of_erc20_transfer(&asset.id);
218
219			// We perform the evm transfers in a storage transaction to ensure that if it fail
220			// any contract storage changes are rolled back.
221			frame_support::storage::with_storage_layer(|| {
222				Self::erc20_transfer(contract_address, from, to, amount, gas_limit)
223			})?;
224
225			Ok(asset.clone().into())
226		}
227
228		// Since we don't control the erc20 contract that manages the asset we want to withdraw,
229		// we can't really withdraw this asset, we can only transfer it to another account.
230		// It would be possible to transfer the asset to a dedicated account that would reflect
231		// the content of the xcm holding, but this would imply to perform two evm calls instead of
232		// one (1 to withdraw the asset and a second one to deposit it).
233		// In order to perform only one evm call, we just trace the origin of the asset,
234		// and then the transfer will only really be performed in the deposit instruction.
235		fn withdraw_asset(
236			what: &Asset,
237			who: &Location,
238			_context: Option<&XcmContext>,
239		) -> Result<AssetsInHolding, XcmError> {
240			let (contract_address, amount) =
241				Erc20Matcher::<T::Erc20MultilocationPrefix>::matches_fungibles(what)?;
242			let who = T::AccountIdConverter::convert_location(who)
243				.ok_or(MatchError::AccountIdConversionFailed)?;
244
245			XcmHoldingErc20sOrigins::with(|erc20s_origins| {
246				erc20s_origins.insert(contract_address, who, amount)
247			})
248			.ok_or(XcmError::FailedToTransactAsset(
249				"missing erc20 executor context",
250			))?;
251
252			Ok(what.clone().into())
253		}
254	}
255}