959 lines
30 KiB
Rust
959 lines
30 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: 2023 Snowfork <hello@snowfork.com>
|
|
//! Converts messages from Solidity ABI-encoding to XCM
|
|
|
|
use super::{message::*, traits::*};
|
|
use crate::{v2::LOG_TARGET, CallIndex};
|
|
use codec::{Decode, DecodeLimit, Encode};
|
|
use core::marker::PhantomData;
|
|
use frame_support::ensure;
|
|
use snowbridge_core::{ParaId, TokenId};
|
|
use sp_core::{Get, RuntimeDebug, H160};
|
|
use sp_io::hashing::blake2_256;
|
|
use sp_runtime::{traits::MaybeConvert, MultiAddress};
|
|
use sp_std::prelude::*;
|
|
use xcm::{
|
|
prelude::{Junction::*, *},
|
|
MAX_XCM_DECODE_DEPTH,
|
|
};
|
|
use xcm_builder::ExternalConsensusLocationsConverterFor;
|
|
use xcm_executor::traits::ConvertLocation;
|
|
|
|
/// Topic prefix used for generating unique identifiers for messages
|
|
const INBOUND_QUEUE_TOPIC_PREFIX: &str = "SnowbridgeInboundQueueV2";
|
|
|
|
/// Representation of an intermediate parsed message, before final
|
|
/// conversion to XCM.
|
|
#[derive(Clone, RuntimeDebug, Encode)]
|
|
pub struct PreparedMessage {
|
|
/// Ethereum account that initiated this messaging operation
|
|
pub origin: H160,
|
|
/// The claimer in the case that funds get trapped.
|
|
pub claimer: Location,
|
|
/// The assets bridged from Ethereum
|
|
pub assets: Vec<AssetTransfer>,
|
|
/// The XCM to execute on the destination
|
|
pub remote_xcm: Xcm<()>,
|
|
/// Fee in Ether to cover the xcm execution on AH.
|
|
pub execution_fee: Asset,
|
|
}
|
|
|
|
/// An asset transfer instruction
|
|
#[derive(Clone, RuntimeDebug, Encode)]
|
|
pub enum AssetTransfer {
|
|
ReserveDeposit(Asset),
|
|
ReserveWithdraw(Asset),
|
|
}
|
|
|
|
#[derive(Clone, RuntimeDebug, Encode)]
|
|
pub struct CreateAssetCallInfo {
|
|
pub create_call: CallIndex,
|
|
pub deposit: u128,
|
|
pub min_balance: u128,
|
|
pub set_reserves_call: CallIndex,
|
|
}
|
|
|
|
pub struct AssetHubUniversal<LocalNetwork, AssetHubParaId>(
|
|
PhantomData<(LocalNetwork, AssetHubParaId)>,
|
|
);
|
|
impl<LocalNetwork, AssetHubParaId> Get<InteriorLocation>
|
|
for AssetHubUniversal<LocalNetwork, AssetHubParaId>
|
|
where
|
|
LocalNetwork: Get<NetworkId>,
|
|
AssetHubParaId: Get<ParaId>,
|
|
{
|
|
fn get() -> InteriorLocation {
|
|
[GlobalConsensus(LocalNetwork::get()), Teyrchain(AssetHubParaId::get().into())].into()
|
|
}
|
|
}
|
|
|
|
/// Concrete implementation of `ConvertMessage`
|
|
pub struct MessageToXcm<
|
|
CreateAssetCall,
|
|
EthereumNetwork,
|
|
LocalNetwork,
|
|
GatewayProxyAddress,
|
|
InboundQueueLocation,
|
|
AssetHubParaId,
|
|
ConvertAssetId,
|
|
AccountId,
|
|
> {
|
|
_phantom: PhantomData<(
|
|
CreateAssetCall,
|
|
EthereumNetwork,
|
|
LocalNetwork,
|
|
GatewayProxyAddress,
|
|
InboundQueueLocation,
|
|
AssetHubParaId,
|
|
ConvertAssetId,
|
|
AccountId,
|
|
)>,
|
|
}
|
|
|
|
impl<
|
|
CreateAssetCall,
|
|
EthereumNetwork,
|
|
LocalNetwork,
|
|
GatewayProxyAddress,
|
|
InboundQueueLocation,
|
|
AssetHubParaId,
|
|
ConvertAssetId,
|
|
AccountId,
|
|
>
|
|
MessageToXcm<
|
|
CreateAssetCall,
|
|
EthereumNetwork,
|
|
LocalNetwork,
|
|
GatewayProxyAddress,
|
|
InboundQueueLocation,
|
|
AssetHubParaId,
|
|
ConvertAssetId,
|
|
AccountId,
|
|
>
|
|
where
|
|
CreateAssetCall: Get<CreateAssetCallInfo>,
|
|
EthereumNetwork: Get<NetworkId>,
|
|
LocalNetwork: Get<NetworkId>,
|
|
GatewayProxyAddress: Get<H160>,
|
|
InboundQueueLocation: Get<InteriorLocation>,
|
|
AssetHubParaId: Get<ParaId>,
|
|
ConvertAssetId: MaybeConvert<TokenId, Location>,
|
|
AccountId: Into<[u8; 32]> + From<[u8; 32]> + Clone,
|
|
{
|
|
/// Parse the message into an intermediate form, with all fields decoded
|
|
/// and prepared.
|
|
fn prepare(message: Message) -> Result<PreparedMessage, ConvertMessageError> {
|
|
// ETH "asset id" is the Ethereum root location. Same location used for the "bridge owner".
|
|
let ether_location = Location::new(2, [GlobalConsensus(EthereumNetwork::get())]);
|
|
let bridge_owner = Self::bridge_owner()?;
|
|
|
|
let claimer = message
|
|
.claimer
|
|
// Get the claimer from the message,
|
|
.and_then(|claimer_bytes| Location::decode(&mut claimer_bytes.as_ref()).ok())
|
|
// or use the Snowbridge sovereign on AH as the fallback claimer.
|
|
.unwrap_or_else(|| {
|
|
Location::new(0, [AccountId32 { network: None, id: bridge_owner.clone().into() }])
|
|
});
|
|
|
|
let mut remote_xcm: Xcm<()> = match &message.xcm {
|
|
XcmPayload::Raw(raw) => Self::decode_raw_xcm(raw),
|
|
XcmPayload::CreateAsset { token, network } => Self::make_create_asset_xcm(
|
|
token,
|
|
*network,
|
|
message.value,
|
|
bridge_owner,
|
|
claimer.clone(),
|
|
)?,
|
|
};
|
|
|
|
// Asset to cover XCM execution fee
|
|
let execution_fee_asset: Asset = (ether_location.clone(), message.execution_fee).into();
|
|
|
|
let mut assets = vec![];
|
|
|
|
if message.value > 0 {
|
|
// Asset for remaining ether
|
|
let remaining_ether_asset: Asset = (ether_location.clone(), message.value).into();
|
|
assets.push(AssetTransfer::ReserveDeposit(remaining_ether_asset));
|
|
}
|
|
|
|
for asset in &message.assets {
|
|
match asset {
|
|
EthereumAsset::NativeTokenERC20 { token_id, value } => {
|
|
ensure!(*token_id != H160::zero(), ConvertMessageError::InvalidAsset);
|
|
let token_location: Location = Location::new(
|
|
2,
|
|
[
|
|
GlobalConsensus(EthereumNetwork::get()),
|
|
AccountKey20 { network: None, key: (*token_id).into() },
|
|
],
|
|
);
|
|
let asset: Asset = (token_location, *value).into();
|
|
assets.push(AssetTransfer::ReserveDeposit(asset));
|
|
},
|
|
EthereumAsset::ForeignTokenERC20 { token_id, value } => {
|
|
let asset_location = ConvertAssetId::maybe_convert(*token_id)
|
|
.ok_or(ConvertMessageError::InvalidAsset)?;
|
|
let asset_hub_from_ethereum: Location = Location::new(
|
|
1,
|
|
[
|
|
GlobalConsensus(LocalNetwork::get()),
|
|
Teyrchain(AssetHubParaId::get().into()),
|
|
],
|
|
);
|
|
let ethereum_universal: InteriorLocation =
|
|
[GlobalConsensus(EthereumNetwork::get())].into();
|
|
let reanchored_asset_location = asset_location
|
|
.reanchored(&asset_hub_from_ethereum, ðereum_universal)
|
|
.map_err(|_| ConvertMessageError::CannotReanchor)?;
|
|
let asset: Asset = (reanchored_asset_location, *value).into();
|
|
assets.push(AssetTransfer::ReserveWithdraw(asset));
|
|
},
|
|
}
|
|
}
|
|
|
|
// Add SetTopic instruction if not already present as the last instruction
|
|
if !matches!(remote_xcm.0.last(), Some(SetTopic(_))) {
|
|
let topic = blake2_256(&(INBOUND_QUEUE_TOPIC_PREFIX, message.nonce).encode());
|
|
remote_xcm.0.push(SetTopic(topic));
|
|
}
|
|
|
|
let prepared_message = PreparedMessage {
|
|
origin: message.origin,
|
|
claimer,
|
|
assets,
|
|
remote_xcm,
|
|
execution_fee: execution_fee_asset,
|
|
};
|
|
|
|
Ok(prepared_message)
|
|
}
|
|
|
|
/// Get sovereign account of Ethereum on Asset Hub.
|
|
fn bridge_owner() -> Result<AccountId, ConvertMessageError> {
|
|
let account =
|
|
ExternalConsensusLocationsConverterFor::<
|
|
AssetHubUniversal<LocalNetwork, AssetHubParaId>,
|
|
AccountId,
|
|
>::convert_location(&Location::new(2, [GlobalConsensus(EthereumNetwork::get())]))
|
|
.ok_or(ConvertMessageError::CannotReanchor)?;
|
|
|
|
Ok(account)
|
|
}
|
|
|
|
/// Construct the remote XCM needed to create a new asset in the `ForeignAssets` pallet
|
|
/// on AssetHub. Pezkuwi is the only supported network at the moment.
|
|
fn make_create_asset_xcm(
|
|
token: &H160,
|
|
network: super::message::Network,
|
|
eth_value: u128,
|
|
bridge_owner: AccountId,
|
|
claimer: Location,
|
|
) -> Result<Xcm<()>, ConvertMessageError> {
|
|
let dot_asset = Location::new(1, Here);
|
|
let dot_fee: xcm::prelude::Asset = (dot_asset, CreateAssetCall::get().deposit).into();
|
|
|
|
let eth_asset: xcm::prelude::Asset =
|
|
(Location::new(2, [GlobalConsensus(EthereumNetwork::get())]), eth_value).into();
|
|
|
|
let create_call_index: [u8; 2] = CreateAssetCall::get().create_call;
|
|
let create_min_blance: u128 = CreateAssetCall::get().min_balance;
|
|
let set_reserves_call_index: [u8; 2] = CreateAssetCall::get().set_reserves_call;
|
|
|
|
let asset_id = Location::new(
|
|
2,
|
|
[
|
|
GlobalConsensus(EthereumNetwork::get()),
|
|
AccountKey20 { network: None, key: (*token).into() },
|
|
],
|
|
);
|
|
|
|
match network {
|
|
super::message::Network::Pezkuwi => Ok(Self::make_create_asset_xcm_for_pezkuwi(
|
|
create_call_index,
|
|
set_reserves_call_index,
|
|
create_min_blance,
|
|
asset_id,
|
|
bridge_owner,
|
|
dot_fee,
|
|
eth_asset,
|
|
claimer,
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Construct the asset creation XCM for the Polkdot network.
|
|
fn make_create_asset_xcm_for_pezkuwi(
|
|
create_call_index: [u8; 2],
|
|
set_reserves_call_index: [u8; 2],
|
|
create_min_blance: u128,
|
|
asset_id: Location,
|
|
bridge_owner: AccountId,
|
|
dot_fee_asset: xcm::prelude::Asset,
|
|
eth_asset: xcm::prelude::Asset,
|
|
claimer: Location,
|
|
) -> Xcm<()> {
|
|
let bridge_owner_bytes: [u8; 32] = bridge_owner.into();
|
|
let reserve_data = assets_common::local_and_foreign_assets::ForeignAssetReserveData {
|
|
reserve: Location::new(2, [GlobalConsensus(EthereumNetwork::get())]),
|
|
teleportable: false,
|
|
};
|
|
vec![
|
|
// Exchange eth for dot to pay the asset creation deposit.
|
|
ExchangeAsset {
|
|
give: eth_asset.into(),
|
|
want: dot_fee_asset.clone().into(),
|
|
maximal: false,
|
|
},
|
|
// Deposit the dot deposit into the bridge sovereign account (where the asset
|
|
// creation fee will be deducted from).
|
|
DepositAsset {
|
|
assets: dot_fee_asset.clone().into(),
|
|
beneficiary: bridge_owner_bytes.into(),
|
|
},
|
|
// Call to create the asset.
|
|
Transact {
|
|
origin_kind: OriginKind::Xcm,
|
|
fallback_max_weight: None,
|
|
call: (
|
|
create_call_index,
|
|
asset_id.clone(),
|
|
MultiAddress::<[u8; 32], ()>::Id(bridge_owner_bytes.into()),
|
|
create_min_blance,
|
|
)
|
|
.encode()
|
|
.into(),
|
|
},
|
|
// Call to set Ethereum as the asset's reserve.
|
|
Transact {
|
|
origin_kind: OriginKind::Xcm,
|
|
fallback_max_weight: None,
|
|
call: (set_reserves_call_index, asset_id, vec![reserve_data]).encode().into(),
|
|
},
|
|
RefundSurplus,
|
|
// Deposit leftover funds to Snowbridge sovereign
|
|
DepositAsset { assets: Wild(AllCounted(2)), beneficiary: claimer },
|
|
]
|
|
.into()
|
|
}
|
|
|
|
/// Parse and (non-strictly) decode `raw` XCM bytes into a `Xcm<()>`.
|
|
/// If decoding fails, return an empty `Xcm<()>`—thus allowing the message
|
|
/// to proceed so assets can still be trapped on AH rather than the funds being locked on
|
|
/// Ethereum but not accessible on AH.
|
|
fn decode_raw_xcm(raw: &[u8]) -> Xcm<()> {
|
|
let mut data = raw;
|
|
if let Ok(versioned_xcm) =
|
|
VersionedXcm::<()>::decode_with_depth_limit(MAX_XCM_DECODE_DEPTH, &mut data)
|
|
{
|
|
if let Ok(decoded_xcm) = versioned_xcm.try_into() {
|
|
return decoded_xcm;
|
|
}
|
|
}
|
|
// Decoding failed; allow an empty XCM so the message won't fail entirely.
|
|
Xcm::new()
|
|
}
|
|
}
|
|
|
|
impl<
|
|
CreateAsset,
|
|
EthereumNetwork,
|
|
LocalNetwork,
|
|
GatewayProxyAddress,
|
|
InboundQueueLocation,
|
|
AssetHubParaId,
|
|
ConvertAssetId,
|
|
AccountId,
|
|
> ConvertMessage
|
|
for MessageToXcm<
|
|
CreateAsset,
|
|
EthereumNetwork,
|
|
LocalNetwork,
|
|
GatewayProxyAddress,
|
|
InboundQueueLocation,
|
|
AssetHubParaId,
|
|
ConvertAssetId,
|
|
AccountId,
|
|
>
|
|
where
|
|
CreateAsset: Get<CreateAssetCallInfo>,
|
|
EthereumNetwork: Get<NetworkId>,
|
|
LocalNetwork: Get<NetworkId>,
|
|
GatewayProxyAddress: Get<H160>,
|
|
InboundQueueLocation: Get<InteriorLocation>,
|
|
AssetHubParaId: Get<ParaId>,
|
|
ConvertAssetId: MaybeConvert<TokenId, Location>,
|
|
AccountId: Into<[u8; 32]> + From<[u8; 32]> + Clone,
|
|
{
|
|
fn convert(message: Message) -> Result<Xcm<()>, ConvertMessageError> {
|
|
let message = Self::prepare(message)?;
|
|
|
|
tracing::trace!(target: LOG_TARGET, ?message, "prepared message");
|
|
|
|
let mut instructions = vec![
|
|
DescendOrigin(InboundQueueLocation::get()),
|
|
UniversalOrigin(GlobalConsensus(EthereumNetwork::get())),
|
|
ReserveAssetDeposited(message.execution_fee.clone().into()),
|
|
];
|
|
|
|
// Set claimer before PayFees, in case the fees are not enough. Then the claimer will be
|
|
// able to claim the funds still.
|
|
instructions.push(SetHints {
|
|
hints: vec![AssetClaimer { location: message.claimer }]
|
|
.try_into()
|
|
.expect("checked statically, qed"),
|
|
});
|
|
|
|
instructions.push(PayFees { asset: message.execution_fee.clone() });
|
|
|
|
let mut reserve_deposit_assets = vec![];
|
|
let mut reserve_withdraw_assets = vec![];
|
|
|
|
for asset in message.assets {
|
|
match asset {
|
|
AssetTransfer::ReserveDeposit(asset) => reserve_deposit_assets.push(asset),
|
|
AssetTransfer::ReserveWithdraw(asset) => reserve_withdraw_assets.push(asset),
|
|
};
|
|
}
|
|
|
|
if !reserve_deposit_assets.is_empty() {
|
|
instructions.push(ReserveAssetDeposited(reserve_deposit_assets.into()));
|
|
}
|
|
if !reserve_withdraw_assets.is_empty() {
|
|
instructions.push(WithdrawAsset(reserve_withdraw_assets.into()));
|
|
}
|
|
|
|
// If the message origin is not the gateway proxy contract, set the origin to
|
|
// the original sender on Ethereum. Important to be before the arbitrary XCM that is
|
|
// appended to the message on the next line.
|
|
if message.origin != GatewayProxyAddress::get() {
|
|
instructions.push(DescendOrigin(
|
|
AccountKey20 { key: message.origin.into(), network: None }.into(),
|
|
));
|
|
}
|
|
|
|
// Add the XCM sent in the message to the end of the xcm instruction
|
|
instructions.extend(message.remote_xcm.0);
|
|
|
|
Ok(instructions.into())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use codec::Encode;
|
|
use frame_support::{assert_err, assert_ok, parameter_types};
|
|
use hex_literal::hex;
|
|
use snowbridge_core::TokenId;
|
|
use snowbridge_test_utils::mock_converter::{
|
|
add_location_override, reanchor_to_ethereum, LocationIdConvert,
|
|
};
|
|
use sp_core::{H160, H256};
|
|
const GATEWAY_ADDRESS: [u8; 20] = hex!["eda338e4dc46038493b885327842fd3e301cab39"];
|
|
|
|
parameter_types! {
|
|
pub const EthereumNetwork: NetworkId = NetworkId::Ethereum { chain_id: 1 };
|
|
pub const LocalNetwork: NetworkId = NetworkId::Pezkuwi;
|
|
pub const GatewayAddress: H160 = H160(GATEWAY_ADDRESS);
|
|
pub InboundQueueLocation: InteriorLocation = [PalletInstance(84)].into();
|
|
pub EthereumUniversalLocation: InteriorLocation =
|
|
[GlobalConsensus(EthereumNetwork::get())].into();
|
|
pub AssetHubParaId: ParaId = 1000.into();
|
|
pub const CreateAssetCallIndex: [u8;2] = [53, 0];
|
|
pub const SetReservesCallIndex: [u8;2] = [53, 33];
|
|
pub const CreateAssetDeposit: u128 = 10_000_000_000u128;
|
|
pub const CreateAssetMinBalance: u128 = 1;
|
|
pub EthereumLocation: Location = Location::new(2,EthereumUniversalLocation::get());
|
|
pub BridgeHubContext: InteriorLocation = [GlobalConsensus(Pezkuwi),Teyrchain(1002)].into();
|
|
pub CreateAssetCall: CreateAssetCallInfo = CreateAssetCallInfo {
|
|
create_call: CreateAssetCallIndex::get(),
|
|
deposit: CreateAssetDeposit::get(),
|
|
min_balance: CreateAssetMinBalance::get(),
|
|
set_reserves_call: SetReservesCallIndex::get(),
|
|
};
|
|
}
|
|
|
|
pub struct MockFailedTokenConvert;
|
|
impl MaybeConvert<TokenId, Location> for MockFailedTokenConvert {
|
|
fn maybe_convert(_id: TokenId) -> Option<Location> {
|
|
None
|
|
}
|
|
}
|
|
|
|
type Converter = MessageToXcm<
|
|
CreateAssetCall,
|
|
EthereumNetwork,
|
|
LocalNetwork,
|
|
GatewayAddress,
|
|
InboundQueueLocation,
|
|
AssetHubParaId,
|
|
LocationIdConvert,
|
|
[u8; 32],
|
|
>;
|
|
|
|
type ConverterFailing = MessageToXcm<
|
|
CreateAssetCall,
|
|
EthereumNetwork,
|
|
LocalNetwork,
|
|
GatewayAddress,
|
|
InboundQueueLocation,
|
|
AssetHubParaId,
|
|
MockFailedTokenConvert,
|
|
[u8; 32],
|
|
>;
|
|
|
|
#[test]
|
|
fn test_successful_message() {
|
|
sp_io::TestExternalities::default().execute_with(|| {
|
|
let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
|
|
let native_token_id: H160 = hex!("5615deb798bb3e4dfa0139dfa1b3d433cc23b72f").into();
|
|
let dot_location = Location::parent();
|
|
let (foreign_token_id, _) = reanchor_to_ethereum(
|
|
dot_location.clone(),
|
|
EthereumLocation::get(),
|
|
BridgeHubContext::get(),
|
|
);
|
|
add_location_override(dot_location, EthereumLocation::get(), BridgeHubContext::get());
|
|
let beneficiary: Location =
|
|
hex!("908783d8cd24c9e02cee1d26ab9c46d458621ad0150b626c536a40b9df3f09c6").into();
|
|
let token_value = 3_000_000_000_000u128;
|
|
let assets = vec![
|
|
EthereumAsset::NativeTokenERC20 { token_id: native_token_id, value: token_value },
|
|
EthereumAsset::ForeignTokenERC20 { token_id: foreign_token_id, value: token_value },
|
|
];
|
|
let instructions = vec![DepositAsset {
|
|
assets: Wild(AllCounted(1).into()),
|
|
beneficiary: beneficiary.clone(),
|
|
}];
|
|
let xcm: Xcm<()> = instructions.into();
|
|
let versioned_xcm = VersionedXcm::V5(xcm);
|
|
let claimer_location =
|
|
Location::new(0, AccountId32 { network: None, id: H256::random().into() });
|
|
let claimer: Option<Vec<u8>> = Some(claimer_location.clone().encode());
|
|
let value = 6_000_000_000_000u128;
|
|
let execution_fee = 1_000_000_000_000u128;
|
|
let relayer_fee = 5_000_000_000_000u128;
|
|
|
|
let message = Message {
|
|
gateway: H160::zero(),
|
|
nonce: 0,
|
|
origin,
|
|
assets,
|
|
xcm: XcmPayload::Raw(versioned_xcm.encode()),
|
|
claimer,
|
|
value,
|
|
execution_fee,
|
|
relayer_fee,
|
|
};
|
|
|
|
let result = Converter::convert(message);
|
|
|
|
assert_ok!(result.clone());
|
|
|
|
let xcm = result.unwrap();
|
|
|
|
// Convert to vec for easier inspection
|
|
let instructions: Vec<_> = xcm.into_iter().collect();
|
|
|
|
// Check last instruction is a SetTopic (automatically added)
|
|
let last_instruction =
|
|
instructions.last().expect("should have at least one instruction");
|
|
assert!(matches!(last_instruction, SetTopic(_)), "Last instruction should be SetTopic");
|
|
|
|
let mut asset_claimer_found = false;
|
|
let mut pay_fees_found = false;
|
|
let mut descend_origin_found = 0;
|
|
let mut reserve_deposited_found = 0;
|
|
let mut withdraw_assets_found = 0;
|
|
let mut deposit_asset_found = 0;
|
|
|
|
for instruction in &instructions {
|
|
if let SetHints { ref hints } = instruction {
|
|
if let Some(AssetClaimer { ref location }) = hints.clone().into_iter().next() {
|
|
assert_eq!(claimer_location, location.clone());
|
|
asset_claimer_found = true;
|
|
}
|
|
}
|
|
if let DescendOrigin(ref location) = instruction {
|
|
descend_origin_found += 1;
|
|
// The second DescendOrigin should be the message.origin (sender)
|
|
if descend_origin_found == 2 {
|
|
let junctions: Junctions =
|
|
AccountKey20 { key: origin.into(), network: None }.into();
|
|
assert_eq!(junctions, location.clone());
|
|
}
|
|
}
|
|
if let PayFees { ref asset } = instruction {
|
|
let fee_asset = Location::new(2, [GlobalConsensus(EthereumNetwork::get())]);
|
|
assert_eq!(asset.id, AssetId(fee_asset));
|
|
assert_eq!(asset.fun, Fungible(execution_fee));
|
|
pay_fees_found = true;
|
|
}
|
|
if let ReserveAssetDeposited(ref reserve_assets) = instruction {
|
|
reserve_deposited_found += 1;
|
|
if reserve_deposited_found == 1 {
|
|
let fee_asset = Location::new(2, [GlobalConsensus(EthereumNetwork::get())]);
|
|
let fee: Asset = (fee_asset, execution_fee).into();
|
|
let fee_assets: Assets = fee.into();
|
|
assert_eq!(fee_assets, reserve_assets.clone());
|
|
}
|
|
if reserve_deposited_found == 2 {
|
|
let token_asset = Location::new(
|
|
2,
|
|
[
|
|
GlobalConsensus(EthereumNetwork::get()),
|
|
AccountKey20 { network: None, key: native_token_id.into() },
|
|
],
|
|
);
|
|
let token: Asset = (token_asset, token_value).into();
|
|
|
|
let remaining_ether_asset: Asset =
|
|
(Location::new(2, [GlobalConsensus(EthereumNetwork::get())]), value)
|
|
.into();
|
|
|
|
let expected_assets: Assets = vec![token, remaining_ether_asset].into();
|
|
assert_eq!(expected_assets, reserve_assets.clone());
|
|
}
|
|
}
|
|
if let WithdrawAsset(ref withdraw_assets) = instruction {
|
|
withdraw_assets_found += 1;
|
|
let token_asset = Location::new(1, Here);
|
|
let token: Asset = (token_asset, token_value).into();
|
|
let token_assets: Assets = token.into();
|
|
assert_eq!(token_assets, withdraw_assets.clone());
|
|
}
|
|
if let DepositAsset { ref assets, beneficiary: deposit_beneficiary } = instruction {
|
|
deposit_asset_found += 1;
|
|
if deposit_asset_found == 1 {
|
|
assert_eq!(AssetFilter::from(Wild(AllCounted(1).into())), assets.clone());
|
|
assert_eq!(*deposit_beneficiary, beneficiary);
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetAssetClaimer must be in the message.
|
|
assert!(asset_claimer_found);
|
|
// PayFees must be in the message.
|
|
assert!(pay_fees_found);
|
|
// The first DescendOrigin to descend into the InboundV2 pallet index and the
|
|
// DescendOrigin into the message.origin
|
|
assert!(descend_origin_found == 2);
|
|
// Expecting two ReserveAssetDeposited instructions, one for the fee and one for the
|
|
// token being transferred.
|
|
assert!(reserve_deposited_found == 2);
|
|
// Expecting one WithdrawAsset for the foreign ERC-20
|
|
assert!(withdraw_assets_found == 1);
|
|
// Deposit asset added by user
|
|
assert!(deposit_asset_found == 1);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_message_with_gateway_origin_does_not_descend_origin_into_sender() {
|
|
let origin: H160 = GatewayAddress::get();
|
|
let native_token_id: H160 = hex!("5615deb798bb3e4dfa0139dfa1b3d433cc23b72f").into();
|
|
let beneficiary =
|
|
hex!("908783d8cd24c9e02cee1d26ab9c46d458621ad0150b626c536a40b9df3f09c6").into();
|
|
let message_id: H256 =
|
|
hex!("8b69c7e376e28114618e829a7ec768dbda28357d359ba417a3bd79b11215059d").into();
|
|
let token_value = 3_000_000_000_000u128;
|
|
let assets =
|
|
vec![EthereumAsset::NativeTokenERC20 { token_id: native_token_id, value: token_value }];
|
|
let instructions = vec![
|
|
DepositAsset { assets: Wild(AllCounted(1).into()), beneficiary },
|
|
SetTopic(message_id.into()),
|
|
];
|
|
let xcm: Xcm<()> = instructions.into();
|
|
let versioned_xcm = VersionedXcm::V5(xcm);
|
|
let claimer_account = AccountId32 { network: None, id: H256::random().into() };
|
|
let claimer: Option<Vec<u8>> = Some(claimer_account.clone().encode());
|
|
let value = 6_000_000_000_000u128;
|
|
let execution_fee = 1_000_000_000_000u128;
|
|
let relayer_fee = 5_000_000_000_000u128;
|
|
|
|
let message = Message {
|
|
gateway: H160::zero(),
|
|
nonce: 0,
|
|
origin,
|
|
assets,
|
|
xcm: XcmPayload::Raw(versioned_xcm.encode()),
|
|
claimer,
|
|
value,
|
|
execution_fee,
|
|
relayer_fee,
|
|
};
|
|
|
|
let result = Converter::convert(message);
|
|
|
|
assert_ok!(result.clone());
|
|
|
|
let xcm = result.unwrap();
|
|
|
|
let mut instructions = xcm.into_iter();
|
|
let mut commands_found = 0;
|
|
while let Some(instruction) = instructions.next() {
|
|
if let DescendOrigin(ref _location) = instruction {
|
|
commands_found = commands_found + 1;
|
|
}
|
|
}
|
|
// There should only be 1 DescendOrigin in the message.
|
|
assert!(commands_found == 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_foreign_erc20() {
|
|
let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
|
|
let token_id: H256 =
|
|
hex!("37a6c666da38711a963d938eafdd09314fd3f95a96a3baffb55f26560f4ecdd8").into();
|
|
let beneficiary =
|
|
hex!("908783d8cd24c9e02cee1d26ab9c46d458621ad0150b626c536a40b9df3f09c6").into();
|
|
let message_id: H256 =
|
|
hex!("8b69c7e376e28114618e829a7ec768dbda28357d359ba417a3bd79b11215059d").into();
|
|
let token_value = 3_000_000_000_000u128;
|
|
let assets = vec![EthereumAsset::ForeignTokenERC20 { token_id, value: token_value }];
|
|
let instructions = vec![
|
|
DepositAsset { assets: Wild(AllCounted(1).into()), beneficiary },
|
|
SetTopic(message_id.into()),
|
|
];
|
|
let xcm: Xcm<()> = instructions.into();
|
|
let versioned_xcm = VersionedXcm::V5(xcm);
|
|
let claimer_account = AccountId32 { network: None, id: H256::random().into() };
|
|
let claimer: Option<Vec<u8>> = Some(claimer_account.clone().encode());
|
|
let value = 0;
|
|
let execution_fee = 1_000_000_000_000u128;
|
|
let relayer_fee = 5_000_000_000_000u128;
|
|
|
|
let message = Message {
|
|
gateway: H160::zero(),
|
|
nonce: 0,
|
|
origin,
|
|
assets,
|
|
xcm: XcmPayload::Raw(versioned_xcm.encode()),
|
|
claimer,
|
|
value,
|
|
execution_fee,
|
|
relayer_fee,
|
|
};
|
|
|
|
assert_err!(ConverterFailing::convert(message), ConvertMessageError::InvalidAsset);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_claimer() {
|
|
sp_io::TestExternalities::default().execute_with(|| {
|
|
let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
|
|
let native_token_id: H160 = hex!("5615deb798bb3e4dfa0139dfa1b3d433cc23b72f").into();
|
|
let beneficiary =
|
|
hex!("908783d8cd24c9e02cee1d26ab9c46d458621ad0150b626c536a40b9df3f09c6").into();
|
|
let token_value = 3_000_000_000_000u128;
|
|
let assets = vec![EthereumAsset::NativeTokenERC20 {
|
|
token_id: native_token_id,
|
|
value: token_value,
|
|
}];
|
|
let instructions =
|
|
vec![DepositAsset { assets: Wild(AllCounted(1).into()), beneficiary }];
|
|
let xcm: Xcm<()> = instructions.into();
|
|
let versioned_xcm = VersionedXcm::V5(xcm);
|
|
// Invalid claimer location, cannot be decoded into a Location
|
|
let claimer: Option<Vec<u8>> = Some(vec![]);
|
|
let value = 6_000_000_000_000u128;
|
|
let execution_fee = 1_000_000_000_000u128;
|
|
let relayer_fee = 5_000_000_000_000u128;
|
|
|
|
let message = Message {
|
|
gateway: H160::zero(),
|
|
nonce: 0,
|
|
origin,
|
|
assets,
|
|
xcm: XcmPayload::Raw(versioned_xcm.encode()),
|
|
claimer,
|
|
value,
|
|
execution_fee,
|
|
relayer_fee,
|
|
};
|
|
|
|
let result = Converter::convert(message.clone());
|
|
|
|
// Invalid claimer does not break the message conversion
|
|
assert_ok!(result.clone());
|
|
|
|
let xcm = result.unwrap();
|
|
let instructions: Vec<_> = xcm.into_iter().collect();
|
|
|
|
// Check last instruction is a SetTopic (automatically added)
|
|
let last_instruction =
|
|
instructions.last().expect("should have at least one instruction");
|
|
assert!(matches!(last_instruction, SetTopic(_)), "Last instruction should be SetTopic");
|
|
|
|
let mut actual_claimer: Option<Location> = None;
|
|
for instruction in &instructions {
|
|
if let SetHints { ref hints } = instruction {
|
|
if let Some(AssetClaimer { location }) = hints.clone().into_iter().next() {
|
|
actual_claimer = Some(location);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// actual claimer should default to Snowbridge sovereign account
|
|
let bridge_owner = ExternalConsensusLocationsConverterFor::<
|
|
AssetHubUniversal<LocalNetwork, AssetHubParaId>,
|
|
[u8; 32],
|
|
>::convert_location(&Location::new(
|
|
2,
|
|
[GlobalConsensus(EthereumNetwork::get())],
|
|
))
|
|
.unwrap();
|
|
assert_eq!(
|
|
actual_claimer,
|
|
Some(Location::new(0, [AccountId32 { network: None, id: bridge_owner }]))
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_xcm() {
|
|
sp_io::TestExternalities::default().execute_with(|| {
|
|
let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
|
|
let native_token_id: H160 = hex!("5615deb798bb3e4dfa0139dfa1b3d433cc23b72f").into();
|
|
let token_value = 3_000_000_000_000u128;
|
|
let assets = vec![EthereumAsset::NativeTokenERC20 {
|
|
token_id: native_token_id,
|
|
value: token_value,
|
|
}];
|
|
// invalid xcm
|
|
let versioned_xcm = hex!("8b69c7e376e28114618e829a7ec7").to_vec();
|
|
let claimer_account = AccountId32 { network: None, id: H256::random().into() };
|
|
let claimer: Option<Vec<u8>> = Some(claimer_account.clone().encode());
|
|
let value = 6_000_000_000_000u128;
|
|
let execution_fee = 1_000_000_000_000u128;
|
|
let relayer_fee = 5_000_000_000_000u128;
|
|
|
|
let message = Message {
|
|
gateway: H160::zero(),
|
|
nonce: 0,
|
|
origin,
|
|
assets,
|
|
xcm: XcmPayload::Raw(versioned_xcm),
|
|
claimer: Some(claimer.encode()),
|
|
value,
|
|
execution_fee,
|
|
relayer_fee,
|
|
};
|
|
|
|
let result = Converter::convert(message);
|
|
|
|
// Invalid xcm does not break the message, allowing funds to be trapped on AH.
|
|
assert_ok!(result.clone());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn message_with_set_topic_respects_user_topic() {
|
|
sp_io::TestExternalities::default().execute_with(|| {
|
|
let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
|
|
|
|
// Create a custom topic ID that the user specifies
|
|
let user_topic: [u8; 32] =
|
|
hex!("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef");
|
|
|
|
// User's XCM with a SetTopic as the last instruction
|
|
let instructions = vec![RefundSurplus, SetTopic(user_topic)];
|
|
let xcm: Xcm<()> = instructions.into();
|
|
let versioned_xcm = VersionedXcm::V5(xcm);
|
|
|
|
let execution_fee = 1_000_000_000_000u128;
|
|
let value = 0;
|
|
|
|
let message = Message {
|
|
gateway: H160::zero(),
|
|
nonce: 0,
|
|
origin,
|
|
assets: vec![],
|
|
xcm: XcmPayload::Raw(versioned_xcm.encode()),
|
|
claimer: None,
|
|
value,
|
|
execution_fee,
|
|
relayer_fee: 0,
|
|
};
|
|
|
|
let result = Converter::convert(message);
|
|
assert_ok!(result.clone());
|
|
|
|
let xcm = result.unwrap();
|
|
let instructions: Vec<_> = xcm.into_iter().collect();
|
|
|
|
// The last instruction should be the user's SetTopic
|
|
let last_instruction =
|
|
instructions.last().expect("should have at least one instruction");
|
|
if let SetTopic(ref topic) = last_instruction {
|
|
assert_eq!(*topic, user_topic);
|
|
} else {
|
|
panic!("Last instruction should be SetTopic");
|
|
}
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn message_with_generates_a_unique_topic_if_no_topic_is_present() {
|
|
sp_io::TestExternalities::default().execute_with(|| {
|
|
let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
|
|
|
|
let execution_fee = 1_000_000_000_000u128;
|
|
let value = 0;
|
|
|
|
let message = Message {
|
|
gateway: H160::zero(),
|
|
nonce: 0,
|
|
origin,
|
|
assets: vec![],
|
|
xcm: XcmPayload::Raw(vec![]),
|
|
claimer: None,
|
|
value,
|
|
execution_fee,
|
|
relayer_fee: 0,
|
|
};
|
|
|
|
let result = Converter::convert(message);
|
|
assert_ok!(result.clone());
|
|
|
|
let xcm = result.unwrap();
|
|
let instructions: Vec<_> = xcm.into_iter().collect();
|
|
|
|
// The last instruction should be a SetTopic
|
|
let last_instruction =
|
|
instructions.last().expect("should have at least one instruction");
|
|
assert!(matches!(last_instruction, SetTopic(_)));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn message_with_user_topic_not_last_instruction_gets_appended() {
|
|
sp_io::TestExternalities::default().execute_with(|| {
|
|
let origin: H160 = hex!("29e3b139f4393adda86303fcdaa35f60bb7092bf").into();
|
|
|
|
let execution_fee = 1_000_000_000_000u128;
|
|
let value = 0;
|
|
|
|
let user_topic: [u8; 32] =
|
|
hex!("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef");
|
|
|
|
// Add a set topic, but not as the last instruction.
|
|
let instructions = vec![SetTopic(user_topic), RefundSurplus];
|
|
let xcm: Xcm<()> = instructions.into();
|
|
let versioned_xcm = VersionedXcm::V5(xcm);
|
|
|
|
let message = Message {
|
|
gateway: H160::zero(),
|
|
nonce: 0,
|
|
origin,
|
|
assets: vec![],
|
|
xcm: XcmPayload::Raw(versioned_xcm.encode()),
|
|
claimer: None,
|
|
value,
|
|
execution_fee,
|
|
relayer_fee: 0,
|
|
};
|
|
|
|
let result = Converter::convert(message);
|
|
assert_ok!(result.clone());
|
|
|
|
let xcm = result.unwrap();
|
|
let instructions: Vec<_> = xcm.into_iter().collect();
|
|
|
|
// Get the last instruction - should still be a SetTopic, but might not have the
|
|
// original topic since for non-last-instruction topics, the filter_topic function
|
|
// extracts it during prepare() and then the original value is later lost when we
|
|
// append a new one
|
|
let last_instruction =
|
|
instructions.last().expect("should have at least one instruction");
|
|
|
|
// Check if the last instruction is a SetTopic (content isn't important)
|
|
assert!(matches!(last_instruction, SetTopic(_)), "Last instruction should be SetTopic");
|
|
});
|
|
}
|
|
}
|