referrerpolicy=no-referrer-when-downgrade

pallet_psm/
lib.rs

1// This file is part of Substrate.
2
3// Copyright (C) Amforc AG.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! # Peg Stability Module (PSM) Pallet
19//!
20//! A module enabling 1:1 swaps between the runtime's internal stablecoin and pre-approved
21//! external stablecoins.
22//!
23//! ## Pallet API
24//!
25//! See the [`pallet`] module for more information about the interfaces this pallet exposes,
26//! including its configuration trait, dispatchables, storage items, events and errors.
27//!
28//! ## Terminology
29//!
30//! Throughout this pallet two distinct token roles are referenced:
31//!
32//! * **Internal** — the stablecoin issued and burned by the PSM. It is a single asset configured
33//!   via [`Config::InternalAsset`] (e.g. a runtime's pUSD). Mint operations credit the user with
34//!   the internal asset; redeem operations burn it. Fees are collected in the internal asset and
35//!   forwarded to [`Config::FeeDestination`].
36//! * **External** — third-party stablecoins (e.g. USDC, USDT) approved via
37//!   [`Pallet::add_external_asset`] and held in reserve by the PSM. Users deposit external to mint
38//!   internal, and burn internal to redeem external. Multiple external assets can be approved
39//!   simultaneously, each identified by `asset_id`.
40//!
41//! ## Overview
42//!
43//! The PSM strengthens the internal asset's peg by providing arbitrage opportunities:
44//! - When the internal asset trades **above** $1: Users swap external stablecoins for the internal
45//!   asset and sell for profit
46//! - When the internal asset trades **below** $1: Users buy cheap internal asset and swap for
47//!   external stablecoins
48//!
49//! This creates a price corridor bounded by the minting and redemption fees.
50//!
51//! ### Key Concepts
52//!
53//! * **Minting**: Deposit external stablecoin → receive internal asset (minus fee)
54//! * **Redemption**: Burn internal asset → receive external stablecoin (minus fee)
55//! * **Reserve**: External stablecoin balance held by the PSM account (derived, not stored)
56//! * **PSM Debt**: Total internal asset minted through PSM, backed 1:1 by external stablecoins
57//! * **Circuit Breaker**: Emergency control to disable minting or all swaps
58//!
59//! ### Supported Assets
60//!
61//! The PSM supports multiple pre-approved external stablecoins (e.g., USDC, USDT).
62//! Each swap operation specifies which asset to use via the `asset_id` parameter.
63//!
64//! ### Fee Structure
65//!
66//! * **Minting Fee (`MintingFee`)**: Deducted from internal-asset output during minting
67//! * **Redemption Fee (`RedemptionFee`)**: Deducted from external stablecoin output during
68//!   redemption
69//!
70//! Fees are collected in the internal asset and transferred to [`Config::FeeDestination`].
71//!
72//! ### Example
73//!
74//! ```ignore
75//! // Mint internal asset by depositing USDC
76//! Psm::mint(RuntimeOrigin::signed(user), USDC_ASSET_ID, 1000 * UNIT)?;
77//!
78//! // Redeem USDC by burning the internal asset
79//! Psm::redeem(RuntimeOrigin::signed(user), USDC_ASSET_ID, 1000 * UNIT)?;
80//! ```
81
82#![cfg_attr(not(feature = "std"), no_std)]
83
84extern crate alloc;
85
86pub mod migrations;
87pub mod weights;
88
89#[cfg(feature = "runtime-benchmarks")]
90mod benchmarking;
91#[cfg(test)]
92mod mock;
93#[cfg(test)]
94mod tests;
95
96pub use pallet::*;
97pub use weights::WeightInfo;
98
99/// Helper trait for benchmark setup.
100///
101/// Provides a way to create an external asset with the correct metadata (decimals)
102/// for benchmarks, abstracting over the deposit requirements of the underlying
103/// asset pallet.
104#[cfg(feature = "runtime-benchmarks")]
105pub trait BenchmarkHelper<AssetId, AccountId> {
106	/// Get the asset ID for a given asset index.
107	fn get_asset_id(asset_index: u32) -> AssetId;
108	/// Create an asset with metadata matching the internal asset's decimals.
109	fn create_asset(asset_id: AssetId, owner: &AccountId, decimals: u8);
110}
111
112#[frame_support::pallet]
113pub mod pallet {
114	pub use frame_support::traits::tokens::stable::PsmInterface;
115
116	use alloc::collections::btree_map::BTreeMap;
117	use codec::DecodeWithMemTracking;
118	use frame_support::{
119		pallet_prelude::*,
120		traits::{
121			fungible::{
122				metadata::Inspect as FungibleMetadataInspect, Inspect as FungibleInspect,
123				Mutate as FungibleMutate,
124			},
125			fungibles::{
126				metadata::Inspect as FungiblesMetadataInspect, Inspect as FungiblesInspect,
127				Mutate as FungiblesMutate,
128			},
129			tokens::{Fortitude, Precision, Preservation},
130		},
131		DefaultNoBound, PalletId,
132	};
133	use frame_system::pallet_prelude::*;
134	use sp_runtime::{
135		traits::{AccountIdConversion, CheckedDiv, CheckedMul, Saturating, Zero},
136		Perbill, Permill,
137	};
138
139	use crate::WeightInfo;
140
141	/// Circuit breaker levels for emergency control.
142	#[derive(
143		Encode,
144		Decode,
145		DecodeWithMemTracking,
146		MaxEncodedLen,
147		TypeInfo,
148		Clone,
149		Copy,
150		PartialEq,
151		Eq,
152		Debug,
153		Default,
154	)]
155	pub enum CircuitBreakerLevel {
156		/// Normal operation, all swaps enabled.
157		#[default]
158		AllEnabled,
159		/// Minting disabled, redemptions still allowed.
160		MintingDisabled,
161		/// All swaps disabled.
162		AllDisabled,
163	}
164
165	impl CircuitBreakerLevel {
166		/// Whether this level allows minting (external → internal).
167		pub const fn allows_minting(&self) -> bool {
168			matches!(self, CircuitBreakerLevel::AllEnabled)
169		}
170
171		/// Whether this level allows redemption (internal → external).
172		pub const fn allows_redemption(&self) -> bool {
173			!matches!(self, CircuitBreakerLevel::AllDisabled)
174		}
175	}
176
177	/// Privilege level returned by ManagerOrigin.
178	///
179	/// Enables tiered authorization where different origins have different
180	/// capabilities for managing PSM parameters.
181	#[derive(
182		Encode,
183		Decode,
184		DecodeWithMemTracking,
185		MaxEncodedLen,
186		TypeInfo,
187		Clone,
188		Copy,
189		PartialEq,
190		Eq,
191		Debug,
192		Default,
193	)]
194	pub enum PsmManagerLevel {
195		/// Full administrative access via GeneralAdmin origin.
196		/// Can modify all parameters including fees, ceilings, and asset management.
197		#[default]
198		Full,
199		/// Emergency access via EmergencyAction origin.
200		/// Can modify circuit breaker status and asset ceiling weights.
201		Emergency,
202	}
203
204	impl PsmManagerLevel {
205		/// Whether this level allows modifying minting/redemption fees.
206		pub const fn can_set_fees(&self) -> bool {
207			matches!(self, PsmManagerLevel::Full)
208		}
209
210		/// Whether this level allows modifying the circuit breaker status.
211		/// Both Full and Emergency levels can set circuit breaker.
212		pub const fn can_set_circuit_breaker(&self) -> bool {
213			matches!(self, PsmManagerLevel::Full | PsmManagerLevel::Emergency)
214		}
215
216		/// Whether this level allows modifying the global PSM debt ratio.
217		/// Both Full and Emergency levels can set the max PSM debt.
218		pub const fn can_set_max_psm_debt(&self) -> bool {
219			matches!(self, PsmManagerLevel::Full | PsmManagerLevel::Emergency)
220		}
221
222		/// Whether this level allows modifying per-asset ceiling weights.
223		/// Both Full and Emergency levels can set asset ceilings.
224		pub const fn can_set_asset_ceiling(&self) -> bool {
225			matches!(self, PsmManagerLevel::Full | PsmManagerLevel::Emergency)
226		}
227
228		/// Whether this level allows adding or removing external assets.
229		pub const fn can_manage_assets(&self) -> bool {
230			matches!(self, PsmManagerLevel::Full)
231		}
232	}
233
234	pub(crate) type BalanceOf<T> = <<T as Config>::Fungibles as FungiblesInspect<
235		<T as frame_system::Config>::AccountId,
236	>>::Balance;
237
238	/// Suggested fee of 0.5% for minting and redemption.
239	pub(crate) struct DefaultFee;
240	impl Get<Permill> for DefaultFee {
241		fn get() -> Permill {
242			Permill::from_parts(5_000)
243		}
244	}
245
246	/// Maximum absolute difference between an external asset's decimals and the internal
247	/// asset's decimals. Bounds the scaling factor `10^diff` well below `u128::MAX`
248	/// so realistic balances cannot overflow during conversion.
249	pub const MAX_DECIMALS_DIFF: u32 = 24;
250
251	#[pallet::config]
252	pub trait Config: frame_system::Config {
253		/// Fungibles implementation for both internal and external stablecoins.
254		type Fungibles: FungiblesMutate<Self::AccountId, AssetId = Self::AssetId>
255			+ FungiblesMetadataInspect<Self::AccountId>;
256
257		/// Asset identifier type.
258		type AssetId: Parameter + Member + Clone + MaybeSerializeDeserialize + MaxEncodedLen + Ord;
259
260		/// Maximum allowed internal issuance across the entire system.
261		type MaximumIssuance: Get<BalanceOf<Self>>;
262
263		/// Origin allowed to update PSM parameters.
264		///
265		/// Returns `PsmManagerLevel` to distinguish privilege levels:
266		/// - `Full` (via GeneralAdmin): Can modify all parameters
267		/// - `Emergency` (via EmergencyAction): Can modify circuit breaker status, per-asset
268		///   ceiling weights, and the global max PSM debt ratio.
269		type ManagerOrigin: EnsureOrigin<Self::RuntimeOrigin, Success = PsmManagerLevel>;
270
271		/// A type representing the weights required by the dispatchables of this pallet.
272		type WeightInfo: WeightInfo;
273
274		/// The internal asset as a single-asset `fungible` type.
275		///
276		/// Typically `ItemOf<Asset, InternalAssetId, AccountId>`.
277		/// Must use the same `Balance` type as `Asset`.
278		type InternalAsset: FungibleMutate<Self::AccountId, Balance = BalanceOf<Self>>
279			+ FungibleMetadataInspect<Self::AccountId>;
280
281		/// Account that receives internal fees from minting and redemption.
282		///
283		/// Must exist before any swap; initialized at genesis and migration
284		/// via `Pallet::ensure_account_exists`.
285		type FeeDestination: Get<Self::AccountId>;
286
287		/// PalletId for deriving the PSM account.
288		#[pallet::constant]
289		type PalletId: Get<PalletId>;
290
291		/// Minimum swap amount.
292		#[pallet::constant]
293		type MinSwapAmount: Get<BalanceOf<Self>>;
294
295		/// Maximum number of approved external assets.
296		#[pallet::constant]
297		type MaxExternalAssets: Get<u32>;
298
299		/// Helper for benchmarks to create an external asset with correct metadata.
300		#[cfg(feature = "runtime-benchmarks")]
301		type BenchmarkHelper: crate::BenchmarkHelper<Self::AssetId, Self::AccountId>;
302	}
303
304	/// The in-code storage version.
305	const STORAGE_VERSION: StorageVersion = StorageVersion::new(2);
306
307	#[pallet::pallet]
308	#[pallet::storage_version(STORAGE_VERSION)]
309	pub struct Pallet<T>(_);
310
311	#[pallet::hooks]
312	impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
313		fn integrity_test() {
314			assert!(!T::MinSwapAmount::get().is_zero(), "MinSwapAmount must be greater than zero");
315		}
316
317		#[cfg(feature = "try-runtime")]
318		fn try_state(_n: BlockNumberFor<T>) -> Result<(), sp_runtime::TryRuntimeError> {
319			Self::do_try_state()
320		}
321	}
322
323	/// internal minted through PSM per external asset, denominated in internal units.
324	#[pallet::storage]
325	pub type PsmDebt<T: Config> =
326		StorageMap<_, Blake2_128Concat, T::AssetId, BalanceOf<T>, ValueQuery>;
327
328	/// Fee for external → internal swaps (minting) per asset. Suggested value is 0.5%.
329	#[pallet::storage]
330	pub(crate) type MintingFee<T: Config> =
331		StorageMap<_, Blake2_128Concat, T::AssetId, Permill, ValueQuery, DefaultFee>;
332
333	/// Fee for internal → external swaps (redemption) per asset. Suggested value is 0.5%.
334	#[pallet::storage]
335	pub(crate) type RedemptionFee<T: Config> =
336		StorageMap<_, Blake2_128Concat, T::AssetId, Permill, ValueQuery, DefaultFee>;
337
338	/// Max PSM debt as percentage of MaximumIssuance (global ceiling).
339	#[pallet::storage]
340	pub(crate) type MaxPsmDebtOfTotal<T: Config> = StorageValue<_, Permill, ValueQuery>;
341
342	/// Per-asset ceiling weight. Weights are normalized against the sum of all weights.
343	/// Zero means minting is disabled for this asset.
344	#[pallet::storage]
345	pub(crate) type AssetCeilingWeight<T: Config> =
346		StorageMap<_, Blake2_128Concat, T::AssetId, Permill, ValueQuery>;
347
348	/// Set of approved external stablecoin asset IDs with their operational status.
349	/// Key existence indicates the asset is approved; the value is the circuit breaker level.
350	#[pallet::storage]
351	pub(crate) type ExternalAssets<T: Config> =
352		CountedStorageMap<_, Blake2_128Concat, T::AssetId, CircuitBreakerLevel, OptionQuery>;
353
354	/// Snapshot of each approved external asset's decimals at registration.
355	/// Used to detect runtime drift from the registered precision.
356	#[pallet::storage]
357	pub(crate) type ExternalDecimals<T: Config> =
358		StorageMap<_, Blake2_128Concat, T::AssetId, u8, OptionQuery>;
359
360	/// Snapshot of the internal asset's decimals taken at genesis.
361	/// Set once during genesis build; present for the lifetime of the pallet.
362	#[pallet::storage]
363	pub(crate) type InternalDecimals<T: Config> = StorageValue<_, u8, OptionQuery>;
364
365	/// Genesis configuration for the PSM pallet.
366	#[pallet::genesis_config]
367	#[derive(DefaultNoBound)]
368	pub struct GenesisConfig<T: Config> {
369		/// Max PSM debt as percentage of total maximum issuance.
370		pub max_psm_debt_of_total: Permill,
371		/// Per-asset configuration: asset_id -> (minting_fee, redemption_fee,
372		/// ceiling_weight). Keys also define the set of approved external assets.
373		pub asset_configs: BTreeMap<T::AssetId, (Permill, Permill, Permill)>,
374		#[serde(skip)]
375		pub _marker: core::marker::PhantomData<T>,
376	}
377
378	#[pallet::genesis_build]
379	impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
380		fn build(&self) {
381			assert!(
382				self.asset_configs.len() as u32 <= T::MaxExternalAssets::get(),
383				"PSM genesis: asset_configs ({}) exceeds MaxExternalAssets ({})",
384				self.asset_configs.len(),
385				T::MaxExternalAssets::get(),
386			);
387			MaxPsmDebtOfTotal::<T>::put(self.max_psm_debt_of_total);
388			let internal_decimals = T::InternalAsset::decimals();
389			InternalDecimals::<T>::put(internal_decimals);
390			for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in &self.asset_configs {
391				let asset_decimals = T::Fungibles::decimals(asset_id.clone());
392				let diff = asset_decimals.abs_diff(internal_decimals) as u32;
393				assert!(
394					diff <= MAX_DECIMALS_DIFF,
395					"PSM genesis: asset {:?} decimals diff ({}) exceeds MAX_DECIMALS_DIFF ({})",
396					asset_id,
397					diff,
398					MAX_DECIMALS_DIFF,
399				);
400				ExternalAssets::<T>::insert(asset_id, CircuitBreakerLevel::AllEnabled);
401				ExternalDecimals::<T>::insert(asset_id, asset_decimals);
402				MintingFee::<T>::insert(asset_id, minting_fee);
403				RedemptionFee::<T>::insert(asset_id, redemption_fee);
404				AssetCeilingWeight::<T>::insert(asset_id, ceiling_weight);
405			}
406			Pallet::<T>::ensure_account_exists(&Pallet::<T>::account_id());
407			Pallet::<T>::ensure_account_exists(&T::FeeDestination::get());
408		}
409	}
410
411	#[pallet::event]
412	#[pallet::generate_deposit(pub(super) fn deposit_event)]
413	pub enum Event<T: Config> {
414		/// User swapped external stablecoin for internal.
415		Minted {
416			who: T::AccountId,
417			asset_id: T::AssetId,
418			external_amount: BalanceOf<T>,
419			received: BalanceOf<T>,
420			fee: BalanceOf<T>,
421		},
422		/// User swapped internal for external stablecoin.
423		Redeemed {
424			who: T::AccountId,
425			asset_id: T::AssetId,
426			paid: BalanceOf<T>,
427			external_received: BalanceOf<T>,
428			fee: BalanceOf<T>,
429		},
430		/// Minting fee updated for an asset by governance.
431		MintingFeeUpdated { asset_id: T::AssetId, old_value: Permill, new_value: Permill },
432		/// Redemption fee updated for an asset by governance.
433		RedemptionFeeUpdated { asset_id: T::AssetId, old_value: Permill, new_value: Permill },
434		/// Max PSM debt ratio updated by governance.
435		MaxPsmDebtOfTotalUpdated { old_value: Permill, new_value: Permill },
436		/// Per-asset debt ceiling weight updated by governance.
437		AssetCeilingWeightUpdated { asset_id: T::AssetId, old_value: Permill, new_value: Permill },
438		/// Per-asset circuit breaker status updated.
439		AssetStatusUpdated { asset_id: T::AssetId, status: CircuitBreakerLevel },
440		/// An external asset was added to the approved list.
441		ExternalAssetAdded { asset_id: T::AssetId },
442		/// An external asset was removed from the approved list.
443		ExternalAssetRemoved { asset_id: T::AssetId },
444	}
445
446	#[pallet::error]
447	pub enum Error<T> {
448		/// PSM doesn't have enough external stablecoin for redemption.
449		InsufficientReserve,
450		/// Swap would exceed PSM debt ceiling.
451		ExceedsMaxPsmDebt,
452		/// Swap amount below minimum threshold.
453		BelowMinimumSwap,
454		/// Minting operations are disabled (circuit breaker level >= 1).
455		MintingStopped,
456		/// All swap operations are disabled (circuit breaker level = 2).
457		AllSwapsStopped,
458		/// Asset is not an approved external stablecoin.
459		UnsupportedAsset,
460		/// Mint would exceed system-wide maximum internal issuance.
461		ExceedsMaxIssuance,
462		/// Asset is already in the approved list.
463		AssetAlreadyApproved,
464		/// Asset does not exist.
465		AssetDoesNotExist,
466		/// Cannot remove asset: not in approved list.
467		AssetNotApproved,
468		/// Cannot remove asset: has non-zero PSM debt.
469		AssetHasDebt,
470		/// Operation requires Full manager level (GeneralAdmin), not Emergency.
471		InsufficientPrivilege,
472		/// Maximum number of approved external assets reached.
473		TooManyAssets,
474		/// Live decimals diverged from the snapshot taken at registration or genesis.
475		DecimalsMismatch,
476		/// The asset's decimal precision is outside the supported range.
477		DecimalsRangeExceeded,
478		/// Decimal scaling produced an arithmetic overflow.
479		ConversionOverflow,
480		/// Conversion to the counter-asset rounds to zero; swap would transfer nothing.
481		AmountTooSmallAfterConversion,
482		/// An unexpected invariant violation occurred. This should be reported.
483		Unexpected,
484	}
485
486	#[pallet::call]
487	impl<T: Config> Pallet<T> {
488		/// Swap external stablecoin for internal.
489		///
490		/// ## Dispatch Origin
491		///
492		/// Must be `Signed` by the user performing the swap.
493		///
494		/// ## Details
495		///
496		/// Transfers `external_amount` of the specified external stablecoin from the caller
497		/// to the PSM account, then mints internal to the caller minus the minting fee.
498		/// The fee is calculated using ceiling rounding (`mul_ceil`), ensuring the
499		/// protocol never undercharges. The fee is transferred to [`Config::FeeDestination`].
500		///
501		/// ## Parameters
502		///
503		/// - `asset_id`: The external stablecoin to deposit (must be in `ExternalAssets`)
504		/// - `external_amount`: Amount of external stablecoin to deposit
505		///
506		/// ## Errors
507		///
508		/// - [`Error::UnsupportedAsset`]: If `asset_id` is not an approved external stablecoin
509		/// - [`Error::MintingStopped`]: If circuit breaker is at `MintingDisabled` or higher
510		/// - [`Error::BelowMinimumSwap`]: If `external_amount` is below [`Config::MinSwapAmount`]
511		/// - [`Error::ExceedsMaxIssuance`]: If minting would exceed system-wide internal issuance
512		///   cap
513		/// - [`Error::ExceedsMaxPsmDebt`]: If minting would exceed PSM debt ceiling (aggregate or
514		///   per-asset)
515		/// - [`Error::DecimalsMismatch`]: If the asset's decimals do not match the internal asset's
516		///   decimals
517		/// - [`Error::AmountTooSmallAfterConversion`]: if the conversion to the counter-asset
518		///   rounds to zero; swap would transfer nothing
519		///
520		/// ## Events
521		///
522		/// - [`Event::Minted`]: Emitted on successful mint
523		#[pallet::call_index(0)]
524		#[pallet::weight(T::WeightInfo::mint(T::MaxExternalAssets::get()))]
525		pub fn mint(
526			origin: OriginFor<T>,
527			asset_id: T::AssetId,
528			external_amount: BalanceOf<T>,
529		) -> DispatchResult {
530			let who = ensure_signed(origin)?;
531
532			// Check asset is approved and minting is enabled
533			let asset_status =
534				ExternalAssets::<T>::get(&asset_id).ok_or(Error::<T>::UnsupportedAsset)?;
535			ensure!(asset_status.allows_minting(), Error::<T>::MintingStopped);
536
537			// Guard against runtime drift in live decimals.
538			let (ext_decimals, internal_decimals) = Self::ensure_decimals_match(asset_id.clone())?;
539
540			// Normalize to internal units for all internal accounting.
541			let internal_equivalent =
542				Self::external_to_internal(external_amount, ext_decimals, internal_decimals)?;
543			ensure!(!internal_equivalent.is_zero(), Error::<T>::AmountTooSmallAfterConversion);
544			ensure!(internal_equivalent >= T::MinSwapAmount::get(), Error::<T>::BelowMinimumSwap);
545
546			// Round-trip back to external units. Truncation dust stays in the user's wallet — only
547			// `effective_external` enters the reserve.
548			let effective_external =
549				Self::internal_to_external(internal_equivalent, ext_decimals, internal_decimals)?;
550
551			let fee = MintingFee::<T>::get(&asset_id).mul_ceil(internal_equivalent);
552			let internal_to_user = internal_equivalent.saturating_sub(fee);
553
554			// Total new issuance = internal_to_user + fee = internal_equivalent.
555			let current_total_issuance = T::InternalAsset::total_issuance();
556			let max_issuance = T::MaximumIssuance::get();
557			ensure!(
558				current_total_issuance.saturating_add(internal_equivalent) <= max_issuance,
559				Error::<T>::ExceedsMaxIssuance
560			);
561
562			// Check aggregate PSM ceiling across all assets (internal units).
563			let current_total_psm_debt = Self::total_psm_debt();
564			let max_psm = Self::max_psm_debt();
565			ensure!(
566				current_total_psm_debt.saturating_add(internal_equivalent) <= max_psm,
567				Error::<T>::ExceedsMaxPsmDebt
568			);
569
570			// Check per-asset ceiling (redistributes from disabled assets).
571			let current_debt = PsmDebt::<T>::get(&asset_id);
572			let max_debt = Self::max_asset_debt(asset_id.clone());
573			let new_debt = current_debt.saturating_add(internal_equivalent);
574			ensure!(new_debt <= max_debt, Error::<T>::ExceedsMaxPsmDebt);
575
576			let psm_account = Self::account_id();
577
578			T::Fungibles::transfer(
579				asset_id.clone(),
580				&who,
581				&psm_account,
582				effective_external,
583				Preservation::Expendable,
584			)?;
585			T::InternalAsset::mint_into(&who, internal_to_user)?;
586			if !fee.is_zero() {
587				T::InternalAsset::mint_into(&T::FeeDestination::get(), fee)?;
588			}
589
590			PsmDebt::<T>::insert(&asset_id, new_debt);
591
592			Self::deposit_event(Event::Minted {
593				who,
594				asset_id,
595				external_amount: effective_external,
596				received: internal_to_user,
597				fee,
598			});
599
600			Ok(())
601		}
602
603		/// Swap internal for external stablecoin.
604		///
605		/// ## Dispatch Origin
606		///
607		/// Must be `Signed` by the user performing the swap.
608		///
609		/// ## Details
610		///
611		/// Burns `amount` internal from the caller minus fee (transferred to
612		/// [`Config::FeeDestination`]), then transfers the resulting amount in external
613		/// stablecoin from PSM to the caller. The fee is calculated using ceiling rounding
614		/// (`mul_ceil`), ensuring the protocol never undercharges.
615		///
616		/// ## Parameters
617		///
618		/// - `asset_id`: The external stablecoin to receive (must be in `ExternalAssets`)
619		/// - `amount`: Amount of internal to redeem
620		///
621		/// ## Errors
622		///
623		/// - [`Error::UnsupportedAsset`]: If `asset_id` is not an approved external stablecoin
624		/// - [`Error::AllSwapsStopped`]: If circuit breaker is at `AllDisabled`
625		/// - [`Error::BelowMinimumSwap`]: If `amount` is below [`Config::MinSwapAmount`]
626		/// - [`Error::InsufficientReserve`]: If PSM has insufficient external stablecoin
627		/// - [`Error::DecimalsMismatch`]: If the asset's decimals do not match the internal asset's
628		///   decimals
629		/// - [`Error::AmountTooSmallAfterConversion`]: if the conversion to the counter-asset
630		///   rounds to zero; swap would transfer nothing
631		///
632		/// ## Events
633		///
634		/// - [`Event::Redeemed`]: Emitted on successful redemption
635		#[pallet::call_index(1)]
636		#[pallet::weight(T::WeightInfo::redeem())]
637		pub fn redeem(
638			origin: OriginFor<T>,
639			asset_id: T::AssetId,
640			amount: BalanceOf<T>,
641		) -> DispatchResult {
642			let who = ensure_signed(origin)?;
643
644			// Check asset is approved and redemption is enabled
645			let asset_status =
646				ExternalAssets::<T>::get(&asset_id).ok_or(Error::<T>::UnsupportedAsset)?;
647			ensure!(asset_status.allows_redemption(), Error::<T>::AllSwapsStopped);
648
649			// Guard against runtime drift in live decimals.
650			let (ext_decimals, internal_decimals) = Self::ensure_decimals_match(asset_id.clone())?;
651
652			ensure!(amount >= T::MinSwapAmount::get(), Error::<T>::BelowMinimumSwap);
653
654			let fee = RedemptionFee::<T>::get(&asset_id).mul_ceil(amount);
655			let internal_net = amount.saturating_sub(fee);
656
657			// Convert internal-net to external units (floor) and round-trip back. The round-tripped
658			// amount (`effective_internal_net`) is what is actually burned and what the tracked
659			// debt decreases by. Any truncation dust stays in the caller's internal balance —
660			// symmetric with `mint`, which only takes the round-tripped share of the external
661			// amount from the caller.
662			let external_out =
663				Self::internal_to_external(internal_net, ext_decimals, internal_decimals)?;
664			// Reject only when truncation wipes a non-zero net amount; a legitimately zero net
665			// (e.g., 100% fee) continues without an external transfer.
666			ensure!(
667				internal_net.is_zero() || !external_out.is_zero(),
668				Error::<T>::AmountTooSmallAfterConversion
669			);
670			let effective_internal_net =
671				Self::external_to_internal(external_out, ext_decimals, internal_decimals)?;
672
673			// Check debt first - redemptions are limited by tracked debt, not raw reserve.
674			// This prevents redemption of "donated" reserves that aren't backed by debt.
675			let current_debt = PsmDebt::<T>::get(&asset_id);
676			ensure!(current_debt >= effective_internal_net, Error::<T>::InsufficientReserve);
677
678			let reserve = Self::get_reserve(asset_id.clone());
679			if reserve < external_out {
680				defensive!("PSM reserve is less than expected output amount");
681				return Err(Error::<T>::Unexpected.into());
682			}
683
684			// Transfer the nominal fee to the destination, then burn the redeemed portion.
685			// Round-trip dust is not charged.
686			if !fee.is_zero() {
687				T::InternalAsset::transfer(
688					&who,
689					&T::FeeDestination::get(),
690					fee,
691					Preservation::Expendable,
692				)?;
693			}
694
695			if !effective_internal_net.is_zero() {
696				T::InternalAsset::burn_from(
697					&who,
698					effective_internal_net,
699					Preservation::Expendable,
700					Precision::Exact,
701					Fortitude::Polite,
702				)?;
703			}
704
705			let psm_account = Self::account_id();
706			if !external_out.is_zero() {
707				T::Fungibles::transfer(
708					asset_id.clone(),
709					&psm_account,
710					&who,
711					external_out,
712					Preservation::Expendable,
713				)?;
714			}
715
716			PsmDebt::<T>::mutate(&asset_id, |debt| {
717				*debt = debt.saturating_sub(effective_internal_net);
718			});
719
720			Self::deposit_event(Event::Redeemed {
721				who,
722				asset_id,
723				paid: effective_internal_net.saturating_add(fee),
724				external_received: external_out,
725				fee,
726			});
727
728			Ok(())
729		}
730
731		/// Set the minting fee for a specific asset (external → internal).
732		///
733		/// ## Dispatch Origin
734		///
735		/// Must be [`Config::ManagerOrigin`].
736		///
737		/// ## Parameters
738		///
739		/// - `asset_id`: The external stablecoin to configure
740		/// - `fee`: The new minting fee as a Permill
741		///
742		/// ## Events
743		///
744		/// - [`Event::MintingFeeUpdated`]: Emitted with old and new values
745		#[pallet::call_index(2)]
746		#[pallet::weight(T::WeightInfo::set_minting_fee())]
747		pub fn set_minting_fee(
748			origin: OriginFor<T>,
749			asset_id: T::AssetId,
750			fee: Permill,
751		) -> DispatchResult {
752			let level = T::ManagerOrigin::ensure_origin(origin)?;
753			ensure!(level.can_set_fees(), Error::<T>::InsufficientPrivilege);
754			ensure!(ExternalAssets::<T>::contains_key(&asset_id), Error::<T>::AssetNotApproved);
755			let old_value = MintingFee::<T>::get(&asset_id);
756			MintingFee::<T>::insert(&asset_id, fee);
757			Self::deposit_event(Event::MintingFeeUpdated { asset_id, old_value, new_value: fee });
758			Ok(())
759		}
760
761		/// Set the redemption fee for a specific asset (internal → external).
762		///
763		/// ## Dispatch Origin
764		///
765		/// Must be [`Config::ManagerOrigin`].
766		///
767		/// ## Parameters
768		///
769		/// - `asset_id`: The external stablecoin to configure
770		/// - `fee`: The new redemption fee as a Permill
771		///
772		/// ## Events
773		///
774		/// - [`Event::RedemptionFeeUpdated`]: Emitted with old and new values
775		#[pallet::call_index(3)]
776		#[pallet::weight(T::WeightInfo::set_redemption_fee())]
777		pub fn set_redemption_fee(
778			origin: OriginFor<T>,
779			asset_id: T::AssetId,
780			fee: Permill,
781		) -> DispatchResult {
782			let level = T::ManagerOrigin::ensure_origin(origin)?;
783			ensure!(level.can_set_fees(), Error::<T>::InsufficientPrivilege);
784			ensure!(ExternalAssets::<T>::contains_key(&asset_id), Error::<T>::AssetNotApproved);
785			let old_value = RedemptionFee::<T>::get(&asset_id);
786			RedemptionFee::<T>::insert(&asset_id, fee);
787			Self::deposit_event(Event::RedemptionFeeUpdated {
788				asset_id,
789				old_value,
790				new_value: fee,
791			});
792			Ok(())
793		}
794
795		/// Set the maximum PSM debt as a percentage of total maximum issuance.
796		///
797		/// ## Dispatch Origin
798		///
799		/// Must be [`Config::ManagerOrigin`].
800		///
801		/// ## Events
802		///
803		/// - [`Event::MaxPsmDebtOfTotalUpdated`]: Emitted with old and new values
804		#[pallet::call_index(4)]
805		#[pallet::weight(T::WeightInfo::set_max_psm_debt())]
806		pub fn set_max_psm_debt(origin: OriginFor<T>, ratio: Permill) -> DispatchResult {
807			let level = T::ManagerOrigin::ensure_origin(origin)?;
808			ensure!(level.can_set_max_psm_debt(), Error::<T>::InsufficientPrivilege);
809			let old_value = MaxPsmDebtOfTotal::<T>::get();
810			MaxPsmDebtOfTotal::<T>::put(ratio);
811			Self::deposit_event(Event::MaxPsmDebtOfTotalUpdated { old_value, new_value: ratio });
812			Ok(())
813		}
814
815		/// Set the circuit breaker status for a specific external asset.
816		///
817		/// ## Dispatch Origin
818		///
819		/// Must be [`Config::ManagerOrigin`].
820		///
821		/// ## Details
822		///
823		/// Controls which operations are allowed for this asset:
824		/// - [`CircuitBreakerLevel::AllEnabled`]: All swaps allowed
825		/// - [`CircuitBreakerLevel::MintingDisabled`]: Only redemptions allowed (useful for
826		///   draining debt)
827		/// - [`CircuitBreakerLevel::AllDisabled`]: No swaps allowed
828		///
829		/// ## Parameters
830		///
831		/// - `asset_id`: The external stablecoin to configure
832		/// - `status`: The new circuit breaker level for this asset
833		///
834		/// ## Errors
835		///
836		/// - [`Error::AssetNotApproved`]: If the asset is not in the approved list
837		///
838		/// ## Events
839		///
840		/// - [`Event::AssetStatusUpdated`]: Emitted with the asset ID and new status
841		#[pallet::call_index(5)]
842		#[pallet::weight(T::WeightInfo::set_asset_status())]
843		pub fn set_asset_status(
844			origin: OriginFor<T>,
845			asset_id: T::AssetId,
846			status: CircuitBreakerLevel,
847		) -> DispatchResult {
848			T::ManagerOrigin::ensure_origin(origin)?;
849			ensure!(ExternalAssets::<T>::contains_key(&asset_id), Error::<T>::AssetNotApproved);
850			ExternalAssets::<T>::insert(&asset_id, status);
851			Self::deposit_event(Event::AssetStatusUpdated { asset_id, status });
852			Ok(())
853		}
854
855		/// Set the per-asset debt ceiling weight.
856		///
857		/// ## Dispatch Origin
858		///
859		/// Must be [`Config::ManagerOrigin`].
860		///
861		/// ## Details
862		///
863		/// Ratios act as weights normalized against the sum of all asset weights:
864		/// `max_asset_debt = (ratio / sum_of_all_ratios) * MaxPsmDebtOfTotal * MaximumIssuance`
865		///
866		/// With a single asset, the weight always normalizes to 100% of the PSM
867		/// ceiling.
868		///
869		/// ## Parameters
870		///
871		/// - `asset_id`: The external stablecoin to configure
872		/// - `ratio`: Weight for this asset's share of the total PSM ceiling
873		///
874		/// ## Events
875		///
876		/// - [`Event::AssetCeilingWeightUpdated`]: Emitted with old and new values
877		#[pallet::call_index(6)]
878		#[pallet::weight(T::WeightInfo::set_asset_ceiling_weight())]
879		pub fn set_asset_ceiling_weight(
880			origin: OriginFor<T>,
881			asset_id: T::AssetId,
882			weight: Permill,
883		) -> DispatchResult {
884			let level = T::ManagerOrigin::ensure_origin(origin)?;
885			ensure!(level.can_set_asset_ceiling(), Error::<T>::InsufficientPrivilege);
886			ensure!(ExternalAssets::<T>::contains_key(&asset_id), Error::<T>::AssetNotApproved);
887			let old_value = AssetCeilingWeight::<T>::get(&asset_id);
888			AssetCeilingWeight::<T>::insert(&asset_id, weight);
889			Self::deposit_event(Event::AssetCeilingWeightUpdated {
890				asset_id,
891				old_value,
892				new_value: weight,
893			});
894			Ok(())
895		}
896
897		/// Add an external stablecoin to the approved list.
898		///
899		/// ## Dispatch Origin
900		///
901		/// Must be [`Config::ManagerOrigin`].
902		///
903		/// ## Parameters
904		///
905		/// - `asset_id`: The external stablecoin to add
906		///
907		/// ## Errors
908		///
909		/// - [`Error::AssetAlreadyApproved`]: If the asset is already in the approved list
910		///
911		/// ## Events
912		///
913		/// - [`Event::ExternalAssetAdded`]: Emitted on successful addition
914		#[pallet::call_index(7)]
915		#[pallet::weight(T::WeightInfo::add_external_asset())]
916		pub fn add_external_asset(origin: OriginFor<T>, asset_id: T::AssetId) -> DispatchResult {
917			let level = T::ManagerOrigin::ensure_origin(origin)?;
918			ensure!(level.can_manage_assets(), Error::<T>::InsufficientPrivilege);
919			ensure!(
920				!ExternalAssets::<T>::contains_key(&asset_id),
921				Error::<T>::AssetAlreadyApproved
922			);
923			ensure!(T::Fungibles::asset_exists(asset_id.clone()), Error::<T>::AssetDoesNotExist);
924			let count = ExternalAssets::<T>::count();
925			ensure!(count < T::MaxExternalAssets::get(), Error::<T>::TooManyAssets);
926
927			let asset_decimals = T::Fungibles::decimals(asset_id.clone());
928			let internal_decimals = InternalDecimals::<T>::get().ok_or(Error::<T>::Unexpected)?;
929			ensure!(
930				T::InternalAsset::decimals() == internal_decimals,
931				Error::<T>::DecimalsMismatch
932			);
933			ensure!(
934				(asset_decimals.abs_diff(internal_decimals) as u32) <= MAX_DECIMALS_DIFF,
935				Error::<T>::DecimalsRangeExceeded
936			);
937
938			ExternalAssets::<T>::insert(&asset_id, CircuitBreakerLevel::AllEnabled);
939			ExternalDecimals::<T>::insert(&asset_id, asset_decimals);
940			Self::deposit_event(Event::ExternalAssetAdded { asset_id });
941			Ok(())
942		}
943
944		/// Remove an external stablecoin from the approved list.
945		///
946		/// ## Dispatch Origin
947		///
948		/// Must be [`Config::ManagerOrigin`].
949		///
950		/// ## Details
951		///
952		/// The asset cannot be removed if it has non-zero PSM debt outstanding.
953		/// This prevents orphaned debt that cannot be redeemed.
954		///
955		/// Upon removal, the associated configuration is also cleaned up:
956		/// - `MintingFee` for this asset
957		/// - `RedemptionFee` for this asset
958		/// - `AssetCeilingWeight` for this asset
959		///
960		/// ## Parameters
961		///
962		/// - `asset_id`: The external stablecoin to remove
963		///
964		/// ## Errors
965		///
966		/// - [`Error::AssetNotApproved`]: If the asset is not in the approved list
967		/// - [`Error::AssetHasDebt`]: If the asset has non-zero PSM debt
968		///
969		/// ## Events
970		///
971		/// - [`Event::ExternalAssetRemoved`]: Emitted on successful removal
972		#[pallet::call_index(8)]
973		#[pallet::weight(T::WeightInfo::remove_external_asset())]
974		pub fn remove_external_asset(origin: OriginFor<T>, asset_id: T::AssetId) -> DispatchResult {
975			let level = T::ManagerOrigin::ensure_origin(origin)?;
976			ensure!(level.can_manage_assets(), Error::<T>::InsufficientPrivilege);
977			ensure!(ExternalAssets::<T>::contains_key(&asset_id), Error::<T>::AssetNotApproved);
978			ensure!(PsmDebt::<T>::get(&asset_id).is_zero(), Error::<T>::AssetHasDebt);
979			ExternalAssets::<T>::remove(&asset_id);
980
981			// Clean up associated configuration
982			MintingFee::<T>::remove(&asset_id);
983			RedemptionFee::<T>::remove(&asset_id);
984			AssetCeilingWeight::<T>::remove(&asset_id);
985			ExternalDecimals::<T>::remove(&asset_id);
986			PsmDebt::<T>::remove(&asset_id);
987			Self::deposit_event(Event::ExternalAssetRemoved { asset_id });
988			Ok(())
989		}
990	}
991
992	impl<T: Config> Pallet<T> {
993		/// Get the PSM's derived account.
994		pub(crate) fn account_id() -> T::AccountId {
995			T::PalletId::get().into_account_truncating()
996		}
997
998		/// Calculate max PSM debt based on system ceiling.
999		pub(crate) fn max_psm_debt() -> BalanceOf<T> {
1000			let max_issuance = T::MaximumIssuance::get();
1001			MaxPsmDebtOfTotal::<T>::get().mul_floor(max_issuance)
1002		}
1003
1004		/// Calculate max debt for a specific asset.
1005		///
1006		/// Assumes the caller has verified the asset is approved and `AllEnabled`.
1007		///
1008		/// Returns zero if the asset has no configured weight or the weight is zero.
1009		///
1010		/// Weights are normalized against the sum of all asset weights to fill the
1011		/// PSM ceiling.
1012		pub(crate) fn max_asset_debt(asset_id: T::AssetId) -> BalanceOf<T> {
1013			let asset_weight = AssetCeilingWeight::<T>::get(asset_id);
1014
1015			if asset_weight.is_zero() {
1016				return BalanceOf::<T>::zero();
1017			}
1018
1019			let total_weight_sum: u32 = AssetCeilingWeight::<T>::iter_values()
1020				.map(|w| w.deconstruct())
1021				.fold(0u32, |acc, x| acc.saturating_add(x));
1022
1023			if total_weight_sum == 0 {
1024				return BalanceOf::<T>::zero();
1025			}
1026
1027			let total_psm_ceiling = Self::max_psm_debt();
1028			Perbill::from_rational(asset_weight.deconstruct(), total_weight_sum)
1029				.mul_floor(total_psm_ceiling)
1030		}
1031
1032		/// Calculate total PSM debt across all approved assets.
1033		pub(crate) fn total_psm_debt() -> BalanceOf<T> {
1034			PsmDebt::<T>::iter_values()
1035				.fold(BalanceOf::<T>::zero(), |acc, debt| acc.saturating_add(debt))
1036		}
1037
1038		/// Check if an asset is approved for PSM swaps.
1039		#[cfg(test)]
1040		pub(crate) fn is_approved_asset(asset_id: &T::AssetId) -> bool {
1041			ExternalAssets::<T>::contains_key(asset_id)
1042		}
1043
1044		/// Get the reserve (balance) of an external asset held by PSM.
1045		pub(crate) fn get_reserve(asset_id: T::AssetId) -> BalanceOf<T> {
1046			T::Fungibles::balance(asset_id, &Self::account_id())
1047		}
1048
1049		/// Convert an amount denominated in external-asset units into internal units.
1050		///
1051		/// Scales by `10^(ext_decimals - internal_decimals)` — multiplies up when internal has more
1052		/// decimals, floor-divides when it has fewer. Returns [`Error::ConversionOverflow`] if
1053		/// the scaling factor or the product does not fit in the balance type.
1054		pub(crate) fn external_to_internal(
1055			amount: BalanceOf<T>,
1056			ext_decimals: u8,
1057			internal_decimals: u8,
1058		) -> Result<BalanceOf<T>, Error<T>> {
1059			use core::cmp::Ordering::*;
1060			match ext_decimals.cmp(&internal_decimals) {
1061				Equal => Ok(amount),
1062				Less => {
1063					let diff = (internal_decimals - ext_decimals) as u32;
1064					let factor = Self::pow10(diff)?;
1065					amount.checked_mul(&factor).ok_or(Error::<T>::ConversionOverflow)
1066				},
1067				Greater => {
1068					let diff = (ext_decimals - internal_decimals) as u32;
1069					let factor = Self::pow10(diff)?;
1070					Ok(amount.checked_div(&factor).unwrap_or_else(BalanceOf::<T>::zero))
1071				},
1072			}
1073		}
1074
1075		/// Convert an amount denominated in internal units into external-asset units.
1076		///
1077		/// Inverse of [`Self::external_to_internal`]. Floor-divides when internal has more
1078		/// decimals, multiplies up when it has fewer.
1079		pub(crate) fn internal_to_external(
1080			amount: BalanceOf<T>,
1081			ext_decimals: u8,
1082			internal_decimals: u8,
1083		) -> Result<BalanceOf<T>, Error<T>> {
1084			use core::cmp::Ordering::*;
1085			match ext_decimals.cmp(&internal_decimals) {
1086				Equal => Ok(amount),
1087				Less => {
1088					let diff = (internal_decimals - ext_decimals) as u32;
1089					let factor = Self::pow10(diff)?;
1090					Ok(amount.checked_div(&factor).unwrap_or_else(BalanceOf::<T>::zero))
1091				},
1092				Greater => {
1093					let diff = (ext_decimals - internal_decimals) as u32;
1094					let factor = Self::pow10(diff)?;
1095					amount.checked_mul(&factor).ok_or(Error::<T>::ConversionOverflow)
1096				},
1097			}
1098		}
1099
1100		/// Compute `10^exp` as a [`BalanceOf`]. Returns [`Error::ConversionOverflow`] if the result
1101		/// does not fit in `u128` or in `BalanceOf<T>`.
1102		fn pow10(exp: u32) -> Result<BalanceOf<T>, Error<T>> {
1103			let factor_u128 = 10u128.checked_pow(exp).ok_or(Error::<T>::ConversionOverflow)?;
1104			factor_u128.try_into().map_err(|_| Error::<T>::ConversionOverflow)
1105		}
1106
1107		/// Verify the live decimals for an external asset still match the snapshot taken at
1108		/// registration, and that the internal asset's live decimals still match the genesis
1109		/// snapshot.
1110		pub(crate) fn ensure_decimals_match(
1111			asset_id: T::AssetId,
1112		) -> Result<(u8, u8), DispatchError> {
1113			let ext_decimals =
1114				ExternalDecimals::<T>::get(&asset_id).ok_or(Error::<T>::UnsupportedAsset)?;
1115			ensure!(T::Fungibles::decimals(asset_id) == ext_decimals, Error::<T>::DecimalsMismatch);
1116
1117			let internal_decimals = InternalDecimals::<T>::get().ok_or(Error::<T>::Unexpected)?;
1118			ensure!(
1119				T::InternalAsset::decimals() == internal_decimals,
1120				Error::<T>::DecimalsMismatch
1121			);
1122
1123			Ok((ext_decimals, internal_decimals))
1124		}
1125
1126		/// Ensure an account exists by incrementing its provider count if needed.
1127		pub(crate) fn ensure_account_exists(account: &T::AccountId) {
1128			if !frame_system::Pallet::<T>::account_exists(account) {
1129				frame_system::Pallet::<T>::inc_providers(account);
1130			}
1131		}
1132
1133		#[cfg(any(feature = "try-runtime", test))]
1134		pub(crate) fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> {
1135			use sp_runtime::traits::CheckedAdd;
1136
1137			// Check 1: Live decimals must still match the snapshots taken at registration/genesis —
1138			// both for the internal asset and every approved external asset.
1139			let internal_decimals_snapshot =
1140				InternalDecimals::<T>::get().ok_or("InternalDecimals not initialized")?;
1141			ensure!(
1142				T::InternalAsset::decimals() == internal_decimals_snapshot,
1143				"Internal asset live decimals differ from the genesis snapshot"
1144			);
1145			for (asset_id, _) in ExternalAssets::<T>::iter() {
1146				let snapshot = ExternalDecimals::<T>::get(&asset_id)
1147					.ok_or("Approved external asset missing decimals snapshot")?;
1148				ensure!(
1149					T::Fungibles::decimals(asset_id) == snapshot,
1150					"External asset live decimals differ from the registration snapshot"
1151				);
1152			}
1153
1154			// Check 2: Per-asset reserve (in external units) must be >= the external equivalent of
1155			// the tracked internal debt. Donated reserves may make it strictly greater.
1156			for (asset_id, _) in ExternalAssets::<T>::iter() {
1157				let debt = PsmDebt::<T>::get(&asset_id);
1158				let reserve = Self::get_reserve(asset_id.clone());
1159				let ext_decimals = ExternalDecimals::<T>::get(&asset_id)
1160					.ok_or("Approved external asset missing decimals snapshot")?;
1161				let debt_as_external =
1162					Self::internal_to_external(debt, ext_decimals, internal_decimals_snapshot)
1163						.map_err(|_| "Failed to convert tracked debt to external units")?;
1164				ensure!(
1165					reserve >= debt_as_external,
1166					"PSM reserve is less than tracked debt for an asset"
1167				);
1168			}
1169
1170			// Check 3: Computed total PSM debt must equal sum of per-asset debts.
1171			let mut sum = BalanceOf::<T>::zero();
1172			for (asset_id, _) in ExternalAssets::<T>::iter() {
1173				sum = sum
1174					.checked_add(&PsmDebt::<T>::get(&asset_id))
1175					.ok_or("PSM debt overflow when summing per-asset debts")?;
1176			}
1177			ensure!(
1178				Self::total_psm_debt() == sum,
1179				"total_psm_debt() does not match sum of per-asset debts"
1180			);
1181
1182			// Check 4: Per-asset debt should not exceed its ceiling.
1183			// (May be transiently violated if governance lowers ceilings, but
1184			// should hold under normal operation.)
1185			for (asset_id, status) in ExternalAssets::<T>::iter() {
1186				if status.allows_minting() {
1187					let debt = PsmDebt::<T>::get(&asset_id);
1188					let ceiling = Self::max_asset_debt(asset_id);
1189					ensure!(debt <= ceiling, "Per-asset PSM debt exceeds its ceiling");
1190				}
1191			}
1192
1193			Ok(())
1194		}
1195	}
1196}
1197
1198impl<T: pallet::Config> PsmInterface for pallet::Pallet<T> {
1199	type Balance = pallet::BalanceOf<T>;
1200
1201	fn reserved_capacity() -> Self::Balance {
1202		Self::max_psm_debt()
1203	}
1204}