From e5de270cd0118b5744b42104118dbcf50d87fddc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 24 Feb 2026 20:18:07 +0000 Subject: [PATCH 01/16] Adjust xcm_config.rs for Pendulum and allow teleport of PEN to assethub --- runtime/pendulum/src/xcm_config.rs | 48 ++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/runtime/pendulum/src/xcm_config.rs b/runtime/pendulum/src/xcm_config.rs index c79351700..a3a1afc51 100644 --- a/runtime/pendulum/src/xcm_config.rs +++ b/runtime/pendulum/src/xcm_config.rs @@ -1,6 +1,7 @@ use core::marker::PhantomData; use cumulus_primitives_utility::XcmFeesTo32ByteAccount; +use frame_support::traits::{Contains, PalletInfoAccess}; use frame_support::{ match_types, parameter_types, traits::{ContainsPair, Everything, Nothing, ProcessMessageError}, @@ -15,6 +16,7 @@ use orml_xcm_support::{DepositToAlternative, IsNativeConcrete, MultiCurrencyAdap use pallet_xcm::XcmPassthrough; use polkadot_parachain::primitives::Sibling; use sp_runtime::traits::Convert; +use sp_std::vec::Vec; use staging_xcm_builder::{ AccountId32Aliases, AllowKnownQueryResponses, AllowSubscriptionsFrom, @@ -52,6 +54,17 @@ parameter_types! { pub CheckingAccount: AccountId = PolkadotXcm::check_account(); pub UniversalLocation: InteriorMultiLocation = X2(GlobalConsensus(RelayNetwork::get()), Parachain(ParachainInfo::parachain_id().into())); + + /// Asset Hub + pub AssetHubLocation: MultiLocation = (Parent, Parachain(1000)).into(); + + // PEN (native) + pub NativeTokenLocation: MultiLocation = MultiLocation { + parents: 0, + interior: Junctions::X1( + PalletInstance(::index() as u8) + ) + }; } /// Type for specifying how a `MultiLocation` can be converted into an `AccountId`. This is used @@ -118,7 +131,7 @@ pub type XcmOriginToTransactDispatchOrigin = ( parameter_types! { // One XCM operation is 1_000_000_000 weight - almost certainly a conservative estimate. - pub UnitWeightCost: XCMWeight = XCMWeight::from_parts(1_000_000_000, 0); + pub UnitWeightCost: XCMWeight = XCMWeight::from_parts(1_000_000_000, 1024); pub const MaxInstructions: u32 = 100; pub SelfLocation: MultiLocation = MultiLocation::here(); pub const BaseXcmWeight: XCMWeight = XCMWeight::from_parts(150_000_000, 0); @@ -266,6 +279,20 @@ impl AutomationPalletConfig for AutomationPalletConfigPendulum { pub type LocalAssetTransactor = CustomTransactorInterceptor; +pub struct TrustedTeleporters; +impl ContainsPair for TrustedTeleporters { + fn contains(asset: &MultiAsset, origin: &MultiLocation) -> bool { + if let MultiAsset { id: Concrete(loc), fun: Fungible(_) } = asset { + if loc == &NativeTokenLocation::get() && origin == &AssetHubLocation::get() { + log::trace!(target: "xcm::TrustedTeleporters", "Allowing teleport of native asset from Asset Hub"); + return true; + } + } + + false + } +} + pub struct XcmConfig; impl staging_xcm_executor::Config for XcmConfig { type RuntimeCall = RuntimeCall; @@ -275,7 +302,7 @@ impl staging_xcm_executor::Config for XcmConfig { type OriginConverter = XcmOriginToTransactDispatchOrigin; type IsReserve = MultiNativeAsset; // Teleporting is disabled. - type IsTeleporter = (); + type IsTeleporter = TrustedTeleporters; type UniversalLocation = UniversalLocation; type Barrier = Barrier; type Weigher = FixedWeightBounds; @@ -308,6 +335,21 @@ pub type XcmRouter = ( XcmpQueue, ); +pub struct OnlyTeleportNative; +impl Contains<(MultiLocation, Vec)> for OnlyTeleportNative { + fn contains(t: &(MultiLocation, Vec)) -> bool { + let native = NativeTokenLocation::get(); + t.1.iter().all(|asset| { + log::trace!(target: "xcm::OnlyTeleportNative", "Asset to be teleported: {:?}", asset); + if let MultiAsset { id: Concrete(location), fun: Fungible(_) } = asset { + *location == native + } else { + false + } + }) + } +} + impl pallet_xcm::Config for Runtime { type RuntimeEvent = RuntimeEvent; type Currency = Balances; @@ -319,7 +361,7 @@ impl pallet_xcm::Config for Runtime { // ^ Disable dispatchable execute on the XCM pallet. // Needs to be `Everything` for local testing. type XcmExecutor = XcmExecutor; - type XcmTeleportFilter = Nothing; + type XcmTeleportFilter = OnlyTeleportNative; type XcmReserveTransferFilter = Everything; type Weigher = FixedWeightBounds; type UniversalLocation = UniversalLocation; From 8929cfacebcb740ee18dedb421a3870921930384 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Feb 2026 16:37:11 +0000 Subject: [PATCH 02/16] Implement xcm-teleport pallet --- pallets/xcm-teleport/Cargo.toml | 50 +++++++ pallets/xcm-teleport/src/lib.rs | 238 ++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 pallets/xcm-teleport/Cargo.toml create mode 100644 pallets/xcm-teleport/src/lib.rs diff --git a/pallets/xcm-teleport/Cargo.toml b/pallets/xcm-teleport/Cargo.toml new file mode 100644 index 000000000..c5b4ad131 --- /dev/null +++ b/pallets/xcm-teleport/Cargo.toml @@ -0,0 +1,50 @@ +[package] +authors = ["Pendulum"] +description = "A pallet to teleport native PEN to AssetHub with correct XCM message ordering" +edition = "2021" +name = "pallet-xcm-teleport" +version = "1.6.0-d" + +[dependencies] +log = { workspace = true } +parity-scale-codec = { workspace = true, features = ["derive"] } +scale-info = { workspace = true, features = ["derive"] } + +frame-support = { workspace = true } +frame-system = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +xcm = { workspace = true } +staging-xcm-executor = { workspace = true } + +# benchmarking +frame-benchmarking = { workspace = true, optional = true } + +[features] +default = ["std"] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "staging-xcm-executor/runtime-benchmarks", +] +std = [ + "frame-support/std", + "frame-system/std", + "log/std", + "parity-scale-codec/std", + "scale-info/std", + "sp-runtime/std", + "sp-std/std", + "xcm/std", + "staging-xcm-executor/std", + "frame-benchmarking?/std", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "sp-runtime/try-runtime", +] diff --git a/pallets/xcm-teleport/src/lib.rs b/pallets/xcm-teleport/src/lib.rs new file mode 100644 index 000000000..9bf7b2f35 --- /dev/null +++ b/pallets/xcm-teleport/src/lib.rs @@ -0,0 +1,238 @@ +//! # XCM Teleport Pallet +//! +//! A pallet that enables teleporting the native PEN token from Pendulum to AssetHub +//! with the correct XCM message ordering that passes AssetHub's barrier. +//! +//! ## Problem +//! +//! The standard `pallet_xcm::limitedTeleportAssets` uses `InitiateTeleport` which always +//! prepends `ReceiveTeleportedAsset` to the inner XCM. This produces a message ordering +//! that AssetHub's barrier rejects when DOT is needed for fees (PEN is not fee-payable on +//! AssetHub). +//! +//! ## Solution +//! +//! This pallet constructs the remote XCM message manually with the correct ordering: +//! ```text +//! WithdrawAsset(DOT) ← from Pendulum's sovereign account on AssetHub +//! BuyExecution(DOT) ← passes the barrier +//! ReceiveTeleportedAsset(PEN) ← mints PEN on AssetHub +//! ClearOrigin +//! DepositAsset(All, beneficiary) +//! ``` +//! +//! Locally, PEN is withdrawn from the sender's account and burned (removed from circulation). +//! The message is sent via `XcmRouter` from the **parachain origin** (no `DescendOrigin`), +//! so `WithdrawAsset(DOT)` correctly accesses the Pendulum sovereign account on AssetHub. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +#[frame_support::pallet] +pub mod pallet { + use frame_support::{ + pallet_prelude::*, + traits::{Currency, ExistenceRequirement, WithdrawReasons}, + }; + use frame_system::pallet_prelude::*; + use sp_std::vec; + use xcm::v3::{ + prelude::*, Instruction, Junction, Junctions, MultiAsset, MultiAssetFilter, MultiAssets, + MultiLocation, SendXcm, WeightLimit, WildMultiAsset, Xcm, + }; + + type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching runtime event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// The native currency (PEN / Balances pallet). + type Currency: Currency; + + /// The XCM router used to send messages to other chains. + type XcmRouter: SendXcm; + + /// The MultiLocation of the destination chain (AssetHub) relative to this chain. + /// For Pendulum → AssetHub: `(Parent, Parachain(1000))`. + #[pallet::constant] + type DestinationLocation: Get; + + /// The MultiLocation of the native token as seen from the destination chain. + /// For PEN on AssetHub: `(parents: 1, X2(Parachain(2094), PalletInstance(10)))`. + #[pallet::constant] + type NativeAssetOnDest: Get; + + /// The MultiLocation of the fee asset (DOT) as seen from the destination chain. + /// For DOT on AssetHub: `(parents: 1, Here)`. + #[pallet::constant] + type FeeAssetOnDest: Get; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// Native tokens were teleported to the destination chain. + TeleportedNativeTo { + /// The account that initiated the teleport. + sender: T::AccountId, + /// The beneficiary account on the destination chain. + beneficiary: T::AccountId, + /// The amount of native token teleported. + amount: BalanceOf, + /// The amount of fee asset (DOT) used for execution on the destination. + fee_amount: u128, + }, + } + + #[pallet::error] + pub enum Error { + /// Failed to send the XCM message to the destination chain. + XcmSendFailed, + /// The teleport amount must be greater than zero. + ZeroAmount, + /// The fee amount must be greater than zero. + ZeroFeeAmount, + /// Failed to convert the amount to u128. + AmountConversionFailed, + } + + #[pallet::call] + impl Pallet + where + T::AccountId: Into<[u8; 32]>, + { + /// Teleport native tokens to the destination chain (AssetHub). + /// + /// This extrinsic: + /// 1. Burns `amount` of native tokens from the sender's account. + /// 2. Sends an XCM message to the destination that: + /// - Withdraws `fee_amount` of the fee asset (DOT) from this chain's + /// sovereign account for execution fees. + /// - Mints `amount` native tokens on the destination via `ReceiveTeleportedAsset`. + /// - Deposits all assets to the `beneficiary`. + /// + /// # Parameters + /// - `origin`: Must be a signed origin (the sender). + /// - `amount`: The amount of native tokens to teleport. + /// - `fee_amount`: The amount of the fee asset (DOT) to use for execution fees + /// on the destination. This is withdrawn from this chain's sovereign account. + /// - `beneficiary`: The destination AccountId32 on the destination chain. + #[pallet::call_index(0)] + #[pallet::weight(Weight::from_parts(200_000_000, 10_000))] + pub fn teleport_native_to_dest( + origin: OriginFor, + amount: BalanceOf, + fee_amount: u128, + beneficiary: T::AccountId, + ) -> DispatchResult { + let sender = ensure_signed(origin)?; + + // Validate inputs + ensure!(amount > BalanceOf::::from(0u32), Error::::ZeroAmount); + ensure!(fee_amount > 0, Error::::ZeroFeeAmount); + + // Convert balance to u128 for XCM + let amount_u128: u128 = amount + .try_into() + .map_err(|_| Error::::AmountConversionFailed)?; + + // 1. Withdraw and burn native tokens from the sender's account. + // Dropping the NegativeImbalance burns the tokens (reduces total issuance). + let _imbalance = T::Currency::withdraw( + &sender, + amount, + WithdrawReasons::TRANSFER, + ExistenceRequirement::AllowDeath, + )?; + // _imbalance is dropped here → tokens are burned + + // 2. Construct the remote XCM message for the destination chain. + let fee_asset_location = T::FeeAssetOnDest::get(); + let native_asset_on_dest = T::NativeAssetOnDest::get(); + + let beneficiary_bytes: [u8; 32] = beneficiary.clone().into(); + let beneficiary_location = MultiLocation { + parents: 0, + interior: Junctions::X1(Junction::AccountId32 { + network: None, + id: beneficiary_bytes, + }), + }; + + let fee_multi_asset = MultiAsset { + id: AssetId::Concrete(fee_asset_location), + fun: Fungibility::Fungible(fee_amount), + }; + + let native_multi_asset = MultiAsset { + id: AssetId::Concrete(native_asset_on_dest), + fun: Fungibility::Fungible(amount_u128), + }; + + let message: Xcm<()> = Xcm(vec![ + // Withdraw fee asset (DOT) from this chain's sovereign account + Instruction::WithdrawAsset(MultiAssets::from(vec![fee_multi_asset.clone()])), + // Pay for execution with the fee asset — this passes the barrier + Instruction::BuyExecution { + fees: fee_multi_asset, + weight_limit: WeightLimit::Unlimited, + }, + // Mint the teleported native tokens on the destination + Instruction::ReceiveTeleportedAsset(MultiAssets::from(vec![native_multi_asset])), + // Remove origin to prevent further privileged operations + Instruction::ClearOrigin, + // Deposit everything (native token + leftover DOT) to the beneficiary + Instruction::DepositAsset { + assets: MultiAssetFilter::Wild(WildMultiAsset::All), + beneficiary: beneficiary_location, + }, + ]); + + // 3. Send the message to the destination via the XCM router. + // Since we call the router directly (not through pallet_xcm::send), + // no DescendOrigin is prepended. The message arrives from the + // parachain origin, so WithdrawAsset accesses the sovereign account. + let dest = T::DestinationLocation::get(); + + log::info!( + target: "xcm-teleport", + "Sending teleport message to {:?}: amount={}, fee_amount={}", + dest, amount_u128, fee_amount, + ); + + let (ticket, _price) = T::XcmRouter::validate(&mut Some(dest), &mut Some(message)) + .map_err(|e| { + log::error!( + target: "xcm-teleport", + "Failed to validate XCM message: {:?}", e + ); + Error::::XcmSendFailed + })?; + + T::XcmRouter::deliver(ticket).map_err(|e| { + log::error!( + target: "xcm-teleport", + "Failed to deliver XCM message: {:?}", e + ); + Error::::XcmSendFailed + })?; + + // 4. Emit event + Self::deposit_event(Event::TeleportedNativeTo { + sender, + beneficiary, + amount, + fee_amount, + }); + + Ok(()) + } + } +} From ba88493ba868b44635c13682b368ef2296ed8804 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Feb 2026 16:38:06 +0000 Subject: [PATCH 03/16] Add xcm-teleport pallet to pendulum runtime --- Cargo.lock | 17 +++++++++++++++++ Cargo.toml | 1 + runtime/pendulum/Cargo.toml | 4 ++++ runtime/pendulum/src/lib.rs | 12 ++++++++++++ runtime/pendulum/src/xcm_config.rs | 15 ++++++++++++++- 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b1b0783b2..7aeacf497 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8591,6 +8591,22 @@ dependencies = [ "staging-xcm-executor", ] +[[package]] +name = "pallet-xcm-teleport" +version = "1.6.0-d" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "parity-scale-codec", + "scale-info", + "sp-runtime 24.0.0 (git+https://github.com/pendulum-chain/polkadot-sdk?rev=22dd6dee5148a0879306337bd8619c16224cc07b)", + "sp-std 8.0.0 (git+https://github.com/pendulum-chain/polkadot-sdk?rev=22dd6dee5148a0879306337bd8619c16224cc07b)", + "staging-xcm", + "staging-xcm-executor", +] + [[package]] name = "parachain-staking" version = "1.6.0-d" @@ -8974,6 +8990,7 @@ dependencies = [ "pallet-utility", "pallet-vesting", "pallet-xcm", + "pallet-xcm-teleport", "parachain-staking", "parachains-common", "parity-scale-codec", diff --git a/Cargo.toml b/Cargo.toml index f46c848fe..3557fcf41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "pallets/orml-currencies-allowance-extension", "pallets/orml-tokens-management-extension", "pallets/treasury-buyout-extension", + "pallets/xcm-teleport", "runtime/common", "runtime/amplitude", "runtime/foucoco", diff --git a/runtime/pendulum/Cargo.toml b/runtime/pendulum/Cargo.toml index d783f9dac..4657b3d8e 100644 --- a/runtime/pendulum/Cargo.toml +++ b/runtime/pendulum/Cargo.toml @@ -120,6 +120,7 @@ dia-oracle-runtime-api = { workspace = true } # Pendulum Pallets vesting-manager = { path = "../../pallets/vesting-manager", default-features = false } +pallet-xcm-teleport = { path = "../../pallets/xcm-teleport", default-features = false } # Polkadot pallet-xcm = { workspace = true } @@ -255,6 +256,7 @@ std = [ "orml-currencies-allowance-extension/std", "parachain-staking/std", "vesting-manager/std", + "pallet-xcm-teleport/std", "price-chain-extension/std", "token-chain-extension/std", "treasury-buyout-extension/std", @@ -330,6 +332,7 @@ runtime-benchmarks = [ "staging-xcm-executor/runtime-benchmarks", "staking/runtime-benchmarks", "vesting-manager/runtime-benchmarks", + "pallet-xcm-teleport/runtime-benchmarks", ] try-runtime = [ @@ -386,6 +389,7 @@ try-runtime = [ "dia-oracle/try-runtime", "orml-currencies-allowance-extension/try-runtime", "vesting-manager/try-runtime", + "pallet-xcm-teleport/try-runtime", "bifrost-farming/try-runtime", "zenlink-protocol/try-runtime", "treasury-buyout-extension/try-runtime", diff --git a/runtime/pendulum/src/lib.rs b/runtime/pendulum/src/lib.rs index c7de018d8..8d0ae0f54 100644 --- a/runtime/pendulum/src/lib.rs +++ b/runtime/pendulum/src/lib.rs @@ -371,6 +371,7 @@ impl Contains for BaseFilter { | RuntimeCall::ParachainInfo(_) | RuntimeCall::CumulusXcm(_) | RuntimeCall::VaultStaking(_) + | RuntimeCall::XcmTeleport(_) | RuntimeCall::MessageQueue(_) => true, // All pallets are allowed, but exhaustive match is defensive // in the case of adding new pallets. } @@ -1010,6 +1011,15 @@ impl vesting_manager::Config for Runtime { type VestingSchedule = Vesting; } +impl pallet_xcm_teleport::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type XcmRouter = xcm_config::XcmRouter; + type DestinationLocation = xcm_config::AssetHubLocation; + type NativeAssetOnDest = xcm_config::NativeAssetOnAssetHub; + type FeeAssetOnDest = xcm_config::DotOnAssetHub; +} + const fn deposit(items: u32, bytes: u32) -> Balance { (items as Balance * UNIT + (bytes as Balance) * (5 * MILLIUNIT / 100)) / 10 } @@ -1584,6 +1594,8 @@ construct_runtime!( VestingManager: vesting_manager = 100, + XcmTeleport: pallet_xcm_teleport = 101, + MessageQueue: pallet_message_queue = 110, } ); diff --git a/runtime/pendulum/src/xcm_config.rs b/runtime/pendulum/src/xcm_config.rs index a3a1afc51..e238a1a39 100644 --- a/runtime/pendulum/src/xcm_config.rs +++ b/runtime/pendulum/src/xcm_config.rs @@ -58,13 +58,26 @@ parameter_types! { /// Asset Hub pub AssetHubLocation: MultiLocation = (Parent, Parachain(1000)).into(); - // PEN (native) + // PEN (native) — local location pub NativeTokenLocation: MultiLocation = MultiLocation { parents: 0, interior: Junctions::X1( PalletInstance(::index() as u8) ) }; + + /// PEN location as seen from AssetHub (used for ReceiveTeleportedAsset on the remote side). + /// (parents: 1, X2(Parachain(self), PalletInstance(Balances_index))) + pub NativeAssetOnAssetHub: MultiLocation = MultiLocation { + parents: 1, + interior: Junctions::X2( + Parachain(ParachainInfo::parachain_id().into()), + PalletInstance(::index() as u8), + ) + }; + + /// DOT location as seen from AssetHub (the relay chain token). + pub const DotOnAssetHub: MultiLocation = MultiLocation { parents: 1, interior: Junctions::Here }; } /// Type for specifying how a `MultiLocation` can be converted into an `AccountId`. This is used From 16998b50cf6f205da83f5e2ba2a447eec3ba84a9 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Feb 2026 16:44:39 +0000 Subject: [PATCH 04/16] Adjust xcm-teleport pallet to deposit leftover DOT back into sovereign account --- pallets/xcm-teleport/src/lib.rs | 53 ++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/pallets/xcm-teleport/src/lib.rs b/pallets/xcm-teleport/src/lib.rs index 9bf7b2f35..24d67d218 100644 --- a/pallets/xcm-teleport/src/lib.rs +++ b/pallets/xcm-teleport/src/lib.rs @@ -18,12 +18,23 @@ //! BuyExecution(DOT) ← passes the barrier //! ReceiveTeleportedAsset(PEN) ← mints PEN on AssetHub //! ClearOrigin -//! DepositAsset(All, beneficiary) +//! DepositAsset(PEN, beneficiary) ← only PEN goes to the user +//! DepositAsset(remaining, sovereign_acct) ← leftover DOT returns to sovereign //! ``` //! //! Locally, PEN is withdrawn from the sender's account and burned (removed from circulation). //! The message is sent via `XcmRouter` from the **parachain origin** (no `DescendOrigin`), //! so `WithdrawAsset(DOT)` correctly accesses the Pendulum sovereign account on AssetHub. +//! +//! ## Fee Protection +//! +//! Two layers of protection prevent users from draining the sovereign DOT balance: +//! +//! 1. **Max fee cap** (`MaxFeeAmount`): The `fee_amount` parameter is capped at a +//! configurable maximum. Any value above this is rejected. +//! +//! 2. **Split deposits**: PEN is deposited to the beneficiary, but leftover DOT (not +//! consumed by `BuyExecution`) is returned to the sovereign account — not the user. #![cfg_attr(not(feature = "std"), no_std)] @@ -39,7 +50,7 @@ pub mod pallet { use sp_std::vec; use xcm::v3::{ prelude::*, Instruction, Junction, Junctions, MultiAsset, MultiAssetFilter, MultiAssets, - MultiLocation, SendXcm, WeightLimit, WildMultiAsset, Xcm, + MultiLocation, SendXcm, WeightLimit, WildFungibility, WildMultiAsset, Xcm, }; type BalanceOf = @@ -73,6 +84,17 @@ pub mod pallet { /// For DOT on AssetHub: `(parents: 1, Here)`. #[pallet::constant] type FeeAssetOnDest: Get; + + /// The MultiLocation of this chain's sovereign account on the destination, + /// used to return leftover fee assets after execution. + /// For Pendulum on AssetHub: `(parents: 0, X1(AccountId32 { network: None, id: sovereign_bytes }))`. + #[pallet::constant] + type SovereignAccountOnDest: Get; + + /// Maximum fee amount (in fee asset's smallest unit) that can be specified. + /// This prevents users from draining the sovereign account's fee asset balance. + #[pallet::constant] + type MaxFeeAmount: Get; } #[pallet::event] @@ -99,6 +121,8 @@ pub mod pallet { ZeroAmount, /// The fee amount must be greater than zero. ZeroFeeAmount, + /// The fee amount exceeds the maximum allowed. + FeeAmountTooHigh, /// Failed to convert the amount to u128. AmountConversionFailed, } @@ -116,13 +140,15 @@ pub mod pallet { /// - Withdraws `fee_amount` of the fee asset (DOT) from this chain's /// sovereign account for execution fees. /// - Mints `amount` native tokens on the destination via `ReceiveTeleportedAsset`. - /// - Deposits all assets to the `beneficiary`. + /// - Deposits only the native tokens to the `beneficiary`. + /// - Returns any leftover fee asset (DOT) to the sovereign account. /// /// # Parameters /// - `origin`: Must be a signed origin (the sender). /// - `amount`: The amount of native tokens to teleport. /// - `fee_amount`: The amount of the fee asset (DOT) to use for execution fees - /// on the destination. This is withdrawn from this chain's sovereign account. + /// on the destination. Must not exceed `MaxFeeAmount`. This DOT is withdrawn + /// from this chain's sovereign account on the destination. /// - `beneficiary`: The destination AccountId32 on the destination chain. #[pallet::call_index(0)] #[pallet::weight(Weight::from_parts(200_000_000, 10_000))] @@ -137,6 +163,10 @@ pub mod pallet { // Validate inputs ensure!(amount > BalanceOf::::from(0u32), Error::::ZeroAmount); ensure!(fee_amount > 0, Error::::ZeroFeeAmount); + ensure!( + fee_amount <= T::MaxFeeAmount::get(), + Error::::FeeAmountTooHigh + ); // Convert balance to u128 for XCM let amount_u128: u128 = amount @@ -156,6 +186,7 @@ pub mod pallet { // 2. Construct the remote XCM message for the destination chain. let fee_asset_location = T::FeeAssetOnDest::get(); let native_asset_on_dest = T::NativeAssetOnDest::get(); + let sovereign_on_dest = T::SovereignAccountOnDest::get(); let beneficiary_bytes: [u8; 32] = beneficiary.clone().into(); let beneficiary_location = MultiLocation { @@ -172,7 +203,7 @@ pub mod pallet { }; let native_multi_asset = MultiAsset { - id: AssetId::Concrete(native_asset_on_dest), + id: AssetId::Concrete(native_asset_on_dest.clone()), fun: Fungibility::Fungible(amount_u128), }; @@ -188,11 +219,19 @@ pub mod pallet { Instruction::ReceiveTeleportedAsset(MultiAssets::from(vec![native_multi_asset])), // Remove origin to prevent further privileged operations Instruction::ClearOrigin, - // Deposit everything (native token + leftover DOT) to the beneficiary + // Deposit ONLY the native token (PEN) to the beneficiary Instruction::DepositAsset { - assets: MultiAssetFilter::Wild(WildMultiAsset::All), + assets: MultiAssetFilter::Wild(WildMultiAsset::AllOf { + id: AssetId::Concrete(native_asset_on_dest), + fun: WildFungibility::Fungible, + }), beneficiary: beneficiary_location, }, + // Return any leftover fee asset (DOT) to the sovereign account + Instruction::DepositAsset { + assets: MultiAssetFilter::Wild(WildMultiAsset::All), + beneficiary: sovereign_on_dest, + }, ]); // 3. Send the message to the destination via the XCM router. From acbcf82123ea83600f7833b83a13091d757dc4b3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Feb 2026 16:49:52 +0000 Subject: [PATCH 05/16] Adjust config in pendulum runtime --- runtime/pendulum/src/lib.rs | 2 ++ runtime/pendulum/src/xcm_config.rs | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/runtime/pendulum/src/lib.rs b/runtime/pendulum/src/lib.rs index 8d0ae0f54..237a6ccfe 100644 --- a/runtime/pendulum/src/lib.rs +++ b/runtime/pendulum/src/lib.rs @@ -1018,6 +1018,8 @@ impl pallet_xcm_teleport::Config for Runtime { type DestinationLocation = xcm_config::AssetHubLocation; type NativeAssetOnDest = xcm_config::NativeAssetOnAssetHub; type FeeAssetOnDest = xcm_config::DotOnAssetHub; + type SovereignAccountOnDest = xcm_config::SovereignAccountOnAssetHub; + type MaxFeeAmount = xcm_config::MaxDotFeeAmount; } const fn deposit(items: u32, bytes: u32) -> Balance { diff --git a/runtime/pendulum/src/xcm_config.rs b/runtime/pendulum/src/xcm_config.rs index e238a1a39..f08dfd8da 100644 --- a/runtime/pendulum/src/xcm_config.rs +++ b/runtime/pendulum/src/xcm_config.rs @@ -15,7 +15,7 @@ use orml_traits::{ use orml_xcm_support::{DepositToAlternative, IsNativeConcrete, MultiCurrencyAdapter}; use pallet_xcm::XcmPassthrough; use polkadot_parachain::primitives::Sibling; -use sp_runtime::traits::Convert; +use sp_runtime::traits::{AccountIdConversion, Convert}; use sp_std::vec::Vec; use staging_xcm_builder::{ @@ -78,6 +78,19 @@ parameter_types! { /// DOT location as seen from AssetHub (the relay chain token). pub const DotOnAssetHub: MultiLocation = MultiLocation { parents: 1, interior: Junctions::Here }; + + /// Pendulum's sovereign account on AssetHub, used for returning leftover DOT fees. + /// Computed from Sibling(para_id) using the standard AccountIdConversion. + pub SovereignAccountOnAssetHub: MultiLocation = { + let sovereign: AccountId = Sibling::from(ParachainInfo::parachain_id()).into_account_truncating(); + MultiLocation { + parents: 0, + interior: Junctions::X1(AccountId32 { network: None, id: sovereign.into() }), + } + }; + + /// Maximum amount of DOT (in Plancks) that can be used for fees per teleport. + pub const MaxDotFeeAmount: u128 = 10_000_000_000; // 1 DOT } /// Type for specifying how a `MultiLocation` can be converted into an `AccountId`. This is used From 267bf397e6269b49ce6446dcbf62987220e2b3c1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Feb 2026 17:46:24 +0000 Subject: [PATCH 06/16] Implement teleport check-in and check-out logic in custom transactor --- runtime/common/src/custom_transactor.rs | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/runtime/common/src/custom_transactor.rs b/runtime/common/src/custom_transactor.rs index efa7aa2eb..6bc90f585 100644 --- a/runtime/common/src/custom_transactor.rs +++ b/runtime/common/src/custom_transactor.rs @@ -57,4 +57,43 @@ impl result::Result { WrappedTransactor::transfer_asset(asset, from, to, _context) } + + fn can_check_out( + _dest: &MultiLocation, + _what: &MultiAsset, + _context: &XcmContext, + ) -> Result { + // Allow teleport check-out. The asset has already been withdrawn from the + // sender's account via WithdrawAsset and is in the holding register. + // We simply permit the teleport-out here. + Ok(()) + } + + fn check_out( + _dest: &MultiLocation, + _what: &MultiAsset, + _context: &XcmContext, + ) { + // No-op: the asset was already withdrawn from the sender's account. + // In a teleport, the local side just needs to ensure the asset is + // removed from circulation, which WithdrawAsset + not depositing + // back effectively does (the asset is burned from holding). + } + + fn can_check_in( + _origin: &MultiLocation, + _what: &MultiAsset, + _context: &XcmContext, + ) -> Result { + // Allow teleport check-in (receiving teleported assets). + Ok(()) + } + + fn check_in( + _origin: &MultiLocation, + _what: &MultiAsset, + _context: &XcmContext, + ) { + // No-op: the asset will be deposited via deposit_asset. + } } From c1ae66a021d71bf7cea2388665db479f5ae032c2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Feb 2026 18:44:21 +0000 Subject: [PATCH 07/16] Implement teleport destination validation in custom transactor --- Cargo.lock | 1 + runtime/common/Cargo.toml | 2 + runtime/common/src/custom_transactor.rs | 52 ++++++++++++++++++------- runtime/pendulum/src/xcm_config.rs | 23 ++++++++++- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7aeacf497..3e9b3f309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11313,6 +11313,7 @@ dependencies = [ "frame-benchmarking", "frame-support", "frame-system", + "log", "orml-asset-registry", "orml-traits", "orml-xcm-support", diff --git a/runtime/common/Cargo.toml b/runtime/common/Cargo.toml index 175d285a5..a17730a4e 100644 --- a/runtime/common/Cargo.toml +++ b/runtime/common/Cargo.toml @@ -11,6 +11,7 @@ edition = "2021" targets = ["x86_64-unknown-linux-gnu"] [dependencies] +log = { workspace = true } paste.workspace = true parity-scale-codec = { workspace = true, features = ["derive"] } scale-info = { workspace = true, features = ["derive"] } @@ -45,6 +46,7 @@ default = [ ] std = [ + "log/std", "parity-scale-codec/std", "scale-info/std", "frame-benchmarking?/std", diff --git a/runtime/common/src/custom_transactor.rs b/runtime/common/src/custom_transactor.rs index 6bc90f585..7f7b26c97 100644 --- a/runtime/common/src/custom_transactor.rs +++ b/runtime/common/src/custom_transactor.rs @@ -1,3 +1,4 @@ +use frame_support::traits::Contains; use sp_std::{marker::PhantomData, result}; use staging_xcm_executor::{traits::TransactAsset, Assets}; @@ -14,12 +15,25 @@ pub trait AutomationPalletConfig { fn callback(length: u8, data: [u8; 32], amount: u128) -> Result; } -pub struct CustomTransactorInterceptor( - PhantomData<(WrappedTransactor, AutomationPalletConfigT)>, -); +/// A wrapper around an inner `TransactAsset` that: +/// 1. Intercepts `deposit_asset` to optionally route to an automation pallet callback. +/// 2. Validates teleport destinations in `can_check_out` against `AllowedTeleportDest`. +/// +/// `AllowedTeleportDest` is a `Contains` filter that determines which +/// destinations are valid for teleporting assets out of this chain. If a destination +/// is not in the allowed set, `can_check_out` returns an error. +pub struct CustomTransactorInterceptor< + WrappedTransactor, + AutomationPalletConfigT, + AllowedTeleportDest, +>(PhantomData<(WrappedTransactor, AutomationPalletConfigT, AllowedTeleportDest)>); -impl - TransactAsset for CustomTransactorInterceptor +impl< + WrappedTransactor: TransactAsset, + AutomationPalletConfigT: AutomationPalletConfig, + AllowedTeleportDest: Contains, + > TransactAsset + for CustomTransactorInterceptor { fn deposit_asset( asset: &MultiAsset, @@ -59,13 +73,21 @@ impl Result { - // Allow teleport check-out. The asset has already been withdrawn from the - // sender's account via WithdrawAsset and is in the holding register. - // We simply permit the teleport-out here. + // Only allow teleporting assets to destinations in the AllowedTeleportDest set. + // This prevents users from burning tokens by teleporting to chains that don't + // recognize this asset as teleportable. + if !AllowedTeleportDest::contains(dest) { + log::warn!( + target: "xcm::custom_transactor", + "Teleport check-out rejected: destination {:?} is not in the allowed set", + dest, + ); + return Err(XcmError::Unroutable); + } Ok(()) } @@ -74,10 +96,9 @@ impl Result { // Allow teleport check-in (receiving teleported assets). + // The origin is already validated by IsTeleporter (TrustedTeleporters) + // before this method is called. Ok(()) } @@ -94,6 +117,7 @@ impl for AllowedTeleportDestinations { + fn contains(dest: &MultiLocation) -> bool { + *dest == AssetHubLocation::get() + } +} + pub type LocalAssetTransactor = - CustomTransactorInterceptor; + CustomTransactorInterceptor; pub struct TrustedTeleporters; impl ContainsPair for TrustedTeleporters { @@ -365,6 +373,19 @@ pub struct OnlyTeleportNative; impl Contains<(MultiLocation, Vec)> for OnlyTeleportNative { fn contains(t: &(MultiLocation, Vec)) -> bool { let native = NativeTokenLocation::get(); + let allowed_dest = AssetHubLocation::get(); + + // Only allow teleporting to AssetHub + if t.0 != allowed_dest { + log::warn!( + target: "xcm::OnlyTeleportNative", + "Teleport rejected: destination {:?} is not AssetHub", + t.0 + ); + return false; + } + + // Only allow teleporting PEN (native token) t.1.iter().all(|asset| { log::trace!(target: "xcm::OnlyTeleportNative", "Asset to be teleported: {:?}", asset); if let MultiAsset { id: Concrete(location), fun: Fungible(_) } = asset { From b5461856761e15f2ad3741c44b8603fba47f216e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 27 Feb 2026 08:59:42 +0000 Subject: [PATCH 08/16] Rename variables --- pallets/xcm-teleport/src/lib.rs | 45 ++++++++++++++++----------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/pallets/xcm-teleport/src/lib.rs b/pallets/xcm-teleport/src/lib.rs index 24d67d218..1545398cf 100644 --- a/pallets/xcm-teleport/src/lib.rs +++ b/pallets/xcm-teleport/src/lib.rs @@ -100,15 +100,15 @@ pub mod pallet { #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { - /// Native tokens were teleported to the destination chain. - TeleportedNativeTo { + /// Native tokens were teleported to AssetHub. + NativeTeleportedToAssetHub { /// The account that initiated the teleport. sender: T::AccountId, - /// The beneficiary account on the destination chain. + /// The beneficiary account on AssetHub. beneficiary: T::AccountId, /// The amount of native token teleported. amount: BalanceOf, - /// The amount of fee asset (DOT) used for execution on the destination. + /// The amount of DOT used for execution fees on AssetHub. fee_amount: u128, }, } @@ -132,27 +132,26 @@ pub mod pallet { where T::AccountId: Into<[u8; 32]>, { - /// Teleport native tokens to the destination chain (AssetHub). + /// Teleport native tokens to AssetHub. /// /// This extrinsic: - /// 1. Burns `amount` of native tokens from the sender's account. - /// 2. Sends an XCM message to the destination that: - /// - Withdraws `fee_amount` of the fee asset (DOT) from this chain's - /// sovereign account for execution fees. - /// - Mints `amount` native tokens on the destination via `ReceiveTeleportedAsset`. + /// 1. Burns `amount` of native tokens from the sender's account on this chain. + /// 2. Sends an XCM message to AssetHub that: + /// - Withdraws `fee_amount` DOT from this chain's sovereign account for fees. + /// - Mints `amount` native tokens on AssetHub via `ReceiveTeleportedAsset`. /// - Deposits only the native tokens to the `beneficiary`. - /// - Returns any leftover fee asset (DOT) to the sovereign account. + /// - Returns any leftover DOT to the sovereign account. /// /// # Parameters /// - `origin`: Must be a signed origin (the sender). /// - `amount`: The amount of native tokens to teleport. - /// - `fee_amount`: The amount of the fee asset (DOT) to use for execution fees - /// on the destination. Must not exceed `MaxFeeAmount`. This DOT is withdrawn - /// from this chain's sovereign account on the destination. - /// - `beneficiary`: The destination AccountId32 on the destination chain. + /// - `fee_amount`: The amount of DOT (in Plancks) to use for execution fees + /// on AssetHub. Must not exceed `MaxFeeAmount`. This DOT is withdrawn + /// from this chain's sovereign account on AssetHub. + /// - `beneficiary`: The destination AccountId32 on AssetHub. #[pallet::call_index(0)] #[pallet::weight(Weight::from_parts(200_000_000, 10_000))] - pub fn teleport_native_to_dest( + pub fn teleport_native_to_asset_hub( origin: OriginFor, amount: BalanceOf, fee_amount: u128, @@ -183,7 +182,7 @@ pub mod pallet { )?; // _imbalance is dropped here → tokens are burned - // 2. Construct the remote XCM message for the destination chain. + // 2. Construct the remote XCM message for AssetHub. let fee_asset_location = T::FeeAssetOnDest::get(); let native_asset_on_dest = T::NativeAssetOnDest::get(); let sovereign_on_dest = T::SovereignAccountOnDest::get(); @@ -234,19 +233,19 @@ pub mod pallet { }, ]); - // 3. Send the message to the destination via the XCM router. + // 3. Send the message to AssetHub via the XCM router. // Since we call the router directly (not through pallet_xcm::send), // no DescendOrigin is prepended. The message arrives from the // parachain origin, so WithdrawAsset accesses the sovereign account. - let dest = T::DestinationLocation::get(); + let asset_hub = T::DestinationLocation::get(); log::info!( target: "xcm-teleport", - "Sending teleport message to {:?}: amount={}, fee_amount={}", - dest, amount_u128, fee_amount, + "Teleporting native to AssetHub ({:?}): amount={}, fee_amount={}", + asset_hub, amount_u128, fee_amount, ); - let (ticket, _price) = T::XcmRouter::validate(&mut Some(dest), &mut Some(message)) + let (ticket, _price) = T::XcmRouter::validate(&mut Some(asset_hub), &mut Some(message)) .map_err(|e| { log::error!( target: "xcm-teleport", @@ -264,7 +263,7 @@ pub mod pallet { })?; // 4. Emit event - Self::deposit_event(Event::TeleportedNativeTo { + Self::deposit_event(Event::NativeTeleportedToAssetHub { sender, beneficiary, amount, From 7bac43233b9f2343114db36d9034a43ddfab4ecb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:51:05 +0100 Subject: [PATCH 09/16] Address review comments: remove unused deps, fix imbalance handling, update comments (#554) * Initial plan * Address review comments: remove unused deps, fix comment, handle imbalance properly Co-authored-by: ebma <6690623+ebma@users.noreply.github.com> * Clarify comment about refund limitations Co-authored-by: ebma <6690623+ebma@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ebma <6690623+ebma@users.noreply.github.com> --- Cargo.lock | 1 - pallets/xcm-teleport/Cargo.toml | 3 --- pallets/xcm-teleport/src/lib.rs | 34 +++++++++++++++++++----------- runtime/pendulum/src/xcm_config.rs | 2 +- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e9b3f309..62d42714d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8604,7 +8604,6 @@ dependencies = [ "sp-runtime 24.0.0 (git+https://github.com/pendulum-chain/polkadot-sdk?rev=22dd6dee5148a0879306337bd8619c16224cc07b)", "sp-std 8.0.0 (git+https://github.com/pendulum-chain/polkadot-sdk?rev=22dd6dee5148a0879306337bd8619c16224cc07b)", "staging-xcm", - "staging-xcm-executor", ] [[package]] diff --git a/pallets/xcm-teleport/Cargo.toml b/pallets/xcm-teleport/Cargo.toml index c5b4ad131..ddcddefec 100644 --- a/pallets/xcm-teleport/Cargo.toml +++ b/pallets/xcm-teleport/Cargo.toml @@ -16,7 +16,6 @@ sp-runtime = { workspace = true } sp-std = { workspace = true } xcm = { workspace = true } -staging-xcm-executor = { workspace = true } # benchmarking frame-benchmarking = { workspace = true, optional = true } @@ -29,7 +28,6 @@ runtime-benchmarks = [ "frame-support/runtime-benchmarks", "frame-system/runtime-benchmarks", "sp-runtime/runtime-benchmarks", - "staging-xcm-executor/runtime-benchmarks", ] std = [ "frame-support/std", @@ -40,7 +38,6 @@ std = [ "sp-runtime/std", "sp-std/std", "xcm/std", - "staging-xcm-executor/std", "frame-benchmarking?/std", ] try-runtime = [ diff --git a/pallets/xcm-teleport/src/lib.rs b/pallets/xcm-teleport/src/lib.rs index 1545398cf..28f12cec0 100644 --- a/pallets/xcm-teleport/src/lib.rs +++ b/pallets/xcm-teleport/src/lib.rs @@ -47,7 +47,6 @@ pub mod pallet { traits::{Currency, ExistenceRequirement, WithdrawReasons}, }; use frame_system::pallet_prelude::*; - use sp_std::vec; use xcm::v3::{ prelude::*, Instruction, Junction, Junctions, MultiAsset, MultiAssetFilter, MultiAssets, MultiLocation, SendXcm, WeightLimit, WildFungibility, WildMultiAsset, Xcm, @@ -172,15 +171,17 @@ pub mod pallet { .try_into() .map_err(|_| Error::::AmountConversionFailed)?; - // 1. Withdraw and burn native tokens from the sender's account. - // Dropping the NegativeImbalance burns the tokens (reduces total issuance). - let _imbalance = T::Currency::withdraw( + // 1. Withdraw native tokens from the sender's account. + // We keep the imbalance and only burn it after successful XCM delivery. + // If validation or delivery fails locally, we refund the tokens back to the sender. + // Note: If the message is delivered but fails during execution on AssetHub, + // the tokens are still burned (remote execution failures cannot be detected here). + let imbalance = T::Currency::withdraw( &sender, amount, WithdrawReasons::TRANSFER, ExistenceRequirement::AllowDeath, )?; - // _imbalance is dropped here → tokens are burned // 2. Construct the remote XCM message for AssetHub. let fee_asset_location = T::FeeAssetOnDest::get(); @@ -245,22 +246,31 @@ pub mod pallet { asset_hub, amount_u128, fee_amount, ); - let (ticket, _price) = T::XcmRouter::validate(&mut Some(asset_hub), &mut Some(message)) - .map_err(|e| { + let (ticket, _price) = match T::XcmRouter::validate(&mut Some(asset_hub), &mut Some(message)) { + Ok(result) => result, + Err(e) => { log::error!( target: "xcm-teleport", "Failed to validate XCM message: {:?}", e ); - Error::::XcmSendFailed - })?; + // Refund the withdrawn tokens back to the sender + T::Currency::resolve_creating(&sender, imbalance); + return Err(Error::::XcmSendFailed.into()); + }, + }; - T::XcmRouter::deliver(ticket).map_err(|e| { + if let Err(e) = T::XcmRouter::deliver(ticket) { log::error!( target: "xcm-teleport", "Failed to deliver XCM message: {:?}", e ); - Error::::XcmSendFailed - })?; + // Refund the withdrawn tokens back to the sender + T::Currency::resolve_creating(&sender, imbalance); + return Err(Error::::XcmSendFailed.into()); + } + + // Drop the imbalance to burn the tokens (successful teleport) + drop(imbalance); // 4. Emit event Self::deposit_event(Event::NativeTeleportedToAssetHub { diff --git a/runtime/pendulum/src/xcm_config.rs b/runtime/pendulum/src/xcm_config.rs index 93693a974..c323fa10d 100644 --- a/runtime/pendulum/src/xcm_config.rs +++ b/runtime/pendulum/src/xcm_config.rs @@ -335,7 +335,7 @@ impl staging_xcm_executor::Config for XcmConfig { type AssetTransactor = LocalAssetTransactor; type OriginConverter = XcmOriginToTransactDispatchOrigin; type IsReserve = MultiNativeAsset; - // Teleporting is disabled. + // Teleporting is restricted to assets/origins defined in TrustedTeleporters. type IsTeleporter = TrustedTeleporters; type UniversalLocation = UniversalLocation; type Barrier = Barrier; From c2c126f718ce21d7a2c5c611b69ff30aea214104 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 27 Feb 2026 09:52:09 +0000 Subject: [PATCH 10/16] Revert UnitWeightCost change --- runtime/pendulum/src/xcm_config.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/runtime/pendulum/src/xcm_config.rs b/runtime/pendulum/src/xcm_config.rs index c323fa10d..72133db18 100644 --- a/runtime/pendulum/src/xcm_config.rs +++ b/runtime/pendulum/src/xcm_config.rs @@ -157,7 +157,7 @@ pub type XcmOriginToTransactDispatchOrigin = ( parameter_types! { // One XCM operation is 1_000_000_000 weight - almost certainly a conservative estimate. - pub UnitWeightCost: XCMWeight = XCMWeight::from_parts(1_000_000_000, 1024); + pub UnitWeightCost: XCMWeight = XCMWeight::from_parts(1_000_000_000, 0); pub const MaxInstructions: u32 = 100; pub SelfLocation: MultiLocation = MultiLocation::here(); pub const BaseXcmWeight: XCMWeight = XCMWeight::from_parts(150_000_000, 0); @@ -310,8 +310,11 @@ impl Contains for AllowedTeleportDestinations { } } -pub type LocalAssetTransactor = - CustomTransactorInterceptor; +pub type LocalAssetTransactor = CustomTransactorInterceptor< + Transactor, + AutomationPalletConfigPendulum, + AllowedTeleportDestinations, +>; pub struct TrustedTeleporters; impl ContainsPair for TrustedTeleporters { From 7ee014e47c3b084179c0a67173c81964e5a9fa1e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Mar 2026 17:59:35 +0100 Subject: [PATCH 11/16] Add minimum teleport amount to prevent griefing attacks --- pallets/xcm-teleport/src/lib.rs | 65 ++++++++++++++++++++---------- runtime/pendulum/src/lib.rs | 1 + runtime/pendulum/src/xcm_config.rs | 12 ++++++ 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/pallets/xcm-teleport/src/lib.rs b/pallets/xcm-teleport/src/lib.rs index 28f12cec0..61543b39c 100644 --- a/pallets/xcm-teleport/src/lib.rs +++ b/pallets/xcm-teleport/src/lib.rs @@ -28,13 +28,18 @@ //! //! ## Fee Protection //! -//! Two layers of protection prevent users from draining the sovereign DOT balance: +//! Three layers of protection prevent users from draining the sovereign DOT balance: //! //! 1. **Max fee cap** (`MaxFeeAmount`): The `fee_amount` parameter is capped at a //! configurable maximum. Any value above this is rejected. //! //! 2. **Split deposits**: PEN is deposited to the beneficiary, but leftover DOT (not //! consumed by `BuyExecution`) is returned to the sovereign account — not the user. +//! +//! 3. **Minimum teleport amount** (`MinTeleportAmount`): A minimum PEN amount is +//! required per teleport. Since PEN is burned on the source chain, this makes +//! griefing attacks (spamming cheap teleports to drain sovereign DOT) economically +//! unviable — the attacker must burn meaningful PEN on every call. #![cfg_attr(not(feature = "std"), no_std)] @@ -94,6 +99,10 @@ pub mod pallet { /// This prevents users from draining the sovereign account's fee asset balance. #[pallet::constant] type MaxFeeAmount: Get; + + /// Minimum amount of native tokens required per teleport. + #[pallet::constant] + type MinTeleportAmount: Get>; } #[pallet::event] @@ -124,6 +133,8 @@ pub mod pallet { FeeAmountTooHigh, /// Failed to convert the amount to u128. AmountConversionFailed, + /// The teleport amount is below the required minimum (`MinTeleportAmount`). + AmountBelowMinimum, } #[pallet::call] @@ -148,8 +159,21 @@ pub mod pallet { /// on AssetHub. Must not exceed `MaxFeeAmount`. This DOT is withdrawn /// from this chain's sovereign account on AssetHub. /// - `beneficiary`: The destination AccountId32 on AssetHub. + // Weight: This is a deliberately conservative estimate. The extrinsic performs: + // - 1 Currency::withdraw (1 read + 1 write) + // - XCM message construction (computation only) + // - XcmRouter::validate + deliver (1 read for XCMP queue + 1 write) + // + // The weight is set higher than the pure computational cost to ensure the PEN + // transaction fee covers a meaningful portion of the DOT execution cost on AssetHub + // (~0.001-0.003 DOT per message). This should be replaced with proper benchmarks. + // + // At the current WeightToFee configuration (MILLIUNIT / (10 * ExtrinsicBaseWeight)): + // 1_000_000_000 ref_time ≈ 0.8 MILLIUNIT ≈ 0.0008 PEN + // + // TODO: Replace with proper frame-benchmarking weights once benchmarks are implemented. #[pallet::call_index(0)] - #[pallet::weight(Weight::from_parts(200_000_000, 10_000))] + #[pallet::weight(Weight::from_parts(1_000_000_000, 65_000))] pub fn teleport_native_to_asset_hub( origin: OriginFor, amount: BalanceOf, @@ -159,17 +183,13 @@ pub mod pallet { let sender = ensure_signed(origin)?; // Validate inputs - ensure!(amount > BalanceOf::::from(0u32), Error::::ZeroAmount); + ensure!(amount >= T::MinTeleportAmount::get(), Error::::AmountBelowMinimum); ensure!(fee_amount > 0, Error::::ZeroFeeAmount); - ensure!( - fee_amount <= T::MaxFeeAmount::get(), - Error::::FeeAmountTooHigh - ); + ensure!(fee_amount <= T::MaxFeeAmount::get(), Error::::FeeAmountTooHigh); // Convert balance to u128 for XCM - let amount_u128: u128 = amount - .try_into() - .map_err(|_| Error::::AmountConversionFailed)?; + let amount_u128: u128 = + amount.try_into().map_err(|_| Error::::AmountConversionFailed)?; // 1. Withdraw native tokens from the sender's account. // We keep the imbalance and only burn it after successful XCM delivery. @@ -246,18 +266,19 @@ pub mod pallet { asset_hub, amount_u128, fee_amount, ); - let (ticket, _price) = match T::XcmRouter::validate(&mut Some(asset_hub), &mut Some(message)) { - Ok(result) => result, - Err(e) => { - log::error!( - target: "xcm-teleport", - "Failed to validate XCM message: {:?}", e - ); - // Refund the withdrawn tokens back to the sender - T::Currency::resolve_creating(&sender, imbalance); - return Err(Error::::XcmSendFailed.into()); - }, - }; + let (ticket, _price) = + match T::XcmRouter::validate(&mut Some(asset_hub), &mut Some(message)) { + Ok(result) => result, + Err(e) => { + log::error!( + target: "xcm-teleport", + "Failed to validate XCM message: {:?}", e + ); + // Refund the withdrawn tokens back to the sender + T::Currency::resolve_creating(&sender, imbalance); + return Err(Error::::XcmSendFailed.into()); + }, + }; if let Err(e) = T::XcmRouter::deliver(ticket) { log::error!( diff --git a/runtime/pendulum/src/lib.rs b/runtime/pendulum/src/lib.rs index 237a6ccfe..8d0f7f18e 100644 --- a/runtime/pendulum/src/lib.rs +++ b/runtime/pendulum/src/lib.rs @@ -1020,6 +1020,7 @@ impl pallet_xcm_teleport::Config for Runtime { type FeeAssetOnDest = xcm_config::DotOnAssetHub; type SovereignAccountOnDest = xcm_config::SovereignAccountOnAssetHub; type MaxFeeAmount = xcm_config::MaxDotFeeAmount; + type MinTeleportAmount = xcm_config::MinNativeTeleportAmount; } const fn deposit(items: u32, bytes: u32) -> Balance { diff --git a/runtime/pendulum/src/xcm_config.rs b/runtime/pendulum/src/xcm_config.rs index 72133db18..f3928cec3 100644 --- a/runtime/pendulum/src/xcm_config.rs +++ b/runtime/pendulum/src/xcm_config.rs @@ -91,6 +91,18 @@ parameter_types! { /// Maximum amount of DOT (in Plancks) that can be used for fees per teleport. pub const MaxDotFeeAmount: u128 = 10_000_000_000; // 1 DOT + + /// Minimum PEN amount required per teleport (anti-griefing). + /// + /// Each teleport costs ~0.001-0.003 DOT net from the sovereign account on AssetHub + /// (the XCM execution fee that BuyExecution consumes). Without a minimum, an attacker + /// could spam dust teleports paying only ~0.00016 PEN per call (the Pendulum transaction + /// fee) while draining the sovereign's DOT at ~0.001 DOT per call. + /// + /// At 1 PEN minimum, the attacker must burn 1 PEN per teleport, making the attack cost + /// scale linearly with the number of calls. Combined with the transaction fee, this makes + /// sovereign DOT drainage economically unviable as long as PEN retains meaningful value. + pub MinNativeTeleportAmount: super::Balance = super::UNIT; // 1 PEN } /// Type for specifying how a `MultiLocation` can be converted into an `AccountId`. This is used From 86cd7ecb10c84d271ea1229ee1952fbb4b93aed0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Mar 2026 18:30:40 +0100 Subject: [PATCH 12/16] Add logic to convert between PEN and DOT price using onchain prices --- pallets/xcm-teleport/src/lib.rs | 162 ++++++++++++++++++++++++-------- runtime/pendulum/src/lib.rs | 95 +++++++++++++++++++ 2 files changed, 220 insertions(+), 37 deletions(-) diff --git a/pallets/xcm-teleport/src/lib.rs b/pallets/xcm-teleport/src/lib.rs index 61543b39c..fc6b06367 100644 --- a/pallets/xcm-teleport/src/lib.rs +++ b/pallets/xcm-teleport/src/lib.rs @@ -28,7 +28,7 @@ //! //! ## Fee Protection //! -//! Three layers of protection prevent users from draining the sovereign DOT balance: +//! Four layers of protection prevent users from draining the sovereign DOT balance: //! //! 1. **Max fee cap** (`MaxFeeAmount`): The `fee_amount` parameter is capped at a //! configurable maximum. Any value above this is rejected. @@ -37,20 +37,46 @@ //! consumed by `BuyExecution`) is returned to the sovereign account — not the user. //! //! 3. **Minimum teleport amount** (`MinTeleportAmount`): A minimum PEN amount is -//! required per teleport. Since PEN is burned on the source chain, this makes -//! griefing attacks (spamming cheap teleports to drain sovereign DOT) economically -//! unviable — the attacker must burn meaningful PEN on every call. +//! required per teleport. +//! +//! 4. **Fee-equivalent PEN charge** (`FeeToNativeConverter`): The `fee_amount` DOT that +//! will be withdrawn from the sovereign account on AssetHub is converted to PEN-equivalent +//! using on-chain oracle prices. That PEN amount is transferred from the caller to the +//! treasury. This ensures every teleport costs the caller the DOT-value of fees in PEN, +//! making sovereign DOT drainage economically unviable. The fee is only charged on +//! successful XCM delivery — failed extrinsics refund everything. #![cfg_attr(not(feature = "std"), no_std)] pub use pallet::*; +use frame_support::pallet_prelude::*; +use sp_runtime::DispatchError; + +/// Converts a fee asset amount (in smallest units, e.g., DOT Plancks) to the +/// equivalent amount of the native currency (in smallest units, e.g., PEN Plancks). +/// +/// Implementations should use on-chain price oracles and account for decimal +/// differences between the fee asset and the native currency. +/// +/// A safety margin may be applied by the implementation to account for price +/// fluctuations between when the conversion is computed and when the XCM message +/// executes on the destination chain. +pub trait FeeToNativeConverter { + /// The balance type used for the native currency. + type Balance; + + /// Convert `fee_amount` units of the destination chain's fee asset to the + /// equivalent amount in the local native currency. + /// + /// Returns `Err` if the oracle price is unavailable or the conversion overflows. + fn convert_fee_to_native(fee_amount: u128) -> Result; +} + #[frame_support::pallet] pub mod pallet { - use frame_support::{ - pallet_prelude::*, - traits::{Currency, ExistenceRequirement, WithdrawReasons}, - }; + use super::*; + use frame_support::traits::{Currency, ExistenceRequirement, Get, WithdrawReasons}; use frame_system::pallet_prelude::*; use xcm::v3::{ prelude::*, Instruction, Junction, Junctions, MultiAsset, MultiAssetFilter, MultiAssets, @@ -101,8 +127,28 @@ pub mod pallet { type MaxFeeAmount: Get; /// Minimum amount of native tokens required per teleport. + /// + /// This is an anti-griefing measure. Each teleport costs real DOT from the + /// sovereign account on the destination chain. Without a minimum, an attacker + /// could spam teleports of dust amounts paying only a tiny transaction fee. #[pallet::constant] type MinTeleportAmount: Get>; + + /// Converts a fee asset amount (DOT Plancks) to the equivalent native currency + /// amount (PEN Plancks) using on-chain oracle prices. + /// + /// This is the primary economic protection: the caller must pay PEN equal in + /// value to the DOT that will be withdrawn from the sovereign account. This + /// PEN is transferred to the treasury, removing any economic incentive for + /// griefing attacks. + type FeeToNativeConverter: FeeToNativeConverter>; + + /// The treasury account that receives the PEN fee equivalent. + /// + /// When a user teleports PEN to AssetHub, the fee_amount DOT consumed from + /// the sovereign account is converted to PEN-equivalent and transferred from + /// the caller to this treasury account. + type TreasuryAccount: Get; } #[pallet::event] @@ -116,8 +162,10 @@ pub mod pallet { beneficiary: T::AccountId, /// The amount of native token teleported. amount: BalanceOf, - /// The amount of DOT used for execution fees on AssetHub. + /// The DOT fee amount requested for execution on AssetHub. fee_amount: u128, + /// The PEN equivalent of the DOT fee, transferred to treasury. + fee_pen_equivalent: BalanceOf, }, } @@ -134,7 +182,12 @@ pub mod pallet { /// Failed to convert the amount to u128. AmountConversionFailed, /// The teleport amount is below the required minimum (`MinTeleportAmount`). + /// This minimum exists to prevent griefing attacks that drain the sovereign + /// account's DOT balance on the destination chain. AmountBelowMinimum, + /// Failed to convert the fee asset amount to native currency using oracle prices. + /// This can happen if the oracle price is unavailable or the conversion overflows. + FeeConversionFailed, } #[pallet::call] @@ -145,12 +198,14 @@ pub mod pallet { /// Teleport native tokens to AssetHub. /// /// This extrinsic: - /// 1. Burns `amount` of native tokens from the sender's account on this chain. + /// 1. Withdraws `amount` + fee-PEN from the sender upfront to ensure funds exist. /// 2. Sends an XCM message to AssetHub that: /// - Withdraws `fee_amount` DOT from this chain's sovereign account for fees. /// - Mints `amount` native tokens on AssetHub via `ReceiveTeleportedAsset`. /// - Deposits only the native tokens to the `beneficiary`. /// - Returns any leftover DOT to the sovereign account. + /// 3. On success: burns the teleport amount and deposits fee-PEN to treasury. + /// 4. On failure: refunds everything to the sender. /// /// # Parameters /// - `origin`: Must be a signed origin (the sender). @@ -159,21 +214,20 @@ pub mod pallet { /// on AssetHub. Must not exceed `MaxFeeAmount`. This DOT is withdrawn /// from this chain's sovereign account on AssetHub. /// - `beneficiary`: The destination AccountId32 on AssetHub. - // Weight: This is a deliberately conservative estimate. The extrinsic performs: - // - 1 Currency::withdraw (1 read + 1 write) - // - XCM message construction (computation only) - // - XcmRouter::validate + deliver (1 read for XCMP queue + 1 write) - // - // The weight is set higher than the pure computational cost to ensure the PEN - // transaction fee covers a meaningful portion of the DOT execution cost on AssetHub - // (~0.001-0.003 DOT per message). This should be replaced with proper benchmarks. - // - // At the current WeightToFee configuration (MILLIUNIT / (10 * ExtrinsicBaseWeight)): - // 1_000_000_000 ref_time ≈ 0.8 MILLIUNIT ≈ 0.0008 PEN + /// + /// # Fees + /// + /// The caller pays two costs: + /// 1. The normal Pendulum transaction fee (weight-based). + /// 2. An additional PEN transfer to treasury equal to the DOT-value of + /// `fee_amount`, computed via on-chain oracle prices. This compensates + /// the chain for the sovereign DOT expenditure on AssetHub. // - // TODO: Replace with proper frame-benchmarking weights once benchmarks are implemented. + // Weight: Accounts for Currency::withdraw (x2), oracle reads (x2), + // XCM message construction, and XcmRouter::validate + deliver. + // TODO: Replace with proper frame-benchmarking weights. #[pallet::call_index(0)] - #[pallet::weight(Weight::from_parts(1_000_000_000, 65_000))] + #[pallet::weight(Weight::from_parts(400_000_000, 65_000))] pub fn teleport_native_to_asset_hub( origin: OriginFor, amount: BalanceOf, @@ -191,17 +245,43 @@ pub mod pallet { let amount_u128: u128 = amount.try_into().map_err(|_| Error::::AmountConversionFailed)?; - // 1. Withdraw native tokens from the sender's account. - // We keep the imbalance and only burn it after successful XCM delivery. - // If validation or delivery fails locally, we refund the tokens back to the sender. - // Note: If the message is delivered but fails during execution on AssetHub, - // the tokens are still burned (remote execution failures cannot be detected here). - let imbalance = T::Currency::withdraw( + // Convert the DOT fee_amount to PEN-equivalent using oracle prices. + let fee_pen_equivalent = T::FeeToNativeConverter::convert_fee_to_native(fee_amount) + .map_err(|_| Error::::FeeConversionFailed)?; + + log::info!( + target: "xcm-teleport", + "Fee conversion: {} DOT plancks => {:?} PEN plancks (will be sent to treasury)", + fee_amount, fee_pen_equivalent, + ); + + // 1. Withdraw BOTH the fee-equivalent PEN and the teleport amount upfront. + // This ensures the sender has sufficient funds for everything before we + // attempt the XCM send. Both are refunded if the XCM send fails. + + // Withdraw the fee-equivalent PEN first (KeepAlive so account stays alive + // for the subsequent teleport amount withdrawal). + let fee_imbalance = T::Currency::withdraw( + &sender, + fee_pen_equivalent, + WithdrawReasons::TRANSFER, + ExistenceRequirement::KeepAlive, + )?; + + // Withdraw the teleport amount (AllowDeath — sender may drain entirely). + let teleport_imbalance = match T::Currency::withdraw( &sender, amount, WithdrawReasons::TRANSFER, ExistenceRequirement::AllowDeath, - )?; + ) { + Ok(imbalance) => imbalance, + Err(e) => { + // Refund the fee withdrawal since the teleport withdrawal failed + T::Currency::resolve_creating(&sender, fee_imbalance); + return Err(e); + }, + }; // 2. Construct the remote XCM message for AssetHub. let fee_asset_location = T::FeeAssetOnDest::get(); @@ -274,8 +354,9 @@ pub mod pallet { target: "xcm-teleport", "Failed to validate XCM message: {:?}", e ); - // Refund the withdrawn tokens back to the sender - T::Currency::resolve_creating(&sender, imbalance); + // Refund everything — XCM was never sent + T::Currency::resolve_creating(&sender, fee_imbalance); + T::Currency::resolve_creating(&sender, teleport_imbalance); return Err(Error::::XcmSendFailed.into()); }, }; @@ -285,20 +366,27 @@ pub mod pallet { target: "xcm-teleport", "Failed to deliver XCM message: {:?}", e ); - // Refund the withdrawn tokens back to the sender - T::Currency::resolve_creating(&sender, imbalance); + // Refund everything — XCM delivery failed + T::Currency::resolve_creating(&sender, fee_imbalance); + T::Currency::resolve_creating(&sender, teleport_imbalance); return Err(Error::::XcmSendFailed.into()); } - // Drop the imbalance to burn the tokens (successful teleport) - drop(imbalance); + // 4. XCM sent successfully — finalize: + // - Drop teleport_imbalance to burn the teleported PEN (removed from supply) + drop(teleport_imbalance); + + // - Deposit the fee-equivalent PEN to the treasury account + let treasury = T::TreasuryAccount::get(); + T::Currency::resolve_creating(&treasury, fee_imbalance); - // 4. Emit event + // 5. Emit event Self::deposit_event(Event::NativeTeleportedToAssetHub { sender, beneficiary, amount, fee_amount, + fee_pen_equivalent, }); Ok(()) diff --git a/runtime/pendulum/src/lib.rs b/runtime/pendulum/src/lib.rs index 8d0f7f18e..295be3a30 100644 --- a/runtime/pendulum/src/lib.rs +++ b/runtime/pendulum/src/lib.rs @@ -1011,6 +1011,99 @@ impl vesting_manager::Config for Runtime { type VestingSchedule = Vesting; } +/// Converts a DOT fee amount (in Plancks) to the equivalent PEN amount using +/// on-chain DIA oracle prices. +/// +/// This uses the same oracle infrastructure as the treasury-buyout-extension pallet. +/// Both DOT-USD and PEN-USD prices are fetched from the DIA oracle, and the conversion +/// accounts for the difference in decimals (DOT: 10, PEN: 12). +/// +/// A 10% safety margin is applied to the converted amount to account for price +/// volatility between transaction submission and block inclusion. +pub struct DotToPenFeeConverter; + +impl pallet_xcm_teleport::FeeToNativeConverter for DotToPenFeeConverter { + type Balance = Balance; + + fn convert_fee_to_native(fee_amount: u128) -> Result { + use sp_runtime::{ + traits::{CheckedDiv, CheckedMul}, + FixedPointNumber, FixedU128, + }; + use treasury_buyout_extension::PriceGetter; + + if fee_amount == 0 { + return Ok(0); + } + + // Get USD prices from DIA oracle + let dot_usd_price: FixedU128 = + runtime_common::OraclePriceGetter::::get_price::(XCM(0)) + .map_err(|_| DispatchError::Other("Failed to get DOT price from oracle"))?; + let pen_usd_price: FixedU128 = + runtime_common::OraclePriceGetter::::get_price::( + CurrencyId::Native, + ) + .map_err(|_| DispatchError::Other("Failed to get PEN price from oracle"))?; + + // Get decimals from the asset registry (DOT: 10, PEN: 12) + let dot_decimals = + ::decimals(XCM(0)); + let pen_decimals = + ::decimals( + CurrencyId::Native, + ); + + // Convert: pen_plancks = fee_dot_plancks * dot_usd / pen_usd * 10^(pen_dec - dot_dec) + // Using the same FixedU128 math pattern as treasury_buyout_extension::convert_amount + let from_amount = FixedU128::from_inner(fee_amount); + + let pen_amount_raw: u128 = if dot_decimals > pen_decimals { + // pen_amount = fee * dot_price / pen_price / 10^(dot_dec - pen_dec) + dot_usd_price + .checked_mul(&from_amount) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Overflow))? + .checked_div(&pen_usd_price) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Underflow))? + .checked_div( + &FixedU128::checked_from_integer( + 10u128.pow(dot_decimals.saturating_sub(pen_decimals)), + ) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Overflow))?, + ) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Underflow))? + .into_inner() + } else { + // pen_amount = fee * dot_price * 10^(pen_dec - dot_dec) / pen_price + dot_usd_price + .checked_mul(&from_amount) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Overflow))? + .checked_mul( + &FixedU128::checked_from_integer( + 10u128.pow(pen_decimals.saturating_sub(dot_decimals)), + ) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Overflow))?, + ) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Overflow))? + .checked_div(&pen_usd_price) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Underflow))? + .into_inner() + }; + + // Apply 10% safety margin to account for price volatility. + // This ensures the caller always pays slightly more PEN than the exact DOT value, + // protecting the sovereign account even if prices move between tx submission and + // block inclusion. + let pen_amount_with_margin = pen_amount_raw + .checked_mul(110) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Overflow))? + .checked_div(100) + .ok_or(DispatchError::Arithmetic(sp_runtime::ArithmeticError::Underflow))?; + + Ok(pen_amount_with_margin) + } +} + impl pallet_xcm_teleport::Config for Runtime { type RuntimeEvent = RuntimeEvent; type Currency = Balances; @@ -1021,6 +1114,8 @@ impl pallet_xcm_teleport::Config for Runtime { type SovereignAccountOnDest = xcm_config::SovereignAccountOnAssetHub; type MaxFeeAmount = xcm_config::MaxDotFeeAmount; type MinTeleportAmount = xcm_config::MinNativeTeleportAmount; + type FeeToNativeConverter = DotToPenFeeConverter; + type TreasuryAccount = PendulumTreasuryAccount; } const fn deposit(items: u32, bytes: u32) -> Balance { From ec7ae17136462ee91bba7d43a18f7deae6693200 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Mar 2026 20:12:33 +0100 Subject: [PATCH 13/16] Fix `vec!` missing --- pallets/xcm-teleport/src/lib.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pallets/xcm-teleport/src/lib.rs b/pallets/xcm-teleport/src/lib.rs index fc6b06367..e6db750b7 100644 --- a/pallets/xcm-teleport/src/lib.rs +++ b/pallets/xcm-teleport/src/lib.rs @@ -76,12 +76,10 @@ pub trait FeeToNativeConverter { #[frame_support::pallet] pub mod pallet { use super::*; - use frame_support::traits::{Currency, ExistenceRequirement, Get, WithdrawReasons}; + use frame_support::traits::{Currency, ExistenceRequirement, WithdrawReasons}; use frame_system::pallet_prelude::*; - use xcm::v3::{ - prelude::*, Instruction, Junction, Junctions, MultiAsset, MultiAssetFilter, MultiAssets, - MultiLocation, SendXcm, WeightLimit, WildFungibility, WildMultiAsset, Xcm, - }; + use sp_std::vec; + use xcm::v3::{prelude::*, Junction, Junctions, MultiAsset, MultiLocation, SendXcm, Xcm}; type BalanceOf = <::Currency as Currency<::AccountId>>::Balance; From 867329a6bf9558ac94fc9d462abf23f57056b6fc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 26 Mar 2026 20:26:10 +0100 Subject: [PATCH 14/16] Deposit leftover DOT to beneficiary --- pallets/xcm-teleport/src/lib.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/pallets/xcm-teleport/src/lib.rs b/pallets/xcm-teleport/src/lib.rs index e6db750b7..d7302377d 100644 --- a/pallets/xcm-teleport/src/lib.rs +++ b/pallets/xcm-teleport/src/lib.rs @@ -18,8 +18,7 @@ //! BuyExecution(DOT) ← passes the barrier //! ReceiveTeleportedAsset(PEN) ← mints PEN on AssetHub //! ClearOrigin -//! DepositAsset(PEN, beneficiary) ← only PEN goes to the user -//! DepositAsset(remaining, sovereign_acct) ← leftover DOT returns to sovereign +//! DepositAsset(All, beneficiary) ← PEN + leftover DOT go to the user //! ``` //! //! Locally, PEN is withdrawn from the sender's account and burned (removed from circulation). @@ -284,7 +283,6 @@ pub mod pallet { // 2. Construct the remote XCM message for AssetHub. let fee_asset_location = T::FeeAssetOnDest::get(); let native_asset_on_dest = T::NativeAssetOnDest::get(); - let sovereign_on_dest = T::SovereignAccountOnDest::get(); let beneficiary_bytes: [u8; 32] = beneficiary.clone().into(); let beneficiary_location = MultiLocation { @@ -317,18 +315,16 @@ pub mod pallet { Instruction::ReceiveTeleportedAsset(MultiAssets::from(vec![native_multi_asset])), // Remove origin to prevent further privileged operations Instruction::ClearOrigin, - // Deposit ONLY the native token (PEN) to the beneficiary - Instruction::DepositAsset { - assets: MultiAssetFilter::Wild(WildMultiAsset::AllOf { - id: AssetId::Concrete(native_asset_on_dest), - fun: WildFungibility::Fungible, - }), - beneficiary: beneficiary_location, - }, - // Return any leftover fee asset (DOT) to the sovereign account + // Deposit ALL remaining assets (PEN + leftover DOT) to the beneficiary. + // + // The caller already paid 110% of fee_amount in DOT-equivalent PEN to the + // treasury on the source chain, so the sovereign account is fully compensated. + // The leftover DOT (fee_amount minus actual execution cost) goes to the + // beneficiary as a fair refund. This also helps fund the beneficiary's + // existential deposit on AssetHub where PEN has isSufficient: false. Instruction::DepositAsset { assets: MultiAssetFilter::Wild(WildMultiAsset::All), - beneficiary: sovereign_on_dest, + beneficiary: beneficiary_location, }, ]); From d46c7a4f22932328969a770c8dd7225de68fbf0f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 27 Mar 2026 10:21:22 +0100 Subject: [PATCH 15/16] Reduce MaxDotFeeAmount to 0.5 --- runtime/pendulum/src/xcm_config.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/runtime/pendulum/src/xcm_config.rs b/runtime/pendulum/src/xcm_config.rs index f3928cec3..c6b7cc334 100644 --- a/runtime/pendulum/src/xcm_config.rs +++ b/runtime/pendulum/src/xcm_config.rs @@ -90,18 +90,9 @@ parameter_types! { }; /// Maximum amount of DOT (in Plancks) that can be used for fees per teleport. - pub const MaxDotFeeAmount: u128 = 10_000_000_000; // 1 DOT + pub const MaxDotFeeAmount: u128 = 5_000_000_000; // 0.5 DOT /// Minimum PEN amount required per teleport (anti-griefing). - /// - /// Each teleport costs ~0.001-0.003 DOT net from the sovereign account on AssetHub - /// (the XCM execution fee that BuyExecution consumes). Without a minimum, an attacker - /// could spam dust teleports paying only ~0.00016 PEN per call (the Pendulum transaction - /// fee) while draining the sovereign's DOT at ~0.001 DOT per call. - /// - /// At 1 PEN minimum, the attacker must burn 1 PEN per teleport, making the attack cost - /// scale linearly with the number of calls. Combined with the transaction fee, this makes - /// sovereign DOT drainage economically unviable as long as PEN retains meaningful value. pub MinNativeTeleportAmount: super::Balance = super::UNIT; // 1 PEN } From 9dc8933afcc0940d7fa0e6860b4e9f7d98df0aba Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 27 Mar 2026 11:03:57 +0100 Subject: [PATCH 16/16] Adjust stale code --- pallets/xcm-teleport/src/lib.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pallets/xcm-teleport/src/lib.rs b/pallets/xcm-teleport/src/lib.rs index d7302377d..f1e9dad17 100644 --- a/pallets/xcm-teleport/src/lib.rs +++ b/pallets/xcm-teleport/src/lib.rs @@ -170,8 +170,6 @@ pub mod pallet { pub enum Error { /// Failed to send the XCM message to the destination chain. XcmSendFailed, - /// The teleport amount must be greater than zero. - ZeroAmount, /// The fee amount must be greater than zero. ZeroFeeAmount, /// The fee amount exceeds the maximum allowed. @@ -199,8 +197,7 @@ pub mod pallet { /// 2. Sends an XCM message to AssetHub that: /// - Withdraws `fee_amount` DOT from this chain's sovereign account for fees. /// - Mints `amount` native tokens on AssetHub via `ReceiveTeleportedAsset`. - /// - Deposits only the native tokens to the `beneficiary`. - /// - Returns any leftover DOT to the sovereign account. + /// - Deposits the native tokens and leftover DOT to the `beneficiary`. /// 3. On success: burns the teleport amount and deposits fee-PEN to treasury. /// 4. On failure: refunds everything to the sender. ///