referrerpolicy=no-referrer-when-downgrade

snowbridge_outbound_queue_primitives/v1/converter/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2023 Snowfork <hello@snowfork.com>
3//! Converts XCM messages into simpler commands that can be processed by the Gateway contract
4
5#[cfg(test)]
6mod tests;
7
8use core::slice::Iter;
9
10use codec::{Decode, Encode};
11
12use super::message::{Command, Message, SendMessage};
13use frame_support::{ensure, traits::Get};
14use snowbridge_core::{AgentId, ChannelId, ParaId, TokenId, TokenIdOf};
15use sp_core::{H160, H256};
16use sp_runtime::traits::MaybeConvert;
17use sp_std::{iter::Peekable, marker::PhantomData, prelude::*};
18use xcm::prelude::*;
19use xcm_executor::traits::{ConvertLocation, ExportXcm};
20
21pub struct EthereumBlobExporter<
22	UniversalLocation,
23	EthereumNetwork,
24	OutboundQueue,
25	AgentHashedDescription,
26	ConvertAssetId,
27>(
28	PhantomData<(
29		UniversalLocation,
30		EthereumNetwork,
31		OutboundQueue,
32		AgentHashedDescription,
33		ConvertAssetId,
34	)>,
35);
36
37impl<UniversalLocation, EthereumNetwork, OutboundQueue, AgentHashedDescription, ConvertAssetId>
38	ExportXcm
39	for EthereumBlobExporter<
40		UniversalLocation,
41		EthereumNetwork,
42		OutboundQueue,
43		AgentHashedDescription,
44		ConvertAssetId,
45	>
46where
47	UniversalLocation: Get<InteriorLocation>,
48	EthereumNetwork: Get<NetworkId>,
49	OutboundQueue: SendMessage<Balance = u128>,
50	AgentHashedDescription: ConvertLocation<H256>,
51	ConvertAssetId: MaybeConvert<TokenId, Location>,
52{
53	type Ticket = (Vec<u8>, XcmHash);
54
55	fn validate(
56		network: NetworkId,
57		_channel: u32,
58		universal_source: &mut Option<InteriorLocation>,
59		destination: &mut Option<InteriorLocation>,
60		message: &mut Option<Xcm<()>>,
61	) -> SendResult<Self::Ticket> {
62		let expected_network = EthereumNetwork::get();
63		let universal_location = UniversalLocation::get();
64
65		if network != expected_network {
66			log::trace!(target: "xcm::ethereum_blob_exporter", "skipped due to unmatched bridge network {network:?}.");
67			return Err(SendError::NotApplicable)
68		}
69
70		// Cloning destination to avoid modifying the value so subsequent exporters can use it.
71		let dest = destination.clone().ok_or(SendError::MissingArgument)?;
72		if dest != Here {
73			log::trace!(target: "xcm::ethereum_blob_exporter", "skipped due to unmatched remote destination {dest:?}.");
74			return Err(SendError::NotApplicable)
75		}
76
77		// Cloning universal_source to avoid modifying the value so subsequent exporters can use it.
78		let (local_net, local_sub) = universal_source.clone()
79			.ok_or_else(|| {
80				log::error!(target: "xcm::ethereum_blob_exporter", "universal source not provided.");
81				SendError::MissingArgument
82			})?
83			.split_global()
84			.map_err(|()| {
85				log::error!(target: "xcm::ethereum_blob_exporter", "could not get global consensus from universal source '{universal_source:?}'.");
86				SendError::NotApplicable
87			})?;
88
89		if Ok(local_net) != universal_location.global_consensus() {
90			log::trace!(target: "xcm::ethereum_blob_exporter", "skipped due to unmatched relay network {local_net:?}.");
91			return Err(SendError::NotApplicable)
92		}
93
94		let para_id = match local_sub.as_slice() {
95			[Parachain(para_id)] => *para_id,
96			_ => {
97				log::error!(target: "xcm::ethereum_blob_exporter", "could not get parachain id from universal source '{local_sub:?}'.");
98				return Err(SendError::NotApplicable)
99			},
100		};
101
102		let source_location = Location::new(1, local_sub.clone());
103
104		let agent_id = match AgentHashedDescription::convert_location(&source_location) {
105			Some(id) => id,
106			None => {
107				log::error!(target: "xcm::ethereum_blob_exporter", "unroutable due to not being able to create agent id. '{source_location:?}'");
108				return Err(SendError::NotApplicable)
109			},
110		};
111
112		let message = message.take().ok_or_else(|| {
113			log::error!(target: "xcm::ethereum_blob_exporter", "xcm message not provided.");
114			SendError::MissingArgument
115		})?;
116
117		let mut converter =
118			XcmConverter::<ConvertAssetId, ()>::new(&message, expected_network, agent_id);
119		let (command, message_id) = converter.convert().map_err(|err|{
120			log::error!(target: "xcm::ethereum_blob_exporter", "unroutable due to pattern matching error '{err:?}'.");
121			SendError::Unroutable
122		})?;
123
124		let channel_id: ChannelId = ParaId::from(para_id).into();
125
126		let outbound_message = Message { id: Some(message_id.into()), channel_id, command };
127
128		// validate the message
129		let (ticket, fee) = OutboundQueue::validate(&outbound_message).map_err(|err| {
130			log::error!(target: "xcm::ethereum_blob_exporter", "OutboundQueue validation of message failed. {err:?}");
131			SendError::Unroutable
132		})?;
133
134		// convert fee to Asset
135		let fee = Asset::from((Location::parent(), fee.total())).into();
136
137		Ok(((ticket.encode(), message_id), fee))
138	}
139
140	fn deliver(blob: (Vec<u8>, XcmHash)) -> Result<XcmHash, SendError> {
141		let ticket: OutboundQueue::Ticket = OutboundQueue::Ticket::decode(&mut blob.0.as_ref())
142			.map_err(|_| {
143				log::trace!(target: "xcm::ethereum_blob_exporter", "undeliverable due to decoding error");
144				SendError::NotApplicable
145			})?;
146
147		let message_id = OutboundQueue::deliver(ticket).map_err(|_| {
148			log::error!(target: "xcm::ethereum_blob_exporter", "OutboundQueue submit of message failed");
149			SendError::Transport("other transport error")
150		})?;
151
152		log::info!(target: "xcm::ethereum_blob_exporter", "message delivered {message_id:#?}.");
153		Ok(message_id.into())
154	}
155}
156
157/// Errors that can be thrown to the pattern matching step.
158#[derive(PartialEq, Debug)]
159enum XcmConverterError {
160	UnexpectedEndOfXcm,
161	EndOfXcmMessageExpected,
162	WithdrawAssetExpected,
163	DepositAssetExpected,
164	NoReserveAssets,
165	FilterDoesNotConsumeAllAssets,
166	TooManyAssets,
167	ZeroAssetTransfer,
168	BeneficiaryResolutionFailed,
169	AssetResolutionFailed,
170	InvalidFeeAsset,
171	SetTopicExpected,
172	ReserveAssetDepositedExpected,
173	InvalidAsset,
174	UnexpectedInstruction,
175}
176
177/// Macro used for capturing values when the pattern matches.
178/// Specifically here for matching against xcm instructions and capture the params in that
179/// instruction
180macro_rules! match_expression {
181	($expression:expr, $(|)? $( $pattern:pat_param )|+ $( if $guard: expr )?, $value:expr $(,)?) => {
182		match $expression {
183			$( $pattern )|+ $( if $guard )? => Some($value),
184			_ => None,
185		}
186	};
187}
188
189struct XcmConverter<'a, ConvertAssetId, Call> {
190	iter: Peekable<Iter<'a, Instruction<Call>>>,
191	ethereum_network: NetworkId,
192	agent_id: AgentId,
193	_marker: PhantomData<ConvertAssetId>,
194}
195impl<'a, ConvertAssetId, Call> XcmConverter<'a, ConvertAssetId, Call>
196where
197	ConvertAssetId: MaybeConvert<TokenId, Location>,
198{
199	fn new(message: &'a Xcm<Call>, ethereum_network: NetworkId, agent_id: AgentId) -> Self {
200		Self {
201			iter: message.inner().iter().peekable(),
202			ethereum_network,
203			agent_id,
204			_marker: Default::default(),
205		}
206	}
207
208	fn convert(&mut self) -> Result<(Command, [u8; 32]), XcmConverterError> {
209		let result = match self.peek() {
210			Ok(ReserveAssetDeposited { .. }) => self.make_mint_foreign_token_command(),
211			// Get withdraw/deposit and make native tokens create message.
212			Ok(WithdrawAsset { .. }) => self.make_unlock_native_token_command(),
213			Err(e) => Err(e),
214			_ => return Err(XcmConverterError::UnexpectedInstruction),
215		}?;
216
217		// All xcm instructions must be consumed before exit.
218		if self.next().is_ok() {
219			return Err(XcmConverterError::EndOfXcmMessageExpected)
220		}
221
222		Ok(result)
223	}
224
225	fn make_unlock_native_token_command(
226		&mut self,
227	) -> Result<(Command, [u8; 32]), XcmConverterError> {
228		use XcmConverterError::*;
229
230		// Get the reserve assets from WithdrawAsset.
231		let reserve_assets =
232			match_expression!(self.next()?, WithdrawAsset(reserve_assets), reserve_assets)
233				.ok_or(WithdrawAssetExpected)?;
234
235		// Check if clear origin exists and skip over it.
236		if match_expression!(self.peek(), Ok(ClearOrigin), ()).is_some() {
237			let _ = self.next();
238		}
239
240		// Get the fee asset item from BuyExecution or continue parsing.
241		let fee_asset = match_expression!(self.peek(), Ok(BuyExecution { fees, .. }), fees);
242		if fee_asset.is_some() {
243			let _ = self.next();
244		}
245
246		let (deposit_assets, beneficiary) = match_expression!(
247			self.next()?,
248			DepositAsset { assets, beneficiary },
249			(assets, beneficiary)
250		)
251		.ok_or(DepositAssetExpected)?;
252
253		// assert that the beneficiary is AccountKey20.
254		let recipient = match_expression!(
255			beneficiary.unpack(),
256			(0, [AccountKey20 { network, key }])
257				if self.network_matches(network),
258			H160(*key)
259		)
260		.ok_or(BeneficiaryResolutionFailed)?;
261
262		// Make sure there are reserved assets.
263		if reserve_assets.len() == 0 {
264			return Err(NoReserveAssets)
265		}
266
267		// Check the the deposit asset filter matches what was reserved.
268		if reserve_assets.inner().iter().any(|asset| !deposit_assets.matches(asset)) {
269			return Err(FilterDoesNotConsumeAllAssets)
270		}
271
272		// We only support a single asset at a time.
273		ensure!(reserve_assets.len() == 1, TooManyAssets);
274		let reserve_asset = reserve_assets.get(0).ok_or(AssetResolutionFailed)?;
275
276		// Fees are collected on AH, up front and directly from the user, to cover the
277		// complete cost of the transfer. Any additional fees provided in the XCM program are
278		// refunded to the beneficiary. We only validate the fee here if its provided to make sure
279		// the XCM program is well formed. Another way to think about this from an XCM perspective
280		// would be that the user offered to pay X amount in fees, but we charge 0 of that X amount
281		// (no fee) and refund X to the user.
282		if let Some(fee_asset) = fee_asset {
283			// The fee asset must be the same as the reserve asset.
284			if fee_asset.id != reserve_asset.id || fee_asset.fun > reserve_asset.fun {
285				return Err(InvalidFeeAsset)
286			}
287		}
288
289		let (token, amount) = match reserve_asset {
290			Asset { id: AssetId(inner_location), fun: Fungible(amount) } =>
291				match inner_location.unpack() {
292					// Get the ERC20 contract address of the token.
293					(0, [AccountKey20 { network, key }]) if self.network_matches(network) =>
294						Some((H160(*key), *amount)),
295					// If there is no ERC20 contract address in the location then signal to the
296					// gateway that is a native Ether transfer by using
297					// `0x0000000000000000000000000000000000000000` as the token address.
298					(0, []) => Some((H160([0; 20]), *amount)),
299					_ => None,
300				},
301			_ => None,
302		}
303		.ok_or(AssetResolutionFailed)?;
304
305		// transfer amount must be greater than 0.
306		ensure!(amount > 0, ZeroAssetTransfer);
307
308		// Check if there is a SetTopic and skip over it if found.
309		let topic_id = match_expression!(self.next()?, SetTopic(id), id).ok_or(SetTopicExpected)?;
310
311		Ok((
312			Command::UnlockNativeToken { agent_id: self.agent_id, token, recipient, amount },
313			*topic_id,
314		))
315	}
316
317	fn next(&mut self) -> Result<&'a Instruction<Call>, XcmConverterError> {
318		self.iter.next().ok_or(XcmConverterError::UnexpectedEndOfXcm)
319	}
320
321	fn peek(&mut self) -> Result<&&'a Instruction<Call>, XcmConverterError> {
322		self.iter.peek().ok_or(XcmConverterError::UnexpectedEndOfXcm)
323	}
324
325	fn network_matches(&self, network: &Option<NetworkId>) -> bool {
326		if let Some(network) = network {
327			*network == self.ethereum_network
328		} else {
329			true
330		}
331	}
332
333	/// Convert the xcm for Polkadot-native token from AH into the Command
334	/// To match transfers of Polkadot-native tokens, we expect an input of the form:
335	/// # ReserveAssetDeposited
336	/// # ClearOrigin
337	/// # BuyExecution
338	/// # DepositAsset
339	/// # SetTopic
340	fn make_mint_foreign_token_command(
341		&mut self,
342	) -> Result<(Command, [u8; 32]), XcmConverterError> {
343		use XcmConverterError::*;
344
345		// Get the reserve assets.
346		let reserve_assets =
347			match_expression!(self.next()?, ReserveAssetDeposited(reserve_assets), reserve_assets)
348				.ok_or(ReserveAssetDepositedExpected)?;
349
350		// Check if clear origin exists and skip over it.
351		if match_expression!(self.peek(), Ok(ClearOrigin), ()).is_some() {
352			let _ = self.next();
353		}
354
355		// Get the fee asset item from BuyExecution or continue parsing.
356		let fee_asset = match_expression!(self.peek(), Ok(BuyExecution { fees, .. }), fees);
357		if fee_asset.is_some() {
358			let _ = self.next();
359		}
360
361		let (deposit_assets, beneficiary) = match_expression!(
362			self.next()?,
363			DepositAsset { assets, beneficiary },
364			(assets, beneficiary)
365		)
366		.ok_or(DepositAssetExpected)?;
367
368		// assert that the beneficiary is AccountKey20.
369		let recipient = match_expression!(
370			beneficiary.unpack(),
371			(0, [AccountKey20 { network, key }])
372				if self.network_matches(network),
373			H160(*key)
374		)
375		.ok_or(BeneficiaryResolutionFailed)?;
376
377		// Make sure there are reserved assets.
378		if reserve_assets.len() == 0 {
379			return Err(NoReserveAssets)
380		}
381
382		// Check the the deposit asset filter matches what was reserved.
383		if reserve_assets.inner().iter().any(|asset| !deposit_assets.matches(asset)) {
384			return Err(FilterDoesNotConsumeAllAssets)
385		}
386
387		// We only support a single asset at a time.
388		ensure!(reserve_assets.len() == 1, TooManyAssets);
389		let reserve_asset = reserve_assets.get(0).ok_or(AssetResolutionFailed)?;
390
391		// Fees are collected on AH, up front and directly from the user, to cover the
392		// complete cost of the transfer. Any additional fees provided in the XCM program are
393		// refunded to the beneficiary. We only validate the fee here if its provided to make sure
394		// the XCM program is well formed. Another way to think about this from an XCM perspective
395		// would be that the user offered to pay X amount in fees, but we charge 0 of that X amount
396		// (no fee) and refund X to the user.
397		if let Some(fee_asset) = fee_asset {
398			// The fee asset must be the same as the reserve asset.
399			if fee_asset.id != reserve_asset.id || fee_asset.fun > reserve_asset.fun {
400				return Err(InvalidFeeAsset)
401			}
402		}
403
404		let (asset_id, amount) = match reserve_asset {
405			Asset { id: AssetId(inner_location), fun: Fungible(amount) } =>
406				Some((inner_location.clone(), *amount)),
407			_ => None,
408		}
409		.ok_or(AssetResolutionFailed)?;
410
411		// transfer amount must be greater than 0.
412		ensure!(amount > 0, ZeroAssetTransfer);
413
414		let token_id = TokenIdOf::convert_location(&asset_id).ok_or(InvalidAsset)?;
415
416		ConvertAssetId::maybe_convert(token_id).ok_or(InvalidAsset)?;
417
418		// Check if there is a SetTopic and skip over it if found.
419		let topic_id = match_expression!(self.next()?, SetTopic(id), id).ok_or(SetTopicExpected)?;
420
421		Ok((Command::MintForeignToken { token_id, recipient, amount }, *topic_id))
422	}
423}