Skip to main content

snowbridge_router_primitives/inbound/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2023 Snowfork <hello@snowfork.com>
3//! Converts messages from Ethereum to XCM messages
4
5#[cfg(test)]
6mod mock;
7#[cfg(test)]
8mod tests;
9
10use codec::{Decode, Encode};
11use core::marker::PhantomData;
12use frame_support::{traits::tokens::Balance as BalanceT, PalletError};
13use scale_info::TypeInfo;
14use snowbridge_core::TokenId;
15use sp_core::{Get, RuntimeDebug, H160, H256};
16use sp_io::hashing::blake2_256;
17use sp_runtime::{traits::MaybeEquivalence, MultiAddress};
18use sp_std::prelude::*;
19use xcm::prelude::{Junction::AccountKey20, *};
20use xcm_executor::traits::ConvertLocation;
21
22const MINIMUM_DEPOSIT: u128 = 1;
23
24/// Messages from Ethereum are versioned. This is because in future,
25/// we may want to evolve the protocol so that the ethereum side sends XCM messages directly.
26/// Instead having BridgeHub transcode the messages into XCM.
27#[derive(Clone, Encode, Decode, RuntimeDebug)]
28pub enum VersionedMessage {
29	V1(MessageV1),
30}
31
32/// For V1, the ethereum side sends messages which are transcoded into XCM. These messages are
33/// self-contained, in that they can be transcoded using only information in the message.
34#[derive(Clone, Encode, Decode, RuntimeDebug)]
35pub struct MessageV1 {
36	/// EIP-155 chain id of the origin Ethereum network
37	pub chain_id: u64,
38	/// The command originating from the Gateway contract
39	pub command: Command,
40}
41
42#[derive(Clone, Encode, Decode, RuntimeDebug)]
43pub enum Command {
44	/// Register a wrapped token on the AssetHub `ForeignAssets` pallet
45	RegisterToken {
46		/// The address of the ERC20 token to be bridged over to AssetHub
47		token: H160,
48		/// XCM execution fee on AssetHub
49		fee: u128,
50	},
51	/// Send Ethereum token to AssetHub or another parachain
52	SendToken {
53		/// The address of the ERC20 token to be bridged over to AssetHub
54		token: H160,
55		/// The destination for the transfer
56		destination: Destination,
57		/// Amount to transfer
58		amount: u128,
59		/// XCM execution fee on AssetHub
60		fee: u128,
61	},
62	/// Send Polkadot token back to the original parachain
63	SendNativeToken {
64		/// The Id of the token
65		token_id: TokenId,
66		/// The destination for the transfer
67		destination: Destination,
68		/// Amount to transfer
69		amount: u128,
70		/// XCM execution fee on AssetHub
71		fee: u128,
72	},
73}
74
75/// Destination for bridged tokens
76#[derive(Clone, Encode, Decode, RuntimeDebug)]
77pub enum Destination {
78	/// The funds will be deposited into account `id` on AssetHub
79	AccountId32 { id: [u8; 32] },
80	/// The funds will deposited into the sovereign account of destination parachain `para_id` on
81	/// AssetHub, Account `id` on the destination parachain will receive the funds via a
82	/// reserve-backed transfer. See <https://github.com/paritytech/xcm-format#depositreserveasset>
83	ForeignAccountId32 {
84		para_id: u32,
85		id: [u8; 32],
86		/// XCM execution fee on final destination
87		fee: u128,
88	},
89	/// The funds will deposited into the sovereign account of destination parachain `para_id` on
90	/// AssetHub, Account `id` on the destination parachain will receive the funds via a
91	/// reserve-backed transfer. See <https://github.com/paritytech/xcm-format#depositreserveasset>
92	ForeignAccountId20 {
93		para_id: u32,
94		id: [u8; 20],
95		/// XCM execution fee on final destination
96		fee: u128,
97	},
98}
99
100pub struct MessageToXcm<
101	CreateAssetCall,
102	CreateAssetDeposit,
103	InboundQueuePalletInstance,
104	AccountId,
105	Balance,
106	ConvertAssetId,
107	EthereumUniversalLocation,
108	GlobalAssetHubLocation,
109> where
110	CreateAssetCall: Get<CallIndex>,
111	CreateAssetDeposit: Get<u128>,
112	Balance: BalanceT,
113	ConvertAssetId: MaybeEquivalence<TokenId, Location>,
114	EthereumUniversalLocation: Get<InteriorLocation>,
115	GlobalAssetHubLocation: Get<Location>,
116{
117	_phantom: PhantomData<(
118		CreateAssetCall,
119		CreateAssetDeposit,
120		InboundQueuePalletInstance,
121		AccountId,
122		Balance,
123		ConvertAssetId,
124		EthereumUniversalLocation,
125		GlobalAssetHubLocation,
126	)>,
127}
128
129/// Reason why a message conversion failed.
130#[derive(Copy, Clone, TypeInfo, PalletError, Encode, Decode, RuntimeDebug)]
131pub enum ConvertMessageError {
132	/// The message version is not supported for conversion.
133	UnsupportedVersion,
134	InvalidDestination,
135	InvalidToken,
136	/// The fee asset is not supported for conversion.
137	UnsupportedFeeAsset,
138	CannotReanchor,
139}
140
141/// convert the inbound message to xcm which will be forwarded to the destination chain
142pub trait ConvertMessage {
143	type Balance: BalanceT + From<u128>;
144	type AccountId;
145	/// Converts a versioned message into an XCM message and an optional topicID
146	fn convert(
147		message_id: H256,
148		message: VersionedMessage,
149	) -> Result<(Xcm<()>, Self::Balance), ConvertMessageError>;
150}
151
152pub type CallIndex = [u8; 2];
153
154impl<
155		CreateAssetCall,
156		CreateAssetDeposit,
157		InboundQueuePalletInstance,
158		AccountId,
159		Balance,
160		ConvertAssetId,
161		EthereumUniversalLocation,
162		GlobalAssetHubLocation,
163	> ConvertMessage
164	for MessageToXcm<
165		CreateAssetCall,
166		CreateAssetDeposit,
167		InboundQueuePalletInstance,
168		AccountId,
169		Balance,
170		ConvertAssetId,
171		EthereumUniversalLocation,
172		GlobalAssetHubLocation,
173	>
174where
175	CreateAssetCall: Get<CallIndex>,
176	CreateAssetDeposit: Get<u128>,
177	InboundQueuePalletInstance: Get<u8>,
178	Balance: BalanceT + From<u128>,
179	AccountId: Into<[u8; 32]>,
180	ConvertAssetId: MaybeEquivalence<TokenId, Location>,
181	EthereumUniversalLocation: Get<InteriorLocation>,
182	GlobalAssetHubLocation: Get<Location>,
183{
184	type Balance = Balance;
185	type AccountId = AccountId;
186
187	fn convert(
188		message_id: H256,
189		message: VersionedMessage,
190	) -> Result<(Xcm<()>, Self::Balance), ConvertMessageError> {
191		use Command::*;
192		use VersionedMessage::*;
193		match message {
194			V1(MessageV1 { chain_id, command: RegisterToken { token, fee } }) =>
195				Ok(Self::convert_register_token(message_id, chain_id, token, fee)),
196			V1(MessageV1 { chain_id, command: SendToken { token, destination, amount, fee } }) =>
197				Ok(Self::convert_send_token(message_id, chain_id, token, destination, amount, fee)),
198			V1(MessageV1 {
199				chain_id,
200				command: SendNativeToken { token_id, destination, amount, fee },
201			}) => Self::convert_send_native_token(
202				message_id,
203				chain_id,
204				token_id,
205				destination,
206				amount,
207				fee,
208			),
209		}
210	}
211}
212
213impl<
214		CreateAssetCall,
215		CreateAssetDeposit,
216		InboundQueuePalletInstance,
217		AccountId,
218		Balance,
219		ConvertAssetId,
220		EthereumUniversalLocation,
221		GlobalAssetHubLocation,
222	>
223	MessageToXcm<
224		CreateAssetCall,
225		CreateAssetDeposit,
226		InboundQueuePalletInstance,
227		AccountId,
228		Balance,
229		ConvertAssetId,
230		EthereumUniversalLocation,
231		GlobalAssetHubLocation,
232	>
233where
234	CreateAssetCall: Get<CallIndex>,
235	CreateAssetDeposit: Get<u128>,
236	InboundQueuePalletInstance: Get<u8>,
237	Balance: BalanceT + From<u128>,
238	AccountId: Into<[u8; 32]>,
239	ConvertAssetId: MaybeEquivalence<TokenId, Location>,
240	EthereumUniversalLocation: Get<InteriorLocation>,
241	GlobalAssetHubLocation: Get<Location>,
242{
243	fn convert_register_token(
244		message_id: H256,
245		chain_id: u64,
246		token: H160,
247		fee: u128,
248	) -> (Xcm<()>, Balance) {
249		let network = Ethereum { chain_id };
250		let xcm_fee: Asset = (Location::parent(), fee).into();
251		let deposit: Asset = (Location::parent(), CreateAssetDeposit::get()).into();
252
253		let total_amount = fee + CreateAssetDeposit::get();
254		let total: Asset = (Location::parent(), total_amount).into();
255
256		let bridge_location = Location::new(2, GlobalConsensus(network));
257
258		let owner = EthereumLocationsConverterFor::<[u8; 32]>::from_chain_id(&chain_id);
259		let asset_id = Self::convert_token_address(network, token);
260		let create_call_index: [u8; 2] = CreateAssetCall::get();
261		let inbound_queue_pallet_index = InboundQueuePalletInstance::get();
262
263		let xcm: Xcm<()> = vec![
264			// Teleport required fees.
265			ReceiveTeleportedAsset(total.into()),
266			// Pay for execution.
267			BuyExecution { fees: xcm_fee, weight_limit: Unlimited },
268			// Fund the snowbridge sovereign with the required deposit for creation.
269			DepositAsset { assets: Definite(deposit.into()), beneficiary: bridge_location.clone() },
270			// This `SetAppendix` ensures that `xcm_fee` not spent by `Transact` will be
271			// deposited to snowbridge sovereign, instead of being trapped, regardless of
272			// `Transact` success or not.
273			SetAppendix(Xcm(vec![
274				RefundSurplus,
275				DepositAsset { assets: AllCounted(1).into(), beneficiary: bridge_location },
276			])),
277			// Only our inbound-queue pallet is allowed to invoke `UniversalOrigin`.
278			DescendOrigin(PalletInstance(inbound_queue_pallet_index).into()),
279			// Change origin to the bridge.
280			UniversalOrigin(GlobalConsensus(network)),
281			// Call create_asset on foreign assets pallet.
282			Transact {
283				origin_kind: OriginKind::Xcm,
284				fallback_max_weight: Some(Weight::from_parts(400_000_000, 8_000)),
285				call: (
286					create_call_index,
287					asset_id,
288					MultiAddress::<[u8; 32], ()>::Id(owner),
289					MINIMUM_DEPOSIT,
290				)
291					.encode()
292					.into(),
293			},
294			// Forward message id to Asset Hub
295			SetTopic(message_id.into()),
296			// Once the program ends here, appendix program will run, which will deposit any
297			// leftover fee to snowbridge sovereign.
298		]
299		.into();
300
301		(xcm, total_amount.into())
302	}
303
304	fn convert_send_token(
305		message_id: H256,
306		chain_id: u64,
307		token: H160,
308		destination: Destination,
309		amount: u128,
310		asset_hub_fee: u128,
311	) -> (Xcm<()>, Balance) {
312		let network = Ethereum { chain_id };
313		let asset_hub_fee_asset: Asset = (Location::parent(), asset_hub_fee).into();
314		let asset: Asset = (Self::convert_token_address(network, token), amount).into();
315
316		let (dest_para_id, beneficiary, dest_para_fee) = match destination {
317			// Final destination is a 32-byte account on AssetHub
318			Destination::AccountId32 { id } =>
319				(None, Location::new(0, [AccountId32 { network: None, id }]), 0),
320			// Final destination is a 32-byte account on a sibling of AssetHub
321			Destination::ForeignAccountId32 { para_id, id, fee } => (
322				Some(para_id),
323				Location::new(0, [AccountId32 { network: None, id }]),
324				// Total fee needs to cover execution on AssetHub and Sibling
325				fee,
326			),
327			// Final destination is a 20-byte account on a sibling of AssetHub
328			Destination::ForeignAccountId20 { para_id, id, fee } => (
329				Some(para_id),
330				Location::new(0, [AccountKey20 { network: None, key: id }]),
331				// Total fee needs to cover execution on AssetHub and Sibling
332				fee,
333			),
334		};
335
336		let total_fees = asset_hub_fee.saturating_add(dest_para_fee);
337		let total_fee_asset: Asset = (Location::parent(), total_fees).into();
338		let inbound_queue_pallet_index = InboundQueuePalletInstance::get();
339
340		let mut instructions = vec![
341			ReceiveTeleportedAsset(total_fee_asset.into()),
342			BuyExecution { fees: asset_hub_fee_asset, weight_limit: Unlimited },
343			DescendOrigin(PalletInstance(inbound_queue_pallet_index).into()),
344			UniversalOrigin(GlobalConsensus(network)),
345			ReserveAssetDeposited(asset.clone().into()),
346			ClearOrigin,
347		];
348
349		match dest_para_id {
350			Some(dest_para_id) => {
351				let dest_para_fee_asset: Asset = (Location::parent(), dest_para_fee).into();
352				let bridge_location = Location::new(2, GlobalConsensus(network));
353
354				instructions.extend(vec![
355					// After program finishes deposit any leftover assets to the snowbridge
356					// sovereign.
357					SetAppendix(Xcm(vec![DepositAsset {
358						assets: Wild(AllCounted(2)),
359						beneficiary: bridge_location,
360					}])),
361					// Perform a deposit reserve to send to destination chain.
362					DepositReserveAsset {
363						// Send over assets and unspent fees, XCM delivery fee will be charged from
364						// here.
365						assets: Wild(AllCounted(2)),
366						dest: Location::new(1, [Parachain(dest_para_id)]),
367						xcm: vec![
368							// Buy execution on target.
369							BuyExecution { fees: dest_para_fee_asset, weight_limit: Unlimited },
370							// Deposit assets to beneficiary.
371							DepositAsset { assets: Wild(AllCounted(2)), beneficiary },
372							// Forward message id to destination parachain.
373							SetTopic(message_id.into()),
374						]
375						.into(),
376					},
377				]);
378			},
379			None => {
380				instructions.extend(vec![
381					// Deposit both asset and fees to beneficiary so the fees will not get
382					// trapped. Another benefit is when fees left more than ED on AssetHub could be
383					// used to create the beneficiary account in case it does not exist.
384					DepositAsset { assets: Wild(AllCounted(2)), beneficiary },
385				]);
386			},
387		}
388
389		// Forward message id to Asset Hub.
390		instructions.push(SetTopic(message_id.into()));
391
392		// The `instructions` to forward to AssetHub, and the `total_fees` to locally burn (since
393		// they are teleported within `instructions`).
394		(instructions.into(), total_fees.into())
395	}
396
397	// Convert ERC20 token address to a location that can be understood by Assets Hub.
398	fn convert_token_address(network: NetworkId, token: H160) -> Location {
399		// If the token is `0x0000000000000000000000000000000000000000` then return the location of
400		// native Ether.
401		if token == H160([0; 20]) {
402			Location::new(2, [GlobalConsensus(network)])
403		} else {
404			Location::new(
405				2,
406				[GlobalConsensus(network), AccountKey20 { network: None, key: token.into() }],
407			)
408		}
409	}
410
411	/// Constructs an XCM message destined for AssetHub that withdraws assets from the sovereign
412	/// account of the Gateway contract and either deposits those assets into a recipient account or
413	/// forwards the assets to another parachain.
414	fn convert_send_native_token(
415		message_id: H256,
416		chain_id: u64,
417		token_id: TokenId,
418		destination: Destination,
419		amount: u128,
420		asset_hub_fee: u128,
421	) -> Result<(Xcm<()>, Balance), ConvertMessageError> {
422		let network = Ethereum { chain_id };
423		let asset_hub_fee_asset: Asset = (Location::parent(), asset_hub_fee).into();
424
425		let beneficiary = match destination {
426			// Final destination is a 32-byte account on AssetHub
427			Destination::AccountId32 { id } =>
428				Ok(Location::new(0, [AccountId32 { network: None, id }])),
429			// Forwarding to a destination parachain is not allowed for PNA and is validated on the
430			// Ethereum side. https://github.com/Snowfork/snowbridge/blob/e87ddb2215b513455c844463a25323bb9c01ff36/contracts/src/Assets.sol#L216-L224
431			_ => Err(ConvertMessageError::InvalidDestination),
432		}?;
433
434		let total_fee_asset: Asset = (Location::parent(), asset_hub_fee).into();
435
436		let asset_loc =
437			ConvertAssetId::convert(&token_id).ok_or(ConvertMessageError::InvalidToken)?;
438
439		let mut reanchored_asset_loc = asset_loc.clone();
440		reanchored_asset_loc
441			.reanchor(&GlobalAssetHubLocation::get(), &EthereumUniversalLocation::get())
442			.map_err(|_| ConvertMessageError::CannotReanchor)?;
443
444		let asset: Asset = (reanchored_asset_loc, amount).into();
445
446		let inbound_queue_pallet_index = InboundQueuePalletInstance::get();
447
448		let instructions = vec![
449			ReceiveTeleportedAsset(total_fee_asset.clone().into()),
450			BuyExecution { fees: asset_hub_fee_asset, weight_limit: Unlimited },
451			DescendOrigin(PalletInstance(inbound_queue_pallet_index).into()),
452			UniversalOrigin(GlobalConsensus(network)),
453			WithdrawAsset(asset.clone().into()),
454			// Deposit both asset and fees to beneficiary so the fees will not get
455			// trapped. Another benefit is when fees left more than ED on AssetHub could be
456			// used to create the beneficiary account in case it does not exist.
457			DepositAsset { assets: Wild(AllCounted(2)), beneficiary },
458			SetTopic(message_id.into()),
459		];
460
461		// `total_fees` to burn on this chain when sending `instructions` to run on AH (which also
462		// teleport fees)
463		Ok((instructions.into(), asset_hub_fee.into()))
464	}
465}
466
467pub struct EthereumLocationsConverterFor<AccountId>(PhantomData<AccountId>);
468impl<AccountId> ConvertLocation<AccountId> for EthereumLocationsConverterFor<AccountId>
469where
470	AccountId: From<[u8; 32]> + Clone,
471{
472	fn convert_location(location: &Location) -> Option<AccountId> {
473		match location.unpack() {
474			(2, [GlobalConsensus(Ethereum { chain_id })]) =>
475				Some(Self::from_chain_id(chain_id).into()),
476			(2, [GlobalConsensus(Ethereum { chain_id }), AccountKey20 { network: _, key }]) =>
477				Some(Self::from_chain_id_with_key(chain_id, *key).into()),
478			_ => None,
479		}
480	}
481}
482
483impl<AccountId> EthereumLocationsConverterFor<AccountId> {
484	pub fn from_chain_id(chain_id: &u64) -> [u8; 32] {
485		(b"ethereum-chain", chain_id).using_encoded(blake2_256)
486	}
487	pub fn from_chain_id_with_key(chain_id: &u64, key: [u8; 20]) -> [u8; 32] {
488		(b"ethereum-chain", chain_id, key).using_encoded(blake2_256)
489	}
490}