referrerpolicy=no-referrer-when-downgrade

snowbridge_inbound_queue_primitives/v2/
converter.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2023 Snowfork <hello@snowfork.com>
3//! Converts messages from Solidity ABI-encoding to XCM
4
5use super::{message::*, traits::*};
6use crate::{v2::LOG_TARGET, CallIndex};
7use codec::{Decode, DecodeLimit, Encode};
8use core::marker::PhantomData;
9use frame_support::ensure;
10use snowbridge_core::TokenId;
11use sp_core::{Get, RuntimeDebug, H160};
12use sp_io::hashing::blake2_256;
13use sp_runtime::{traits::MaybeConvert, MultiAddress};
14use sp_std::prelude::*;
15use xcm::{
16	prelude::{Junction::*, *},
17	MAX_XCM_DECODE_DEPTH,
18};
19use xcm_builder::ExternalConsensusLocationsConverterFor;
20use xcm_executor::traits::ConvertLocation;
21
22const MINIMUM_DEPOSIT: u128 = 1;
23
24/// Topic prefix used for generating unique identifiers for messages
25const INBOUND_QUEUE_TOPIC_PREFIX: &str = "SnowbridgeInboundQueueV2";
26
27/// Representation of an intermediate parsed message, before final
28/// conversion to XCM.
29#[derive(Clone, RuntimeDebug, Encode)]
30pub struct PreparedMessage {
31	/// Ethereum account that initiated this messaging operation
32	pub origin: H160,
33	/// The claimer in the case that funds get trapped.
34	pub claimer: Location,
35	/// The assets bridged from Ethereum
36	pub assets: Vec<AssetTransfer>,
37	/// The XCM to execute on the destination
38	pub remote_xcm: Xcm<()>,
39	/// Fee in Ether to cover the xcm execution on AH.
40	pub execution_fee: Asset,
41}
42
43/// An asset transfer instruction
44#[derive(Clone, RuntimeDebug, Encode)]
45pub enum AssetTransfer {
46	ReserveDeposit(Asset),
47	ReserveWithdraw(Asset),
48}
49
50/// Concrete implementation of `ConvertMessage`
51pub struct MessageToXcm<
52	CreateAssetCall,
53	CreateAssetDeposit,
54	EthereumNetwork,
55	InboundQueueLocation,
56	ConvertAssetId,
57	GatewayProxyAddress,
58	EthereumUniversalLocation,
59	AssetHubFromEthereum,
60	AssetHubUniversalLocation,
61	AccountId,
62> {
63	_phantom: PhantomData<(
64		CreateAssetCall,
65		CreateAssetDeposit,
66		EthereumNetwork,
67		InboundQueueLocation,
68		ConvertAssetId,
69		GatewayProxyAddress,
70		EthereumUniversalLocation,
71		AssetHubFromEthereum,
72		AssetHubUniversalLocation,
73		AccountId,
74	)>,
75}
76
77impl<
78		CreateAssetCall,
79		CreateAssetDeposit,
80		EthereumNetwork,
81		InboundQueueLocation,
82		ConvertAssetId,
83		GatewayProxyAddress,
84		EthereumUniversalLocation,
85		AssetHubFromEthereum,
86		AssetHubUniversalLocation,
87		AccountId,
88	>
89	MessageToXcm<
90		CreateAssetCall,
91		CreateAssetDeposit,
92		EthereumNetwork,
93		InboundQueueLocation,
94		ConvertAssetId,
95		GatewayProxyAddress,
96		EthereumUniversalLocation,
97		AssetHubFromEthereum,
98		AssetHubUniversalLocation,
99		AccountId,
100	>
101where
102	CreateAssetCall: Get<CallIndex>,
103	CreateAssetDeposit: Get<u128>,
104	EthereumNetwork: Get<NetworkId>,
105	InboundQueueLocation: Get<InteriorLocation>,
106	ConvertAssetId: MaybeConvert<TokenId, Location>,
107	GatewayProxyAddress: Get<H160>,
108	EthereumUniversalLocation: Get<InteriorLocation>,
109	AssetHubFromEthereum: Get<Location>,
110	AssetHubUniversalLocation: Get<InteriorLocation>,
111	AccountId: Into<[u8; 32]> + From<[u8; 32]> + Clone,
112{
113	/// Parse the message into an intermediate form, with all fields decoded
114	/// and prepared.
115	fn prepare(message: Message) -> Result<PreparedMessage, ConvertMessageError> {
116		// ETH "asset id" is the Ethereum root location. Same location used for the "bridge owner".
117		let ether_location = Location::new(2, [GlobalConsensus(EthereumNetwork::get())]);
118		let bridge_owner = Self::bridge_owner()?;
119
120		let claimer = message
121			.claimer
122			// Get the claimer from the message,
123			.and_then(|claimer_bytes| Location::decode(&mut claimer_bytes.as_ref()).ok())
124			// or use the Snowbridge sovereign on AH as the fallback claimer.
125			.unwrap_or_else(|| {
126				Location::new(0, [AccountId32 { network: None, id: bridge_owner.clone().into() }])
127			});
128
129		let mut remote_xcm: Xcm<()> = match &message.xcm {
130			XcmPayload::Raw(raw) => Self::decode_raw_xcm(raw),
131			XcmPayload::CreateAsset { token, network } => Self::make_create_asset_xcm(
132				token,
133				*network,
134				message.value,
135				bridge_owner,
136				claimer.clone(),
137			)?,
138		};
139
140		// Asset to cover XCM execution fee
141		let execution_fee_asset: Asset = (ether_location.clone(), message.execution_fee).into();
142
143		let mut assets = vec![];
144
145		if message.value > 0 {
146			// Asset for remaining ether
147			let remaining_ether_asset: Asset = (ether_location.clone(), message.value).into();
148			assets.push(AssetTransfer::ReserveDeposit(remaining_ether_asset));
149		}
150
151		for asset in &message.assets {
152			match asset {
153				EthereumAsset::NativeTokenERC20 { token_id, value } => {
154					ensure!(*token_id != H160::zero(), ConvertMessageError::InvalidAsset);
155					let token_location: Location = Location::new(
156						2,
157						[
158							GlobalConsensus(EthereumNetwork::get()),
159							AccountKey20 { network: None, key: (*token_id).into() },
160						],
161					);
162					let asset: Asset = (token_location, *value).into();
163					assets.push(AssetTransfer::ReserveDeposit(asset));
164				},
165				EthereumAsset::ForeignTokenERC20 { token_id, value } => {
166					let asset_loc = ConvertAssetId::maybe_convert(*token_id)
167						.ok_or(ConvertMessageError::InvalidAsset)?;
168					let reanchored_asset_loc = asset_loc
169						.reanchored(&AssetHubFromEthereum::get(), &EthereumUniversalLocation::get())
170						.map_err(|_| ConvertMessageError::CannotReanchor)?;
171					let asset: Asset = (reanchored_asset_loc, *value).into();
172					assets.push(AssetTransfer::ReserveWithdraw(asset));
173				},
174			}
175		}
176
177		// Add SetTopic instruction if not already present as the last instruction
178		if !matches!(remote_xcm.0.last(), Some(SetTopic(_))) {
179			let topic = blake2_256(&(INBOUND_QUEUE_TOPIC_PREFIX, message.nonce).encode());
180			remote_xcm.0.push(SetTopic(topic));
181		}
182
183		let prepared_message = PreparedMessage {
184			origin: message.origin,
185			claimer,
186			assets,
187			remote_xcm,
188			execution_fee: execution_fee_asset,
189		};
190
191		Ok(prepared_message)
192	}
193
194	/// Get sovereign account of Ethereum on Asset Hub.
195	fn bridge_owner() -> Result<AccountId, ConvertMessageError> {
196		let account =
197			ExternalConsensusLocationsConverterFor::<AssetHubUniversalLocation, AccountId>::convert_location(
198				&Location::new(2, [GlobalConsensus(EthereumNetwork::get())]),
199			)
200			.ok_or(ConvertMessageError::CannotReanchor)?;
201
202		Ok(account)
203	}
204
205	/// Construct the remote XCM needed to create a new asset in the `ForeignAssets` pallet
206	/// on AssetHub. Polkadot is the only supported network at the moment.
207	fn make_create_asset_xcm(
208		token: &H160,
209		network: super::message::Network,
210		eth_value: u128,
211		bridge_owner: AccountId,
212		claimer: Location,
213	) -> Result<Xcm<()>, ConvertMessageError> {
214		let dot_asset = Location::new(1, Here);
215		let dot_fee: xcm::prelude::Asset = (dot_asset, CreateAssetDeposit::get()).into();
216
217		let eth_asset: xcm::prelude::Asset =
218			(Location::new(2, [GlobalConsensus(EthereumNetwork::get())]), eth_value).into();
219
220		let create_call_index: [u8; 2] = CreateAssetCall::get();
221
222		let asset_id = Location::new(
223			2,
224			[
225				GlobalConsensus(EthereumNetwork::get()),
226				AccountKey20 { network: None, key: (*token).into() },
227			],
228		);
229
230		match network {
231			super::message::Network::Polkadot => Ok(Self::make_create_asset_xcm_for_polkadot(
232				create_call_index,
233				asset_id,
234				bridge_owner,
235				dot_fee,
236				eth_asset,
237				claimer,
238			)),
239		}
240	}
241
242	/// Construct the asset creation XCM for the Polkdot network.
243	fn make_create_asset_xcm_for_polkadot(
244		create_call_index: [u8; 2],
245		asset_id: Location,
246		bridge_owner: AccountId,
247		dot_fee_asset: xcm::prelude::Asset,
248		eth_asset: xcm::prelude::Asset,
249		claimer: Location,
250	) -> Xcm<()> {
251		let bridge_owner_bytes: [u8; 32] = bridge_owner.into();
252		vec![
253			// Exchange eth for dot to pay the asset creation deposit.
254			ExchangeAsset {
255				give: eth_asset.into(),
256				want: dot_fee_asset.clone().into(),
257				maximal: false,
258			},
259			// Deposit the dot deposit into the bridge sovereign account (where the asset
260			// creation fee will be deducted from).
261			DepositAsset {
262				assets: dot_fee_asset.clone().into(),
263				beneficiary: bridge_owner_bytes.into(),
264			},
265			// Call to create the asset.
266			Transact {
267				origin_kind: OriginKind::Xcm,
268				fallback_max_weight: None,
269				call: (
270					create_call_index,
271					asset_id.clone(),
272					MultiAddress::<[u8; 32], ()>::Id(bridge_owner_bytes.into()),
273					MINIMUM_DEPOSIT,
274				)
275					.encode()
276					.into(),
277			},
278			RefundSurplus,
279			// Deposit leftover funds to Snowbridge sovereign
280			DepositAsset { assets: Wild(AllCounted(2)), beneficiary: claimer },
281		]
282		.into()
283	}
284
285	/// Parse and (non-strictly) decode `raw` XCM bytes into a `Xcm<()>`.
286	/// If decoding fails, return an empty `Xcm<()>`—thus allowing the message
287	/// to proceed so assets can still be trapped on AH rather than the funds being locked on
288	/// Ethereum but not accessible on AH.
289	fn decode_raw_xcm(raw: &[u8]) -> Xcm<()> {
290		let mut data = raw;
291		if let Ok(versioned_xcm) =
292			VersionedXcm::<()>::decode_with_depth_limit(MAX_XCM_DECODE_DEPTH, &mut data)
293		{
294			if let Ok(decoded_xcm) = versioned_xcm.try_into() {
295				return decoded_xcm;
296			}
297		}
298		// Decoding failed; allow an empty XCM so the message won't fail entirely.
299		Xcm::new()
300	}
301}
302
303impl<
304		CreateAssetCall,
305		CreateAssetDeposit,
306		EthereumNetwork,
307		InboundQueueLocation,
308		ConvertAssetId,
309		GatewayProxyAddress,
310		EthereumUniversalLocation,
311		AssetHubFromEthereum,
312		AssetHubUniversalLocation,
313		AccountId,
314	> ConvertMessage
315	for MessageToXcm<
316		CreateAssetCall,
317		CreateAssetDeposit,
318		EthereumNetwork,
319		InboundQueueLocation,
320		ConvertAssetId,
321		GatewayProxyAddress,
322		EthereumUniversalLocation,
323		AssetHubFromEthereum,
324		AssetHubUniversalLocation,
325		AccountId,
326	>
327where
328	CreateAssetCall: Get<CallIndex>,
329	CreateAssetDeposit: Get<u128>,
330	EthereumNetwork: Get<NetworkId>,
331	InboundQueueLocation: Get<InteriorLocation>,
332	ConvertAssetId: MaybeConvert<TokenId, Location>,
333	GatewayProxyAddress: Get<H160>,
334	EthereumUniversalLocation: Get<InteriorLocation>,
335	AssetHubFromEthereum: Get<Location>,
336	AssetHubUniversalLocation: Get<InteriorLocation>,
337	AccountId: Into<[u8; 32]> + From<[u8; 32]> + Clone,
338{
339	fn convert(message: Message) -> Result<Xcm<()>, ConvertMessageError> {
340		let message = Self::prepare(message)?;
341
342		log::trace!(target: LOG_TARGET, "prepared message: {:?}", message);
343
344		let mut instructions = vec![
345			DescendOrigin(InboundQueueLocation::get()),
346			UniversalOrigin(GlobalConsensus(EthereumNetwork::get())),
347			ReserveAssetDeposited(message.execution_fee.clone().into()),
348		];
349
350		// Set claimer before PayFees, in case the fees are not enough. Then the claimer will be
351		// able to claim the funds still.
352		instructions.push(SetHints {
353			hints: vec![AssetClaimer { location: message.claimer }]
354				.try_into()
355				.expect("checked statically, qed"),
356		});
357
358		instructions.push(PayFees { asset: message.execution_fee.clone() });
359
360		let mut reserve_deposit_assets = vec![];
361		let mut reserve_withdraw_assets = vec![];
362
363		for asset in message.assets {
364			match asset {
365				AssetTransfer::ReserveDeposit(asset) => reserve_deposit_assets.push(asset),
366				AssetTransfer::ReserveWithdraw(asset) => reserve_withdraw_assets.push(asset),
367			};
368		}
369
370		if !reserve_deposit_assets.is_empty() {
371			instructions.push(ReserveAssetDeposited(reserve_deposit_assets.into()));
372		}
373		if !reserve_withdraw_assets.is_empty() {
374			instructions.push(WithdrawAsset(reserve_withdraw_assets.into()));
375		}
376
377		// If the message origin is not the gateway proxy contract, set the origin to
378		// the original sender on Ethereum. Important to be before the arbitrary XCM that is
379		// appended to the message on the next line.
380		if message.origin != GatewayProxyAddress::get() {
381			instructions.push(DescendOrigin(
382				AccountKey20 { key: message.origin.into(), network: None }.into(),
383			));
384		}
385
386		// Add the XCM sent in the message to the end of the xcm instruction
387		instructions.extend(message.remote_xcm.0);
388
389		Ok(instructions.into())
390	}
391}
392
393#[cfg(test)]
394mod tests {
395	use super::*;
396
397	use codec::Encode;
398	use frame_support::{assert_err, assert_ok, parameter_types};
399	use hex_literal::hex;
400	use snowbridge_core::TokenId;
401	use snowbridge_test_utils::mock_converter::{
402		add_location_override, reanchor_to_ethereum, LocationIdConvert,
403	};
404	use sp_core::{H160, H256};
405	const GATEWAY_ADDRESS: [u8; 20] = hex!["eda338e4dc46038493b885327842fd3e301cab39"];
406
407	parameter_types! {
408		pub const EthereumNetwork: xcm::v5::NetworkId = xcm::v5::NetworkId::Ethereum { chain_id: 1 };
409		pub const GatewayAddress: H160 = H160(GATEWAY_ADDRESS);
410		pub InboundQueueLocation: InteriorLocation = [PalletInstance(84)].into();
411		pub EthereumUniversalLocation: InteriorLocation =
412			[GlobalConsensus(EthereumNetwork::get())].into();
413		pub AssetHubFromEthereum: Location = Location::new(1,[GlobalConsensus(Polkadot),Parachain(1000)]);
414		pub AssetHubUniversalLocation: InteriorLocation = [GlobalConsensus(Polkadot),Parachain(1000)].into();
415		pub const CreateAssetCall: [u8;2] = [53, 0];
416		pub const CreateAssetDeposit: u128 = 10_000_000_000u128;
417		pub EthereumLocation: Location = Location::new(2,EthereumUniversalLocation::get());
418		pub BridgeHubContext: InteriorLocation = [GlobalConsensus(Polkadot),Parachain(1002)].into();
419	}
420
421	pub struct MockFailedTokenConvert;
422	impl MaybeConvert<TokenId, Location> for MockFailedTokenConvert {
423		fn maybe_convert(_id: TokenId) -> Option<Location> {
424			None
425		}
426	}
427
428	type Converter = MessageToXcm<
429		CreateAssetCall,
430		CreateAssetDeposit,
431		EthereumNetwork,
432		InboundQueueLocation,
433		LocationIdConvert,
434		GatewayAddress,
435		EthereumUniversalLocation,
436		AssetHubFromEthereum,
437		AssetHubUniversalLocation,
438		[u8; 32],
439	>;
440
441	type ConverterFailing = MessageToXcm<
442		CreateAssetCall,
443		CreateAssetDeposit,
444		EthereumNetwork,
445		InboundQueueLocation,
446		MockFailedTokenConvert,
447		GatewayAddress,
448		EthereumUniversalLocation,
449		AssetHubFromEthereum,
450		AssetHubUniversalLocation,
451		[u8; 32],
452	>;
453
454	#[test]
455	fn test_successful_message() {
456		sp_io::TestExternalities::default().execute_with(|| {
457			let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
458			let native_token_id: H160 = hex!("5615deb798bb3e4dfa0139dfa1b3d433cc23b72f").into();
459			let dot_location = Location::parent();
460			let (foreign_token_id, _) = reanchor_to_ethereum(
461				dot_location.clone(),
462				EthereumLocation::get(),
463				BridgeHubContext::get(),
464			);
465			add_location_override(dot_location, EthereumLocation::get(), BridgeHubContext::get());
466			let beneficiary: Location =
467				hex!("908783d8cd24c9e02cee1d26ab9c46d458621ad0150b626c536a40b9df3f09c6").into();
468			let token_value = 3_000_000_000_000u128;
469			let assets = vec![
470				EthereumAsset::NativeTokenERC20 { token_id: native_token_id, value: token_value },
471				EthereumAsset::ForeignTokenERC20 { token_id: foreign_token_id, value: token_value },
472			];
473			let instructions = vec![DepositAsset {
474				assets: Wild(AllCounted(1).into()),
475				beneficiary: beneficiary.clone(),
476			}];
477			let xcm: Xcm<()> = instructions.into();
478			let versioned_xcm = VersionedXcm::V5(xcm);
479			let claimer_location =
480				Location::new(0, AccountId32 { network: None, id: H256::random().into() });
481			let claimer: Option<Vec<u8>> = Some(claimer_location.clone().encode());
482			let value = 6_000_000_000_000u128;
483			let execution_fee = 1_000_000_000_000u128;
484			let relayer_fee = 5_000_000_000_000u128;
485
486			let message = Message {
487				gateway: H160::zero(),
488				nonce: 0,
489				origin,
490				assets,
491				xcm: XcmPayload::Raw(versioned_xcm.encode()),
492				claimer,
493				value,
494				execution_fee,
495				relayer_fee,
496			};
497
498			let result = Converter::convert(message);
499
500			assert_ok!(result.clone());
501
502			let xcm = result.unwrap();
503
504			// Convert to vec for easier inspection
505			let instructions: Vec<_> = xcm.into_iter().collect();
506
507			// Check last instruction is a SetTopic (automatically added)
508			let last_instruction =
509				instructions.last().expect("should have at least one instruction");
510			assert!(matches!(last_instruction, SetTopic(_)), "Last instruction should be SetTopic");
511
512			let mut asset_claimer_found = false;
513			let mut pay_fees_found = false;
514			let mut descend_origin_found = 0;
515			let mut reserve_deposited_found = 0;
516			let mut withdraw_assets_found = 0;
517			let mut deposit_asset_found = 0;
518
519			for instruction in &instructions {
520				if let SetHints { ref hints } = instruction {
521					if let Some(AssetClaimer { ref location }) = hints.clone().into_iter().next() {
522						assert_eq!(claimer_location, location.clone());
523						asset_claimer_found = true;
524					}
525				}
526				if let DescendOrigin(ref location) = instruction {
527					descend_origin_found += 1;
528					// The second DescendOrigin should be the message.origin (sender)
529					if descend_origin_found == 2 {
530						let junctions: Junctions =
531							AccountKey20 { key: origin.into(), network: None }.into();
532						assert_eq!(junctions, location.clone());
533					}
534				}
535				if let PayFees { ref asset } = instruction {
536					let fee_asset = Location::new(2, [GlobalConsensus(EthereumNetwork::get())]);
537					assert_eq!(asset.id, AssetId(fee_asset));
538					assert_eq!(asset.fun, Fungible(execution_fee));
539					pay_fees_found = true;
540				}
541				if let ReserveAssetDeposited(ref reserve_assets) = instruction {
542					reserve_deposited_found += 1;
543					if reserve_deposited_found == 1 {
544						let fee_asset = Location::new(2, [GlobalConsensus(EthereumNetwork::get())]);
545						let fee: Asset = (fee_asset, execution_fee).into();
546						let fee_assets: Assets = fee.into();
547						assert_eq!(fee_assets, reserve_assets.clone());
548					}
549					if reserve_deposited_found == 2 {
550						let token_asset = Location::new(
551							2,
552							[
553								GlobalConsensus(EthereumNetwork::get()),
554								AccountKey20 { network: None, key: native_token_id.into() },
555							],
556						);
557						let token: Asset = (token_asset, token_value).into();
558
559						let remaining_ether_asset: Asset =
560							(Location::new(2, [GlobalConsensus(EthereumNetwork::get())]), value)
561								.into();
562
563						let expected_assets: Assets = vec![token, remaining_ether_asset].into();
564						assert_eq!(expected_assets, reserve_assets.clone());
565					}
566				}
567				if let WithdrawAsset(ref withdraw_assets) = instruction {
568					withdraw_assets_found += 1;
569					let token_asset = Location::new(1, Here);
570					let token: Asset = (token_asset, token_value).into();
571					let token_assets: Assets = token.into();
572					assert_eq!(token_assets, withdraw_assets.clone());
573				}
574				if let DepositAsset { ref assets, beneficiary: deposit_beneficiary } = instruction {
575					deposit_asset_found += 1;
576					if deposit_asset_found == 1 {
577						assert_eq!(AssetFilter::from(Wild(AllCounted(1).into())), assets.clone());
578						assert_eq!(*deposit_beneficiary, beneficiary);
579					}
580				}
581			}
582
583			// SetAssetClaimer must be in the message.
584			assert!(asset_claimer_found);
585			// PayFees must be in the message.
586			assert!(pay_fees_found);
587			// The first DescendOrigin to descend into the InboundV2 pallet index and the
588			// DescendOrigin into the message.origin
589			assert!(descend_origin_found == 2);
590			// Expecting two ReserveAssetDeposited instructions, one for the fee and one for the
591			// token being transferred.
592			assert!(reserve_deposited_found == 2);
593			// Expecting one WithdrawAsset for the foreign ERC-20
594			assert!(withdraw_assets_found == 1);
595			// Deposit asset added by user
596			assert!(deposit_asset_found == 1);
597		});
598	}
599
600	#[test]
601	fn test_message_with_gateway_origin_does_not_descend_origin_into_sender() {
602		let origin: H160 = GatewayAddress::get();
603		let native_token_id: H160 = hex!("5615deb798bb3e4dfa0139dfa1b3d433cc23b72f").into();
604		let beneficiary =
605			hex!("908783d8cd24c9e02cee1d26ab9c46d458621ad0150b626c536a40b9df3f09c6").into();
606		let message_id: H256 =
607			hex!("8b69c7e376e28114618e829a7ec768dbda28357d359ba417a3bd79b11215059d").into();
608		let token_value = 3_000_000_000_000u128;
609		let assets =
610			vec![EthereumAsset::NativeTokenERC20 { token_id: native_token_id, value: token_value }];
611		let instructions = vec![
612			DepositAsset { assets: Wild(AllCounted(1).into()), beneficiary },
613			SetTopic(message_id.into()),
614		];
615		let xcm: Xcm<()> = instructions.into();
616		let versioned_xcm = VersionedXcm::V5(xcm);
617		let claimer_account = AccountId32 { network: None, id: H256::random().into() };
618		let claimer: Option<Vec<u8>> = Some(claimer_account.clone().encode());
619		let value = 6_000_000_000_000u128;
620		let execution_fee = 1_000_000_000_000u128;
621		let relayer_fee = 5_000_000_000_000u128;
622
623		let message = Message {
624			gateway: H160::zero(),
625			nonce: 0,
626			origin,
627			assets,
628			xcm: XcmPayload::Raw(versioned_xcm.encode()),
629			claimer,
630			value,
631			execution_fee,
632			relayer_fee,
633		};
634
635		let result = Converter::convert(message);
636
637		assert_ok!(result.clone());
638
639		let xcm = result.unwrap();
640
641		let mut instructions = xcm.into_iter();
642		let mut commands_found = 0;
643		while let Some(instruction) = instructions.next() {
644			if let DescendOrigin(ref _location) = instruction {
645				commands_found = commands_found + 1;
646			}
647		}
648		// There should only be 1 DescendOrigin in the message.
649		assert!(commands_found == 1);
650	}
651
652	#[test]
653	fn test_invalid_foreign_erc20() {
654		let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
655		let token_id: H256 =
656			hex!("37a6c666da38711a963d938eafdd09314fd3f95a96a3baffb55f26560f4ecdd8").into();
657		let beneficiary =
658			hex!("908783d8cd24c9e02cee1d26ab9c46d458621ad0150b626c536a40b9df3f09c6").into();
659		let message_id: H256 =
660			hex!("8b69c7e376e28114618e829a7ec768dbda28357d359ba417a3bd79b11215059d").into();
661		let token_value = 3_000_000_000_000u128;
662		let assets = vec![EthereumAsset::ForeignTokenERC20 { token_id, value: token_value }];
663		let instructions = vec![
664			DepositAsset { assets: Wild(AllCounted(1).into()), beneficiary },
665			SetTopic(message_id.into()),
666		];
667		let xcm: Xcm<()> = instructions.into();
668		let versioned_xcm = VersionedXcm::V5(xcm);
669		let claimer_account = AccountId32 { network: None, id: H256::random().into() };
670		let claimer: Option<Vec<u8>> = Some(claimer_account.clone().encode());
671		let value = 0;
672		let execution_fee = 1_000_000_000_000u128;
673		let relayer_fee = 5_000_000_000_000u128;
674
675		let message = Message {
676			gateway: H160::zero(),
677			nonce: 0,
678			origin,
679			assets,
680			xcm: XcmPayload::Raw(versioned_xcm.encode()),
681			claimer,
682			value,
683			execution_fee,
684			relayer_fee,
685		};
686
687		assert_err!(ConverterFailing::convert(message), ConvertMessageError::InvalidAsset);
688	}
689
690	#[test]
691	fn test_invalid_claimer() {
692		sp_io::TestExternalities::default().execute_with(|| {
693			let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
694			let native_token_id: H160 = hex!("5615deb798bb3e4dfa0139dfa1b3d433cc23b72f").into();
695			let beneficiary =
696				hex!("908783d8cd24c9e02cee1d26ab9c46d458621ad0150b626c536a40b9df3f09c6").into();
697			let token_value = 3_000_000_000_000u128;
698			let assets = vec![EthereumAsset::NativeTokenERC20 {
699				token_id: native_token_id,
700				value: token_value,
701			}];
702			let instructions =
703				vec![DepositAsset { assets: Wild(AllCounted(1).into()), beneficiary }];
704			let xcm: Xcm<()> = instructions.into();
705			let versioned_xcm = VersionedXcm::V5(xcm);
706			// Invalid claimer location, cannot be decoded into a Location
707			let claimer: Option<Vec<u8>> = Some(vec![]);
708			let value = 6_000_000_000_000u128;
709			let execution_fee = 1_000_000_000_000u128;
710			let relayer_fee = 5_000_000_000_000u128;
711
712			let message = Message {
713				gateway: H160::zero(),
714				nonce: 0,
715				origin,
716				assets,
717				xcm: XcmPayload::Raw(versioned_xcm.encode()),
718				claimer,
719				value,
720				execution_fee,
721				relayer_fee,
722			};
723
724			let result = Converter::convert(message.clone());
725
726			// Invalid claimer does not break the message conversion
727			assert_ok!(result.clone());
728
729			let xcm = result.unwrap();
730			let instructions: Vec<_> = xcm.into_iter().collect();
731
732			// Check last instruction is a SetTopic (automatically added)
733			let last_instruction =
734				instructions.last().expect("should have at least one instruction");
735			assert!(matches!(last_instruction, SetTopic(_)), "Last instruction should be SetTopic");
736
737			let mut actual_claimer: Option<Location> = None;
738			for instruction in &instructions {
739				if let SetHints { ref hints } = instruction {
740					if let Some(AssetClaimer { location }) = hints.clone().into_iter().next() {
741						actual_claimer = Some(location);
742						break;
743					}
744				}
745			}
746
747			// actual claimer should default to Snowbridge sovereign account
748			let bridge_owner = ExternalConsensusLocationsConverterFor::<
749				AssetHubUniversalLocation,
750				[u8; 32],
751			>::convert_location(&Location::new(
752				2,
753				[GlobalConsensus(EthereumNetwork::get())],
754			))
755			.unwrap();
756			assert_eq!(
757				actual_claimer,
758				Some(Location::new(0, [AccountId32 { network: None, id: bridge_owner }]))
759			);
760		});
761	}
762
763	#[test]
764	fn test_invalid_xcm() {
765		sp_io::TestExternalities::default().execute_with(|| {
766			let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
767			let native_token_id: H160 = hex!("5615deb798bb3e4dfa0139dfa1b3d433cc23b72f").into();
768			let token_value = 3_000_000_000_000u128;
769			let assets = vec![EthereumAsset::NativeTokenERC20 {
770				token_id: native_token_id,
771				value: token_value,
772			}];
773			// invalid xcm
774			let versioned_xcm = hex!("8b69c7e376e28114618e829a7ec7").to_vec();
775			let claimer_account = AccountId32 { network: None, id: H256::random().into() };
776			let claimer: Option<Vec<u8>> = Some(claimer_account.clone().encode());
777			let value = 6_000_000_000_000u128;
778			let execution_fee = 1_000_000_000_000u128;
779			let relayer_fee = 5_000_000_000_000u128;
780
781			let message = Message {
782				gateway: H160::zero(),
783				nonce: 0,
784				origin,
785				assets,
786				xcm: XcmPayload::Raw(versioned_xcm),
787				claimer: Some(claimer.encode()),
788				value,
789				execution_fee,
790				relayer_fee,
791			};
792
793			let result = Converter::convert(message);
794
795			// Invalid xcm does not break the message, allowing funds to be trapped on AH.
796			assert_ok!(result.clone());
797		});
798	}
799
800	#[test]
801	fn message_with_set_topic_respects_user_topic() {
802		sp_io::TestExternalities::default().execute_with(|| {
803			let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
804
805			// Create a custom topic ID that the user specifies
806			let user_topic: [u8; 32] =
807				hex!("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef");
808
809			// User's XCM with a SetTopic as the last instruction
810			let instructions = vec![RefundSurplus, SetTopic(user_topic)];
811			let xcm: Xcm<()> = instructions.into();
812			let versioned_xcm = VersionedXcm::V5(xcm);
813
814			let execution_fee = 1_000_000_000_000u128;
815			let value = 0;
816
817			let message = Message {
818				gateway: H160::zero(),
819				nonce: 0,
820				origin,
821				assets: vec![],
822				xcm: XcmPayload::Raw(versioned_xcm.encode()),
823				claimer: None,
824				value,
825				execution_fee,
826				relayer_fee: 0,
827			};
828
829			let result = Converter::convert(message);
830			assert_ok!(result.clone());
831
832			let xcm = result.unwrap();
833			let instructions: Vec<_> = xcm.into_iter().collect();
834
835			// The last instruction should be the user's SetTopic
836			let last_instruction =
837				instructions.last().expect("should have at least one instruction");
838			if let SetTopic(ref topic) = last_instruction {
839				assert_eq!(*topic, user_topic);
840			} else {
841				panic!("Last instruction should be SetTopic");
842			}
843		});
844	}
845
846	#[test]
847	fn message_with_generates_a_unique_topic_if_no_topic_is_present() {
848		sp_io::TestExternalities::default().execute_with(|| {
849			let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
850
851			let execution_fee = 1_000_000_000_000u128;
852			let value = 0;
853
854			let message = Message {
855				gateway: H160::zero(),
856				nonce: 0,
857				origin,
858				assets: vec![],
859				xcm: XcmPayload::Raw(vec![]),
860				claimer: None,
861				value,
862				execution_fee,
863				relayer_fee: 0,
864			};
865
866			let result = Converter::convert(message);
867			assert_ok!(result.clone());
868
869			let xcm = result.unwrap();
870			let instructions: Vec<_> = xcm.into_iter().collect();
871
872			// The last instruction should be a SetTopic
873			let last_instruction =
874				instructions.last().expect("should have at least one instruction");
875			assert!(matches!(last_instruction, SetTopic(_)));
876		});
877	}
878
879	#[test]
880	fn message_with_user_topic_not_last_instruction_gets_appended() {
881		sp_io::TestExternalities::default().execute_with(|| {
882			let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
883
884			let execution_fee = 1_000_000_000_000u128;
885			let value = 0;
886
887			let user_topic: [u8; 32] =
888				hex!("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef");
889
890			// Add a set topic, but not as the last instruction.
891			let instructions = vec![SetTopic(user_topic), RefundSurplus];
892			let xcm: Xcm<()> = instructions.into();
893			let versioned_xcm = VersionedXcm::V5(xcm);
894
895			let message = Message {
896				gateway: H160::zero(),
897				nonce: 0,
898				origin,
899				assets: vec![],
900				xcm: XcmPayload::Raw(versioned_xcm.encode()),
901				claimer: None,
902				value,
903				execution_fee,
904				relayer_fee: 0,
905			};
906
907			let result = Converter::convert(message);
908			assert_ok!(result.clone());
909
910			let xcm = result.unwrap();
911			let instructions: Vec<_> = xcm.into_iter().collect();
912
913			// Get the last instruction - should still be a SetTopic, but might not have the
914			// original topic since for non-last-instruction topics, the filter_topic function
915			// extracts it during prepare() and then the original value is later lost when we
916			// append a new one
917			let last_instruction =
918				instructions.last().expect("should have at least one instruction");
919
920			// Check if the last instruction is a SetTopic (content isn't important)
921			assert!(matches!(last_instruction, SetTopic(_)), "Last instruction should be SetTopic");
922		});
923	}
924}