fix: EnsureOrigin try_successful_origin and snowbridge rename

- Fix pezpallet-welati EnsureOrigin implementations (3 fixes)
  - Remove incorrect #[cfg(not(feature = "runtime-benchmarks"))] blocks
  - Affects EnsureSerok, EnsureParlementer, EnsureDiwan

- Fix asset-hub-zagros governance origins macros (2 fixes)
  - Remove non-benchmark try_successful_origin from decl_unit_ensures!
  - Remove non-benchmark try_successful_origin from decl_ensure!

- Rename snowbridge -> pezsnowbridge for consistency

- Update WORKFLOW_PLAN.md with build status and package names
  - Correct package names: pezkuwi-teyrchain-bin, pezstaging-node-cli
  - Mark completed builds: pezkuwi, pezkuwi-teyrchain-bin,
    pezstaging-node-cli, teyrchain-template-node
This commit is contained in:
2025-12-25 01:26:18 +03:00
parent 18319a1017
commit 6b597bebcf
195 changed files with 156 additions and 139 deletions
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2023 Snowfork <hello@snowfork.com>
// SPDX-FileCopyrightText: 2021-2022 Parity Technologies (UK) Ltd.
#![cfg_attr(not(feature = "std"), no_std)]
pub mod v1;
pub mod v2;
use codec::Encode;
use pezsp_core::blake2_256;
use pezsp_std::marker::PhantomData;
use xcm::prelude::{AccountKey20, Ethereum, GlobalConsensus, Location};
use xcm_executor::traits::ConvertLocation;
pub use pezsnowbridge_verification_primitives::*;
/// DEPRECATED in favor of [xcm_builder::ExternalConsensusLocationsConverterFor]
pub struct EthereumLocationsConverterFor<AccountId>(PhantomData<AccountId>);
impl<AccountId> ConvertLocation<AccountId> for EthereumLocationsConverterFor<AccountId>
where
AccountId: From<[u8; 32]> + Clone,
{
fn convert_location(location: &Location) -> Option<AccountId> {
match location.unpack() {
(2, [GlobalConsensus(Ethereum { chain_id })]) => {
Some(Self::from_chain_id(chain_id).into())
},
(2, [GlobalConsensus(Ethereum { chain_id }), AccountKey20 { network: _, key }]) => {
Some(Self::from_chain_id_with_key(chain_id, *key).into())
},
_ => None,
}
}
}
impl<AccountId> EthereumLocationsConverterFor<AccountId> {
pub fn from_chain_id(chain_id: &u64) -> [u8; 32] {
(b"ethereum-chain", chain_id).using_encoded(blake2_256)
}
pub fn from_chain_id_with_key(chain_id: &u64, key: [u8; 20]) -> [u8; 32] {
(b"ethereum-chain", chain_id, key).using_encoded(blake2_256)
}
}
pub type CallIndex = [u8; 2];
@@ -0,0 +1,677 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2023 Snowfork <hello@snowfork.com>
//! Converts messages from Ethereum to XCM messages
use crate::{CallIndex, EthereumLocationsConverterFor};
use codec::{Decode, DecodeWithMemTracking, Encode};
use core::marker::PhantomData;
use pezframe_support::{traits::tokens::Balance as BalanceT, PalletError};
use pezsnowbridge_core::TokenId;
use pezsp_core::{Get, RuntimeDebug, H160, H256};
use pezsp_runtime::{traits::MaybeConvert, MultiAddress};
use pezsp_std::prelude::*;
use scale_info::TypeInfo;
use xcm::prelude::{Junction::AccountKey20, *};
const MINIMUM_DEPOSIT: u128 = 1;
/// Messages from Ethereum are versioned. This is because in future,
/// we may want to evolve the protocol so that the ethereum side sends XCM messages directly.
/// Instead having BridgeHub transcode the messages into XCM.
#[derive(Clone, Encode, Decode, RuntimeDebug)]
pub enum VersionedMessage {
V1(MessageV1),
}
/// For V1, the ethereum side sends messages which are transcoded into XCM. These messages are
/// self-contained, in that they can be transcoded using only information in the message.
#[derive(Clone, Encode, Decode, RuntimeDebug)]
pub struct MessageV1 {
/// EIP-155 chain id of the origin Ethereum network
pub chain_id: u64,
/// The command originating from the Gateway contract
pub command: Command,
}
#[derive(Clone, Encode, Decode, RuntimeDebug)]
pub enum Command {
/// Register a wrapped token on the AssetHub `ForeignAssets` pezpallet
RegisterToken {
/// The address of the ERC20 token to be bridged over to AssetHub
token: H160,
/// XCM execution fee on AssetHub
fee: u128,
},
/// Send Ethereum token to AssetHub or another teyrchain
SendToken {
/// The address of the ERC20 token to be bridged over to AssetHub
token: H160,
/// The destination for the transfer
destination: Destination,
/// Amount to transfer
amount: u128,
/// XCM execution fee on AssetHub
fee: u128,
},
/// Send Pezkuwi token back to the original teyrchain
SendNativeToken {
/// The Id of the token
token_id: TokenId,
/// The destination for the transfer
destination: Destination,
/// Amount to transfer
amount: u128,
/// XCM execution fee on AssetHub
fee: u128,
},
}
/// Destination for bridged tokens
#[derive(Clone, Encode, Decode, RuntimeDebug)]
pub enum Destination {
/// The funds will be deposited into account `id` on AssetHub
AccountId32 { id: [u8; 32] },
/// The funds will deposited into the sovereign account of destination teyrchain `para_id` on
/// AssetHub, Account `id` on the destination teyrchain will receive the funds via a
/// reserve-backed transfer. See <https://github.com/polkadot-fellows/xcm-format#depositreserveasset>
ForeignAccountId32 {
para_id: u32,
id: [u8; 32],
/// XCM execution fee on final destination
fee: u128,
},
/// The funds will deposited into the sovereign account of destination teyrchain `para_id` on
/// AssetHub, Account `id` on the destination teyrchain will receive the funds via a
/// reserve-backed transfer. See <https://github.com/polkadot-fellows/xcm-format#depositreserveasset>
ForeignAccountId20 {
para_id: u32,
id: [u8; 20],
/// XCM execution fee on final destination
fee: u128,
},
}
pub struct MessageToXcm<
CreateAssetCall,
CreateAssetDeposit,
InboundQueuePalletInstance,
AccountId,
Balance,
ConvertAssetId,
EthereumUniversalLocation,
GlobalAssetHubLocation,
> where
CreateAssetCall: Get<CallIndex>,
CreateAssetDeposit: Get<u128>,
Balance: BalanceT,
ConvertAssetId: MaybeConvert<TokenId, Location>,
EthereumUniversalLocation: Get<InteriorLocation>,
GlobalAssetHubLocation: Get<Location>,
{
_phantom: PhantomData<(
CreateAssetCall,
CreateAssetDeposit,
InboundQueuePalletInstance,
AccountId,
Balance,
ConvertAssetId,
EthereumUniversalLocation,
GlobalAssetHubLocation,
)>,
}
/// Reason why a message conversion failed.
#[derive(
Copy, Clone, TypeInfo, PalletError, Encode, Decode, DecodeWithMemTracking, RuntimeDebug,
)]
pub enum ConvertMessageError {
/// The message version is not supported for conversion.
UnsupportedVersion,
InvalidDestination,
InvalidToken,
/// The fee asset is not supported for conversion.
UnsupportedFeeAsset,
CannotReanchor,
}
/// convert the inbound message to xcm which will be forwarded to the destination chain
pub trait ConvertMessage {
type Balance: BalanceT + From<u128>;
type AccountId;
/// Converts a versioned message into an XCM message and an optional topicID
fn convert(
message_id: H256,
message: VersionedMessage,
) -> Result<(Xcm<()>, Self::Balance), ConvertMessageError>;
}
impl<
CreateAssetCall,
CreateAssetDeposit,
InboundQueuePalletInstance,
AccountId,
Balance,
ConvertAssetId,
EthereumUniversalLocation,
GlobalAssetHubLocation,
> ConvertMessage
for MessageToXcm<
CreateAssetCall,
CreateAssetDeposit,
InboundQueuePalletInstance,
AccountId,
Balance,
ConvertAssetId,
EthereumUniversalLocation,
GlobalAssetHubLocation,
>
where
CreateAssetCall: Get<CallIndex>,
CreateAssetDeposit: Get<u128>,
InboundQueuePalletInstance: Get<u8>,
Balance: BalanceT + From<u128>,
AccountId: Into<[u8; 32]>,
ConvertAssetId: MaybeConvert<TokenId, Location>,
EthereumUniversalLocation: Get<InteriorLocation>,
GlobalAssetHubLocation: Get<Location>,
{
type Balance = Balance;
type AccountId = AccountId;
fn convert(
message_id: H256,
message: VersionedMessage,
) -> Result<(Xcm<()>, Self::Balance), ConvertMessageError> {
use Command::*;
use VersionedMessage::*;
match message {
V1(MessageV1 { chain_id, command: RegisterToken { token, fee } }) => {
Ok(Self::convert_register_token(message_id, chain_id, token, fee))
},
V1(MessageV1 { chain_id, command: SendToken { token, destination, amount, fee } }) => {
Ok(Self::convert_send_token(message_id, chain_id, token, destination, amount, fee))
},
V1(MessageV1 {
chain_id,
command: SendNativeToken { token_id, destination, amount, fee },
}) => Self::convert_send_native_token(
message_id,
chain_id,
token_id,
destination,
amount,
fee,
),
}
}
}
impl<
CreateAssetCall,
CreateAssetDeposit,
InboundQueuePalletInstance,
AccountId,
Balance,
ConvertAssetId,
EthereumUniversalLocation,
GlobalAssetHubLocation,
>
MessageToXcm<
CreateAssetCall,
CreateAssetDeposit,
InboundQueuePalletInstance,
AccountId,
Balance,
ConvertAssetId,
EthereumUniversalLocation,
GlobalAssetHubLocation,
>
where
CreateAssetCall: Get<CallIndex>,
CreateAssetDeposit: Get<u128>,
InboundQueuePalletInstance: Get<u8>,
Balance: BalanceT + From<u128>,
AccountId: Into<[u8; 32]>,
ConvertAssetId: MaybeConvert<TokenId, Location>,
EthereumUniversalLocation: Get<InteriorLocation>,
GlobalAssetHubLocation: Get<Location>,
{
fn convert_register_token(
message_id: H256,
chain_id: u64,
token: H160,
fee: u128,
) -> (Xcm<()>, Balance) {
let network = Ethereum { chain_id };
let xcm_fee: Asset = (Location::parent(), fee).into();
let deposit: Asset = (Location::parent(), CreateAssetDeposit::get()).into();
let total_amount = fee + CreateAssetDeposit::get();
let total: Asset = (Location::parent(), total_amount).into();
let bridge_location = Location::new(2, GlobalConsensus(network));
let owner = EthereumLocationsConverterFor::<[u8; 32]>::from_chain_id(&chain_id);
let asset_id = Self::convert_token_address(network, token);
let create_call_index: [u8; 2] = CreateAssetCall::get();
let inbound_queue_pallet_index = InboundQueuePalletInstance::get();
let xcm: Xcm<()> = vec![
// Teleport required fees.
ReceiveTeleportedAsset(total.into()),
// Pay for execution.
BuyExecution { fees: xcm_fee, weight_limit: Unlimited },
// Fund the snowbridge sovereign with the required deposit for creation.
DepositAsset { assets: Definite(deposit.into()), beneficiary: bridge_location.clone() },
// This `SetAppendix` ensures that `xcm_fee` not spent by `Transact` will be
// deposited to snowbridge sovereign, instead of being trapped, regardless of
// `Transact` success or not.
SetAppendix(Xcm(vec![
RefundSurplus,
DepositAsset { assets: AllCounted(1).into(), beneficiary: bridge_location },
])),
// Only our inbound-queue pezpallet is allowed to invoke `UniversalOrigin`.
DescendOrigin(PalletInstance(inbound_queue_pallet_index).into()),
// Change origin to the bridge.
UniversalOrigin(GlobalConsensus(network)),
// Call create_asset on foreign assets pezpallet.
Transact {
origin_kind: OriginKind::Xcm,
fallback_max_weight: Some(Weight::from_parts(400_000_000, 8_000)),
call: (
create_call_index,
asset_id,
MultiAddress::<[u8; 32], ()>::Id(owner),
MINIMUM_DEPOSIT,
)
.encode()
.into(),
},
// Forward message id to Asset Hub
SetTopic(message_id.into()),
// Once the program ends here, appendix program will run, which will deposit any
// leftover fee to snowbridge sovereign.
]
.into();
(xcm, total_amount.into())
}
fn convert_send_token(
message_id: H256,
chain_id: u64,
token: H160,
destination: Destination,
amount: u128,
asset_hub_fee: u128,
) -> (Xcm<()>, Balance) {
let network = Ethereum { chain_id };
let asset_hub_fee_asset: Asset = (Location::parent(), asset_hub_fee).into();
let asset: Asset = (Self::convert_token_address(network, token), amount).into();
let (dest_para_id, beneficiary, dest_para_fee) = match destination {
// Final destination is a 32-byte account on AssetHub
Destination::AccountId32 { id } => {
(None, Location::new(0, [AccountId32 { network: None, id }]), 0)
},
// Final destination is a 32-byte account on a sibling of AssetHub
Destination::ForeignAccountId32 { para_id, id, fee } => (
Some(para_id),
Location::new(0, [AccountId32 { network: None, id }]),
// Total fee needs to cover execution on AssetHub and Sibling
fee,
),
// Final destination is a 20-byte account on a sibling of AssetHub
Destination::ForeignAccountId20 { para_id, id, fee } => (
Some(para_id),
Location::new(0, [AccountKey20 { network: None, key: id }]),
// Total fee needs to cover execution on AssetHub and Sibling
fee,
),
};
let total_fees = asset_hub_fee.saturating_add(dest_para_fee);
let total_fee_asset: Asset = (Location::parent(), total_fees).into();
let inbound_queue_pallet_index = InboundQueuePalletInstance::get();
let mut instructions = vec![
ReceiveTeleportedAsset(total_fee_asset.into()),
BuyExecution { fees: asset_hub_fee_asset, weight_limit: Unlimited },
DescendOrigin(PalletInstance(inbound_queue_pallet_index).into()),
UniversalOrigin(GlobalConsensus(network)),
ReserveAssetDeposited(asset.clone().into()),
ClearOrigin,
];
match dest_para_id {
Some(dest_para_id) => {
let dest_para_fee_asset: Asset = (Location::parent(), dest_para_fee).into();
let bridge_location = Location::new(2, GlobalConsensus(network));
instructions.extend(vec![
// After program finishes deposit any leftover assets to the snowbridge
// sovereign.
SetAppendix(Xcm(vec![DepositAsset {
assets: Wild(AllCounted(2)),
beneficiary: bridge_location,
}])),
// Perform a deposit reserve to send to destination chain.
DepositReserveAsset {
// Send over assets and unspent fees, XCM delivery fee will be charged from
// here.
assets: Wild(AllCounted(2)),
dest: Location::new(1, [Teyrchain(dest_para_id)]),
xcm: vec![
// Buy execution on target.
BuyExecution { fees: dest_para_fee_asset, weight_limit: Unlimited },
// Deposit assets to beneficiary.
DepositAsset { assets: Wild(AllCounted(2)), beneficiary },
// Forward message id to destination teyrchain.
SetTopic(message_id.into()),
]
.into(),
},
]);
},
None => {
instructions.extend(vec![
// Deposit both asset and fees to beneficiary so the fees will not get
// trapped. Another benefit is when fees left more than ED on AssetHub could be
// used to create the beneficiary account in case it does not exist.
DepositAsset { assets: Wild(AllCounted(2)), beneficiary },
]);
},
}
// Forward message id to Asset Hub.
instructions.push(SetTopic(message_id.into()));
// The `instructions` to forward to AssetHub, and the `total_fees` to locally burn (since
// they are teleported within `instructions`).
(instructions.into(), total_fees.into())
}
// Convert ERC20 token address to a location that can be understood by Assets Hub.
fn convert_token_address(network: NetworkId, token: H160) -> Location {
if token == H160([0; 20]) {
Location::new(2, [GlobalConsensus(network)])
} else {
Location::new(
2,
[GlobalConsensus(network), AccountKey20 { network: None, key: token.into() }],
)
}
}
/// Constructs an XCM message destined for AssetHub that withdraws assets from the sovereign
/// account of the Gateway contract and either deposits those assets into a recipient account or
/// forwards the assets to another teyrchain.
fn convert_send_native_token(
message_id: H256,
chain_id: u64,
token_id: TokenId,
destination: Destination,
amount: u128,
asset_hub_fee: u128,
) -> Result<(Xcm<()>, Balance), ConvertMessageError> {
let network = Ethereum { chain_id };
let asset_hub_fee_asset: Asset = (Location::parent(), asset_hub_fee).into();
let beneficiary = match destination {
// Final destination is a 32-byte account on AssetHub
Destination::AccountId32 { id } => {
Ok(Location::new(0, [AccountId32 { network: None, id }]))
},
// Forwarding to a destination teyrchain is not allowed for PNA and is validated on the
// Ethereum side. https://github.com/Snowfork/snowbridge/blob/e87ddb2215b513455c844463a25323bb9c01ff36/contracts/src/Assets.sol#L216-L224
_ => Err(ConvertMessageError::InvalidDestination),
}?;
let total_fee_asset: Asset = (Location::parent(), asset_hub_fee).into();
let asset_loc =
ConvertAssetId::maybe_convert(token_id).ok_or(ConvertMessageError::InvalidToken)?;
let mut reanchored_asset_loc = asset_loc.clone();
reanchored_asset_loc
.reanchor(&GlobalAssetHubLocation::get(), &EthereumUniversalLocation::get())
.map_err(|_| ConvertMessageError::CannotReanchor)?;
let asset: Asset = (reanchored_asset_loc, amount).into();
let inbound_queue_pallet_index = InboundQueuePalletInstance::get();
let instructions = vec![
ReceiveTeleportedAsset(total_fee_asset.clone().into()),
BuyExecution { fees: asset_hub_fee_asset, weight_limit: Unlimited },
DescendOrigin(PalletInstance(inbound_queue_pallet_index).into()),
UniversalOrigin(GlobalConsensus(network)),
WithdrawAsset(asset.clone().into()),
// Deposit both asset and fees to beneficiary so the fees will not get
// trapped. Another benefit is when fees left more than ED on AssetHub could be
// used to create the beneficiary account in case it does not exist.
DepositAsset { assets: Wild(AllCounted(2)), beneficiary },
SetTopic(message_id.into()),
];
// `total_fees` to burn on this chain when sending `instructions` to run on AH (which also
// teleport fees)
Ok((instructions.into(), asset_hub_fee.into()))
}
}
#[cfg(test)]
mod tests {
use crate::{
v1::{Command, ConvertMessage, Destination, MessageToXcm, MessageV1, VersionedMessage},
CallIndex, EthereumLocationsConverterFor,
};
use hex_literal::hex;
use pezframe_support::{assert_ok, parameter_types};
use pezsnowbridge_test_utils::mock_converter::{
add_location_override, reanchor_to_ethereum, LocationIdConvert,
};
use pezsp_core::H160;
use pezsp_runtime::{
traits::{IdentifyAccount, Verify},
MultiSignature,
};
use xcm::prelude::*;
use xcm_executor::traits::ConvertLocation;
pub const CHAIN_ID: u64 = 1;
const NETWORK: NetworkId = Ethereum { chain_id: CHAIN_ID };
parameter_types! {
pub EthereumNetwork: NetworkId = NETWORK;
pub const CreateAssetCall: CallIndex = [1, 1];
pub const CreateAssetExecutionFee: u128 = 123;
pub const CreateAssetDeposit: u128 = 891;
pub const SendTokenExecutionFee: u128 = 592;
pub const InboundQueuePalletInstance: u8 = 80;
pub EthereumUniversalLocation: InteriorLocation =
[GlobalConsensus(NETWORK)].into();
pub AssetHubFromEthereum: Location = Location::new(1,[GlobalConsensus(Pezkuwi),Teyrchain(1000)]);
pub EthereumLocation: Location = Location::new(2,EthereumUniversalLocation::get());
pub BridgeHubContext: InteriorLocation = [GlobalConsensus(Pezkuwi),Teyrchain(1002)].into();
}
type AccountId = <<MultiSignature as Verify>::Signer as IdentifyAccount>::AccountId;
type Balance = u128;
pub type MessageConverter = MessageToXcm<
CreateAssetCall,
CreateAssetDeposit,
InboundQueuePalletInstance,
AccountId,
Balance,
LocationIdConvert,
EthereumUniversalLocation,
AssetHubFromEthereum,
>;
#[test]
fn test_contract_location_with_network_converts_successfully() {
let expected_account: [u8; 32] =
hex!("204dfe37731e8e2b4866ad0da9a17c49f434542c3477c5f914a3349acd88ba1a");
let contract_location = Location::new(2, [GlobalConsensus(NETWORK)]);
let account =
EthereumLocationsConverterFor::<[u8; 32]>::convert_location(&contract_location)
.unwrap();
assert_eq!(account, expected_account);
}
#[test]
fn test_contract_location_with_incorrect_location_fails_convert() {
let contract_location = Location::new(2, [GlobalConsensus(Pezkuwi), Teyrchain(1000)]);
assert_eq!(
EthereumLocationsConverterFor::<[u8; 32]>::convert_location(&contract_location),
None,
);
}
#[test]
fn test_reanchor_all_assets() {
let ethereum_context: InteriorLocation = [GlobalConsensus(Ethereum { chain_id: 1 })].into();
let ethereum = Location::new(2, ethereum_context.clone());
let ah_context: InteriorLocation = [GlobalConsensus(Pezkuwi), Teyrchain(1000)].into();
let global_ah = Location::new(1, ah_context.clone());
let assets = vec![
// HEZ
Location::new(1, []),
// GLMR (Some Pezkuwi teyrchain currency)
Location::new(1, [Teyrchain(2004)]),
// AH asset
Location::new(0, [PalletInstance(50), GeneralIndex(42)]),
// KSM
Location::new(2, [GlobalConsensus(Kusama)]),
// KAR (Some Kusama teyrchain currency)
Location::new(2, [GlobalConsensus(Kusama), Teyrchain(2000)]),
];
for asset in assets.iter() {
// reanchor logic in pezpallet_xcm on AH
let mut reanchored_asset = asset.clone();
assert_ok!(reanchored_asset.reanchor(&ethereum, &ah_context));
// reanchor back to original location in context of Ethereum
let mut reanchored_asset_with_ethereum_context = reanchored_asset.clone();
assert_ok!(
reanchored_asset_with_ethereum_context.reanchor(&global_ah, &ethereum_context)
);
assert_eq!(reanchored_asset_with_ethereum_context, asset.clone());
}
}
#[test]
fn test_convert_send_weth() {
const WETH: H160 = H160([0xff; 20]);
const AMOUNT: u128 = 1_000_000;
const FEE: u128 = 1_000;
const ACCOUNT_ID: [u8; 32] = [0xBA; 32];
const MESSAGE: VersionedMessage = VersionedMessage::V1(MessageV1 {
chain_id: CHAIN_ID,
command: Command::SendToken {
token: WETH,
destination: Destination::AccountId32 { id: ACCOUNT_ID },
amount: AMOUNT,
fee: FEE,
},
});
let result = MessageConverter::convert([1; 32].into(), MESSAGE);
assert_ok!(&result);
let (xcm, fee) = result.unwrap();
assert_eq!(FEE, fee);
let expected_assets = ReserveAssetDeposited(
vec![Asset {
id: AssetId(Location {
parents: 2,
interior: Junctions::X2(
[
GlobalConsensus(NETWORK),
AccountKey20 { network: None, key: WETH.into() },
]
.into(),
),
}),
fun: Fungible(AMOUNT),
}]
.into(),
);
let actual_assets = xcm.into_iter().find(|x| matches!(x, ReserveAssetDeposited(..)));
assert_eq!(actual_assets, Some(expected_assets))
}
#[test]
fn test_convert_send_eth() {
const ETH: H160 = H160([0x00; 20]);
const AMOUNT: u128 = 1_000_000;
const FEE: u128 = 1_000;
const ACCOUNT_ID: [u8; 32] = [0xBA; 32];
const MESSAGE: VersionedMessage = VersionedMessage::V1(MessageV1 {
chain_id: CHAIN_ID,
command: Command::SendToken {
token: ETH,
destination: Destination::AccountId32 { id: ACCOUNT_ID },
amount: AMOUNT,
fee: FEE,
},
});
let result = MessageConverter::convert([1; 32].into(), MESSAGE);
assert_ok!(&result);
let (xcm, fee) = result.unwrap();
assert_eq!(FEE, fee);
let expected_assets = ReserveAssetDeposited(
vec![Asset {
id: AssetId(Location {
parents: 2,
interior: Junctions::X1([GlobalConsensus(NETWORK)].into()),
}),
fun: Fungible(AMOUNT),
}]
.into(),
);
let actual_assets = xcm.into_iter().find(|x| matches!(x, ReserveAssetDeposited(..)));
assert_eq!(actual_assets, Some(expected_assets))
}
#[test]
fn test_convert_send_dot() {
let dot_location = Location::parent();
let (token_id, _) = reanchor_to_ethereum(
dot_location.clone(),
EthereumLocation::get(),
BridgeHubContext::get(),
);
add_location_override(
dot_location.clone(),
EthereumLocation::get(),
BridgeHubContext::get(),
);
const AMOUNT: u128 = 1_000_000;
const FEE: u128 = 1_000;
const ACCOUNT_ID: [u8; 32] = [0xBA; 32];
let message: VersionedMessage = VersionedMessage::V1(MessageV1 {
chain_id: CHAIN_ID,
command: Command::SendNativeToken {
token_id,
destination: Destination::AccountId32 { id: ACCOUNT_ID },
amount: AMOUNT,
fee: FEE,
},
});
let result = MessageConverter::convert([1; 32].into(), message);
assert_ok!(&result);
let (xcm, fee) = result.unwrap();
assert_eq!(FEE, fee);
let expected_assets = WithdrawAsset(
vec![Asset { id: AssetId(Location::parent()), fun: Fungible(AMOUNT) }].into(),
);
let actual_assets = xcm.into_iter().find(|x| matches!(x, WithdrawAsset(..)));
assert_eq!(actual_assets, Some(expected_assets))
}
}
@@ -0,0 +1,958 @@
// 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 pezframe_support::ensure;
use pezsnowbridge_core::{ParaId, TokenId};
use pezsp_core::{Get, RuntimeDebug, H160};
use pezsp_io::hashing::blake2_256;
use pezsp_runtime::{traits::MaybeConvert, MultiAddress};
use pezsp_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, &ethereum_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` pezpallet
/// 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 hex_literal::hex;
use pezframe_support::{assert_err, assert_ok, parameter_types};
use pezsnowbridge_core::TokenId;
use pezsnowbridge_test_utils::mock_converter::{
add_location_override, reanchor_to_ethereum, LocationIdConvert,
};
use pezsp_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() {
pezsp_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 pezpallet 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() {
pezsp_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() {
pezsp_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() {
pezsp_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() {
pezsp_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() {
pezsp_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");
});
}
}
@@ -0,0 +1,267 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2023 Snowfork <hello@snowfork.com>
//! Converts messages from Ethereum to XCM messages
use crate::{v2::IGatewayV2::Payload, Log};
use alloy_core::{
primitives::B256,
sol,
sol_types::{SolEvent, SolType},
};
use codec::{Decode, Encode};
use pezsp_core::{RuntimeDebug, H160, H256};
use pezsp_std::prelude::*;
use scale_info::TypeInfo;
sol! {
interface IGatewayV2 {
struct AsNativeTokenERC20 {
address token_id;
uint128 value;
}
struct AsForeignTokenERC20 {
bytes32 token_id;
uint128 value;
}
struct EthereumAsset {
uint8 kind;
bytes data;
}
struct Xcm {
uint8 kind;
bytes data;
}
struct XcmCreateAsset {
address token;
uint8 network;
}
struct Payload {
address origin;
EthereumAsset[] assets;
Xcm xcm;
bytes claimer;
uint128 value;
uint128 executionFee;
uint128 relayerFee;
}
event OutboundMessageAccepted(uint64 nonce, Payload payload);
}
}
impl core::fmt::Debug for IGatewayV2::Payload {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Payload")
.field("origin", &self.origin)
.field("assets", &self.assets)
.field("xcm", &self.xcm)
.field("claimer", &self.claimer)
.field("value", &self.value)
.field("executionFee", &self.executionFee)
.field("relayerFee", &self.relayerFee)
.finish()
}
}
impl core::fmt::Debug for IGatewayV2::EthereumAsset {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("EthereumAsset")
.field("kind", &self.kind)
.field("data", &self.data)
.finish()
}
}
impl core::fmt::Debug for IGatewayV2::Xcm {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Xcm")
.field("kind", &self.kind)
.field("data", &self.data)
.finish()
}
}
#[derive(Clone, Encode, Decode, RuntimeDebug, TypeInfo)]
pub enum XcmPayload {
/// Represents raw XCM bytes
Raw(Vec<u8>),
/// A token registration template
CreateAsset { token: H160, network: Network },
}
/// Network enum for cross-chain message destination
#[derive(Clone, Copy, Debug, Eq, PartialEq, Encode, Decode, TypeInfo)]
pub enum Network {
/// Pezkuwi network
Pezkuwi,
}
/// The ethereum side sends messages which are transcoded into XCM on BH. These messages are
/// self-contained, in that they can be transcoded using only information in the message.
#[derive(Clone, Encode, Decode, RuntimeDebug, TypeInfo)]
pub struct Message {
/// The address of the outbound queue on Ethereum that emitted this message as an event log
pub gateway: H160,
/// A nonce for enforcing replay protection and ordering.
pub nonce: u64,
/// The address on Ethereum that initiated the message.
pub origin: H160,
/// The assets sent from Ethereum (ERC-20s).
pub assets: Vec<EthereumAsset>,
/// The command originating from the Gateway contract.
pub xcm: XcmPayload,
/// The claimer in the case that funds get trapped. Expected to be an XCM::v5::Location.
pub claimer: Option<Vec<u8>>,
/// Native ether bridged over from Ethereum
pub value: u128,
/// Fee in eth to cover the xcm execution on AH.
pub execution_fee: u128,
/// Relayer reward in eth. Needs to cover all costs of sending a message.
pub relayer_fee: u128,
}
/// An asset that will be transacted on AH. The asset will be reserved/withdrawn and placed into
/// the holding register. The user needs to provide additional xcm to deposit the asset
/// in a beneficiary account.
#[derive(Clone, Encode, Decode, RuntimeDebug, TypeInfo)]
pub enum EthereumAsset {
NativeTokenERC20 {
/// The native token ID
token_id: H160,
/// The monetary value of the asset
value: u128,
},
ForeignTokenERC20 {
/// The foreign token ID
token_id: H256,
/// The monetary value of the asset
value: u128,
},
}
#[derive(Copy, Clone, RuntimeDebug)]
pub struct MessageDecodeError;
impl TryFrom<&Log> for Message {
type Error = MessageDecodeError;
fn try_from(log: &Log) -> Result<Self, Self::Error> {
// Convert to B256 for Alloy decoding
let topics: Vec<B256> = log.topics.iter().map(|x| B256::from_slice(x.as_ref())).collect();
// Decode the Solidity event from raw logs
let event = IGatewayV2::OutboundMessageAccepted::decode_raw_log_validate(topics, &log.data)
.map_err(|_| MessageDecodeError)?;
let payload = event.payload;
let bizinikiwi_assets = Self::extract_assets(&payload)?;
let xcm = XcmPayload::try_from(&payload)?;
let mut claimer = None;
if payload.claimer.len() > 0 {
claimer = Some(payload.claimer.to_vec());
}
let message = Message {
gateway: log.address,
nonce: event.nonce,
origin: H160::from(payload.origin.as_ref()),
assets: bizinikiwi_assets,
xcm,
claimer,
value: payload.value,
execution_fee: payload.executionFee,
relayer_fee: payload.relayerFee,
};
Ok(message)
}
}
impl Message {
fn extract_assets(
payload: &IGatewayV2::Payload,
) -> Result<Vec<EthereumAsset>, MessageDecodeError> {
let mut bizinikiwi_assets = vec![];
for asset in &payload.assets {
bizinikiwi_assets.push(EthereumAsset::try_from(asset)?);
}
Ok(bizinikiwi_assets)
}
}
impl TryFrom<&IGatewayV2::Payload> for XcmPayload {
type Error = MessageDecodeError;
fn try_from(payload: &Payload) -> Result<Self, Self::Error> {
let xcm = match payload.xcm.kind {
0 => XcmPayload::Raw(payload.xcm.data.to_vec()),
1 => {
let create_asset =
IGatewayV2::XcmCreateAsset::abi_decode_validate(&payload.xcm.data)
.map_err(|_| MessageDecodeError)?;
// Convert u8 network to Network enum
let network = match create_asset.network {
0 => Network::Pezkuwi,
_ => return Err(MessageDecodeError),
};
XcmPayload::CreateAsset { token: H160::from(create_asset.token.as_ref()), network }
},
_ => return Err(MessageDecodeError),
};
Ok(xcm)
}
}
impl TryFrom<&IGatewayV2::EthereumAsset> for EthereumAsset {
type Error = MessageDecodeError;
fn try_from(asset: &IGatewayV2::EthereumAsset) -> Result<EthereumAsset, Self::Error> {
let asset = match asset.kind {
0 => {
let native_data = IGatewayV2::AsNativeTokenERC20::abi_decode_validate(&asset.data)
.map_err(|_| MessageDecodeError)?;
EthereumAsset::NativeTokenERC20 {
token_id: H160::from(native_data.token_id.as_ref()),
value: native_data.value,
}
},
1 => {
let foreign_data =
IGatewayV2::AsForeignTokenERC20::abi_decode_validate(&asset.data)
.map_err(|_| MessageDecodeError)?;
EthereumAsset::ForeignTokenERC20 {
token_id: H256::from(foreign_data.token_id.as_ref()),
value: foreign_data.value,
}
},
_ => return Err(MessageDecodeError),
};
Ok(asset)
}
}
#[cfg(test)]
mod tests {
use super::*;
use hex_literal::hex;
use pezframe_support::assert_ok;
use pezsp_core::H160;
#[test]
fn test_decode() {
let log = Log{
address: hex!("b1185ede04202fe62d38f5db72f71e38ff3e8305").into(),
topics: vec![hex!("550e2067494b1736ea5573f2d19cdc0ac95b410fff161bf16f11c6229655ec9c").into()],
data: hex!("00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000b1185ede04202fe62d38f5db72f71e38ff3e830500000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000009184e72a0000000000000000000000000000000000000000000000000000000015d3ef798000000000000000000000000000000000000000000000000000000015d3ef798000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000b8ea8cb425d85536b158d661da1ef0895bb92f1d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").to_vec(),
};
let result = Message::try_from(&log);
assert_ok!(result.clone());
let message = result.unwrap();
assert_eq!(H160::from(hex!("b1185ede04202fe62d38f5db72f71e38ff3e8305")), message.gateway);
assert_eq!(H160::from(hex!("b1185ede04202fe62d38f5db72f71e38ff3e8305")), message.origin);
}
}
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2025 Snowfork <hello@snowfork.com>
// SPDX-FileCopyrightText: 2021-2025 Parity Technologies (UK) Ltd.
pub mod converter;
pub mod message;
pub mod traits;
pub use converter::*;
pub use message::*;
pub use traits::*;
const LOG_TARGET: &str = "pezsnowbridge-inbound-queue-primitives";
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2025 Snowfork <hello@snowfork.com>
// SPDX-FileCopyrightText: 2021-2025 Parity Technologies (UK) Ltd.
use super::Message;
use pezsp_core::RuntimeDebug;
use xcm::latest::Xcm;
/// Converts an inbound message from Ethereum to an XCM message that can be
/// executed on a teyrchain.
pub trait ConvertMessage {
fn convert(message: Message) -> Result<Xcm<()>, ConvertMessageError>;
}
/// Reason why a message conversion failed.
#[derive(Copy, Clone, RuntimeDebug, PartialEq)]
pub enum ConvertMessageError {
/// Invalid foreign ERC-20 token ID
InvalidAsset,
/// Cannot reachor a foreign ERC-20 asset location.
CannotReanchor,
/// Invalid network specified (not from Ethereum)
InvalidNetwork,
}