diff --git a/Cargo.lock b/Cargo.lock index b1b0783b2..62d42714d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8591,6 +8591,21 @@ 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", +] + [[package]] name = "parachain-staking" version = "1.6.0-d" @@ -8974,6 +8989,7 @@ dependencies = [ "pallet-utility", "pallet-vesting", "pallet-xcm", + "pallet-xcm-teleport", "parachain-staking", "parachains-common", "parity-scale-codec", @@ -11296,6 +11312,7 @@ dependencies = [ "frame-benchmarking", "frame-support", "frame-system", + "log", "orml-asset-registry", "orml-traits", "orml-xcm-support", 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/pallets/xcm-teleport/Cargo.toml b/pallets/xcm-teleport/Cargo.toml new file mode 100644 index 000000000..ddcddefec --- /dev/null +++ b/pallets/xcm-teleport/Cargo.toml @@ -0,0 +1,47 @@ +[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 } + +# 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", +] +std = [ + "frame-support/std", + "frame-system/std", + "log/std", + "parity-scale-codec/std", + "scale-info/std", + "sp-runtime/std", + "sp-std/std", + "xcm/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..f1e9dad17 --- /dev/null +++ b/pallets/xcm-teleport/src/lib.rs @@ -0,0 +1,386 @@ +//! # 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) ← PEN + leftover DOT go to the user +//! ``` +//! +//! 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 +//! +//! 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. +//! +//! 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. +//! +//! 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 super::*; + use frame_support::traits::{Currency, ExistenceRequirement, WithdrawReasons}; + use frame_system::pallet_prelude::*; + use sp_std::vec; + use xcm::v3::{prelude::*, Junction, Junctions, MultiAsset, MultiLocation, SendXcm, 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; + + /// 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; + + /// 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] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// Native tokens were teleported to AssetHub. + NativeTeleportedToAssetHub { + /// The account that initiated the teleport. + sender: T::AccountId, + /// The beneficiary account on AssetHub. + beneficiary: T::AccountId, + /// The amount of native token teleported. + amount: BalanceOf, + /// 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, + }, + } + + #[pallet::error] + pub enum Error { + /// Failed to send the XCM message to the destination chain. + XcmSendFailed, + /// 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, + /// 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] + impl Pallet + where + T::AccountId: Into<[u8; 32]>, + { + /// Teleport native tokens to AssetHub. + /// + /// This extrinsic: + /// 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 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. + /// + /// # Parameters + /// - `origin`: Must be a signed origin (the sender). + /// - `amount`: The amount of native tokens to teleport. + /// - `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. + /// + /// # 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. + // + // 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(400_000_000, 65_000))] + pub fn teleport_native_to_asset_hub( + origin: OriginFor, + amount: BalanceOf, + fee_amount: u128, + beneficiary: T::AccountId, + ) -> DispatchResult { + let sender = ensure_signed(origin)?; + + // Validate inputs + ensure!(amount >= T::MinTeleportAmount::get(), Error::::AmountBelowMinimum); + 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.try_into().map_err(|_| Error::::AmountConversionFailed)?; + + // 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(); + 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.clone()), + 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 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: beneficiary_location, + }, + ]); + + // 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 asset_hub = T::DestinationLocation::get(); + + log::info!( + target: "xcm-teleport", + "Teleporting native to AssetHub ({:?}): amount={}, fee_amount={}", + 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 everything — XCM was never sent + T::Currency::resolve_creating(&sender, fee_imbalance); + T::Currency::resolve_creating(&sender, teleport_imbalance); + return Err(Error::::XcmSendFailed.into()); + }, + }; + + if let Err(e) = T::XcmRouter::deliver(ticket) { + log::error!( + target: "xcm-teleport", + "Failed to deliver XCM message: {:?}", e + ); + // Refund everything — XCM delivery failed + T::Currency::resolve_creating(&sender, fee_imbalance); + T::Currency::resolve_creating(&sender, teleport_imbalance); + return Err(Error::::XcmSendFailed.into()); + } + + // 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); + + // 5. Emit event + Self::deposit_event(Event::NativeTeleportedToAssetHub { + sender, + beneficiary, + amount, + fee_amount, + fee_pen_equivalent, + }); + + Ok(()) + } + } +} 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 efa7aa2eb..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, @@ -57,4 +71,53 @@ impl result::Result { WrappedTransactor::transfer_asset(asset, from, to, _context) } + + fn can_check_out( + dest: &MultiLocation, + _what: &MultiAsset, + _context: &XcmContext, + ) -> Result { + // 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(()) + } + + fn check_out( + _dest: &MultiLocation, + _what: &MultiAsset, + _context: &XcmContext, + ) { + // No-op: the asset was already withdrawn from the sender's account via + // WithdrawAsset which correctly reduces total issuance via ORML's + // MultiCurrencyAdapter. No additional accounting needed here. + } + + fn can_check_in( + _origin: &MultiLocation, + _what: &MultiAsset, + _context: &XcmContext, + ) -> Result { + // Allow teleport check-in (receiving teleported assets). + // The origin is already validated by IsTeleporter (TrustedTeleporters) + // before this method is called. + Ok(()) + } + + fn check_in( + _origin: &MultiLocation, + _what: &MultiAsset, + _context: &XcmContext, + ) { + // No-op: the asset will be minted/deposited via deposit_asset which + // correctly increases total issuance. + } } 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..295be3a30 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,113 @@ 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; + type XcmRouter = xcm_config::XcmRouter; + 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; + type MinTeleportAmount = xcm_config::MinNativeTeleportAmount; + type FeeToNativeConverter = DotToPenFeeConverter; + type TreasuryAccount = PendulumTreasuryAccount; +} + const fn deposit(items: u32, bytes: u32) -> Balance { (items as Balance * UNIT + (bytes as Balance) * (5 * MILLIUNIT / 100)) / 10 } @@ -1584,6 +1692,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 c79351700..c6b7cc334 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}, @@ -14,7 +15,8 @@ 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::{ AccountId32Aliases, AllowKnownQueryResponses, AllowSubscriptionsFrom, @@ -52,6 +54,46 @@ 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) — 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 }; + + /// 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 = 5_000_000_000; // 0.5 DOT + + /// Minimum PEN amount required per teleport (anti-griefing). + pub MinNativeTeleportAmount: super::Balance = super::UNIT; // 1 PEN } /// Type for specifying how a `MultiLocation` can be converted into an `AccountId`. This is used @@ -263,8 +305,33 @@ impl AutomationPalletConfig for AutomationPalletConfigPendulum { } } -pub type LocalAssetTransactor = - CustomTransactorInterceptor; +/// Only allows teleporting assets to AssetHub. +pub struct AllowedTeleportDestinations; +impl Contains for AllowedTeleportDestinations { + fn contains(dest: &MultiLocation) -> bool { + *dest == AssetHubLocation::get() + } +} + +pub type LocalAssetTransactor = CustomTransactorInterceptor< + Transactor, + AutomationPalletConfigPendulum, + AllowedTeleportDestinations, +>; + +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 { @@ -274,8 +341,8 @@ impl staging_xcm_executor::Config for XcmConfig { type AssetTransactor = LocalAssetTransactor; type OriginConverter = XcmOriginToTransactDispatchOrigin; type IsReserve = MultiNativeAsset; - // Teleporting is disabled. - type IsTeleporter = (); + // Teleporting is restricted to assets/origins defined in TrustedTeleporters. + type IsTeleporter = TrustedTeleporters; type UniversalLocation = UniversalLocation; type Barrier = Barrier; type Weigher = FixedWeightBounds; @@ -308,6 +375,34 @@ pub type XcmRouter = ( XcmpQueue, ); +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 { + *location == native + } else { + false + } + }) + } +} + impl pallet_xcm::Config for Runtime { type RuntimeEvent = RuntimeEvent; type Currency = Balances; @@ -319,7 +414,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;