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//! Instantiable Peg Stability Modules (PSMs). Each PSM enables 1:1 swaps between an internal
21//! stablecoin and one or more approved external stablecoins, typically to maintain a peg.
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 a PSM issues and burns (e.g. a runtime's own USD-pegged
33//!   stablecoin). Each PSM instance is keyed by its internal asset id; multiple instances can
34//!   coexist, each with its own reserve, debt ceiling, fee destination and approved externals. Mint
35//!   operations credit the user with the internal asset; redeem operations burn it. Fees are
36//!   collected in the internal asset and forwarded to that instance's [`PsmInfo::fee_destination`].
37//! * **External** — third-party assets (e.g. USDC, USDT) approved on a specific PSM via
38//!   [`Pallet::add_external_asset`] and held in that PSM's reserve. Users deposit external to mint
39//!   internal, and burn internal to redeem external. A PSM may approve multiple externals, each
40//!   identified by `external_asset`.
41//!
42//! ## Overview
43//!
44//! A PSM strengthens its internal asset's peg by providing arbitrage opportunities:
45//! - When the internal asset trades **above** $1: Users swap external assets for the internal asset
46//!   and sell for profit.
47//! - When the internal asset trades **below** $1: Users buy cheap internal asset and swap for
48//!   external assets.
49//!
50//! This creates a price corridor bounded by the minting and redemption fees.
51//!
52//! ### Key Concepts
53//!
54//! * **PSM instance**: A configured Peg Stability Module, keyed by its internal asset id and
55//!   described by [`PsmInfo`]. Each instance has its own reserve account derived from
56//!   `blake2_256((PalletId::TYPE_ID, PalletId, internal_asset).encode())`.
57//! * **Minting**: Deposit external asset → receive internal asset (minus fee).
58//! * **Redemption**: Burn internal asset → receive external asset (minus fee).
59//! * **Reserve**: External asset balance held by a PSM's reserve account (derived, not stored).
60//! * **PSM Debt**: Total internal asset minted through a PSM, backed 1:1 by external assets in that
61//!   PSM's reserve.
62//! * **Circuit Breaker**: Per-external emergency control to disable minting or all swaps.
63//!
64//! ### Fee Structure
65//!
66//! * **Minting Fee (`MintingFee`)**: Deducted from internal-asset output during minting, configured
67//!   per `(internal_asset, external_asset)` pair.
68//! * **Redemption Fee (`RedemptionFee`)**: Deducted from external-asset output during redemption,
69//!   configured per `(internal_asset, external_asset)` pair.
70//!
71//! Fees are collected in the internal asset and transferred to the instance's
72//! [`PsmInfo::fee_destination`].
73//!
74//! ### Example
75//!
76//! ```ignore
77//! // Mint internal asset by depositing USDC on the PSM
78//! let max_fee = MintingFee::<Runtime>::get(INTERNAL_ASSET_ID, USDC_ASSET_ID);
79//! Psm::mint(
80//! 	RuntimeOrigin::signed(user),
81//! 	INTERNAL_ASSET_ID,
82//! 	USDC_ASSET_ID,
83//! 	1000 * UNIT,
84//! 	max_fee,
85//! )?;
86//!
87//! // Redeem USDC by burning the internal asset
88//! let max_fee = RedemptionFee::<Runtime>::get(INTERNAL_ASSET_ID, USDC_ASSET_ID);
89//! Psm::redeem(
90//! 	RuntimeOrigin::signed(user),
91//! 	INTERNAL_ASSET_ID,
92//! 	USDC_ASSET_ID,
93//! 	1000 * UNIT,
94//! 	max_fee,
95//! )?;
96//! ```
97
98#![cfg_attr(not(feature = "std"), no_std)]
99
100extern crate alloc;
101
102pub mod weights;
103
104#[cfg(feature = "runtime-benchmarks")]
105mod benchmarking;
106#[cfg(test)]
107mod mock;
108#[cfg(test)]
109mod tests;
110
111pub use pallet::*;
112pub use weights::WeightInfo;
113
114/// Helper trait for benchmark setup.
115///
116/// Provides a way to create an external asset with the correct metadata (decimals)
117/// for benchmarks, abstracting over the deposit requirements of the underlying
118/// asset pallet.
119#[cfg(feature = "runtime-benchmarks")]
120pub trait BenchmarkHelper<AssetId, AccountId> {
121	/// Get the asset ID for a given asset index.
122	fn get_asset_id(asset_index: u32) -> AssetId;
123	/// Create an asset with metadata matching the internal asset's decimals.
124	fn create_asset(asset_id: AssetId, owner: &AccountId, decimals: u8);
125}
126
127#[frame_support::pallet]
128pub mod pallet {
129
130	use alloc::boxed::Box;
131	use codec::DecodeWithMemTracking;
132	use frame_support::{
133		pallet_prelude::*,
134		traits::{
135			fungibles::{
136				metadata::Inspect as FungiblesMetadataInspect,
137				roles::Inspect as FungiblesRolesInspect, Inspect as FungiblesInspect,
138				Mutate as FungiblesMutate,
139			},
140			tokens::{Fortitude, Precision, Preservation},
141			CallerTrait, Consideration, EnsureOriginWithArg, Footprint, OriginTrait,
142		},
143		PalletId,
144	};
145	use frame_system::pallet_prelude::*;
146	use sp_runtime::{
147		traits::{CheckedDiv, CheckedMul, Saturating, TrailingZeroInput, Zero},
148		Perbill, Permill, TypeId,
149	};
150
151	use crate::WeightInfo;
152
153	/// Circuit breaker levels for emergency control.
154	#[derive(
155		Encode,
156		Decode,
157		DecodeWithMemTracking,
158		MaxEncodedLen,
159		TypeInfo,
160		Clone,
161		Copy,
162		PartialEq,
163		Eq,
164		Debug,
165		Default,
166	)]
167	pub enum CircuitBreakerLevel {
168		/// Normal operation, all swaps enabled.
169		#[default]
170		AllEnabled,
171		/// Minting disabled, redemptions still allowed.
172		MintingDisabled,
173		/// All swaps disabled.
174		AllDisabled,
175	}
176
177	impl CircuitBreakerLevel {
178		/// Whether this level allows minting (external → internal).
179		pub const fn allows_minting(&self) -> bool {
180			matches!(self, CircuitBreakerLevel::AllEnabled)
181		}
182
183		/// Whether this level allows redemption (internal → external).
184		pub const fn allows_redemption(&self) -> bool {
185			!matches!(self, CircuitBreakerLevel::AllDisabled)
186		}
187	}
188
189	/// Privilege level of an origin acting on a PSM instance.
190	///
191	/// Resolved by matching the incoming origin against the instance's stored
192	/// [`PsmAdminInfo::full_admin`] (`Full`) or [`PsmAdminInfo::emergency_admin`]
193	/// (`Emergency`), enabling tiered authorization over the instance's parameters.
194	#[derive(
195		Encode,
196		Decode,
197		DecodeWithMemTracking,
198		MaxEncodedLen,
199		TypeInfo,
200		Clone,
201		Copy,
202		PartialEq,
203		Eq,
204		Debug,
205		Default,
206	)]
207	pub enum PsmManagerLevel {
208		/// Full administrative access, held by the instance's `full_admin`.
209		/// Can modify all parameters including fees, ceilings, and asset management,
210		/// reassign admins, and remove the instance.
211		#[default]
212		Full,
213		/// Emergency access, held by the instance's `emergency_admin`.
214		/// Can modify circuit breaker status.
215		Emergency,
216	}
217
218	impl PsmManagerLevel {
219		/// Whether this level allows modifying minting/redemption fees.
220		pub const fn can_set_fees(&self) -> bool {
221			matches!(self, PsmManagerLevel::Full)
222		}
223
224		/// Whether this level allows modifying the circuit breaker status.
225		/// Both Full and Emergency levels can set circuit breaker.
226		pub const fn can_set_circuit_breaker(&self) -> bool {
227			matches!(self, PsmManagerLevel::Full | PsmManagerLevel::Emergency)
228		}
229
230		/// Whether this level allows modifying the PSM debt ceiling.
231		/// Only Full can set the debt ceiling.
232		pub const fn can_set_max_debt(&self) -> bool {
233			matches!(self, PsmManagerLevel::Full)
234		}
235
236		/// Whether this level allows modifying per-asset ceiling weights.
237		/// Only Full can set asset ceiling weights.
238		pub const fn can_set_asset_ceiling(&self) -> bool {
239			matches!(self, PsmManagerLevel::Full)
240		}
241
242		/// Whether this level allows adding or removing external assets.
243		pub const fn can_manage_assets(&self) -> bool {
244			matches!(self, PsmManagerLevel::Full)
245		}
246
247		/// Whether this level allows reassigning the PSM's `full_admin` / `emergency_admin`.
248		pub const fn can_manage_admins(&self) -> bool {
249			matches!(self, PsmManagerLevel::Full)
250		}
251
252		/// Whether this level allows removing the PSM instance.
253		pub const fn can_remove_psm(&self) -> bool {
254			matches!(self, PsmManagerLevel::Full)
255		}
256	}
257
258	pub(crate) type BalanceOf<T> = <<T as Config>::Fungibles as FungiblesInspect<
259		<T as frame_system::Config>::AccountId,
260	>>::Balance;
261
262	/// Suggested fee of 0.5% for minting and redemption.
263	pub(crate) struct DefaultFee;
264	impl Get<Permill> for DefaultFee {
265		fn get() -> Permill {
266			Permill::from_parts(5_000)
267		}
268	}
269
270	/// Maximum absolute difference between an external asset's decimals and the internal
271	/// asset's decimals. Bounds the scaling factor `10^diff` well below `u128::MAX`
272	/// so realistic balances cannot overflow during conversion.
273	pub const MAX_DECIMALS_DIFF: u32 = 24;
274
275	/// On-chain record of a PSM instance.
276	#[derive(
277		Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq, Debug,
278	)]
279	#[scale_info(skip_type_params(T))]
280	pub struct PsmInfo<T: Config> {
281		/// Account receiving minting and redemption fees, denominated in the internal asset.
282		pub fee_destination: T::AccountId,
283		/// This PSM instance's debt ceiling, in internal-asset units.
284		pub max_debt: BalanceOf<T>,
285		/// Minimum swap amount for this instance, in internal-asset units. Swaps whose
286		/// internal-equivalent falls below this are rejected with [`Error::BelowMinimumSwap`].
287		pub min_swap_amount: BalanceOf<T>,
288		/// Snapshot of the internal asset's decimals at install time.
289		pub internal_decimals: u8,
290		/// Number of approved external assets attached to this instance.
291		pub external_count: u32,
292	}
293
294	/// Admin origins and creation-deposit bookkeeping for a PSM instance. Always written
295	/// and removed together with the [`Psm`] entry.
296	#[derive(Encode, Decode, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq, Debug)]
297	#[scale_info(skip_type_params(T))]
298	// Kept separate from `PsmInfo` so the swap path (`mint`/`redeem`) doesn't read the
299	// admin origins, which can be large.
300	pub struct PsmAdminInfo<T: Config> {
301		/// Origin with `Full` management privileges over this PSM. Set on `create_psm` and
302		/// reassignable to any origin via `set_full_admin`.
303		pub full_admin: T::PalletsOrigin,
304		/// Origin with `Emergency` management privileges over this PSM. Set on `create_psm` and
305		/// reassignable to any origin via `set_emergency_admin`.
306		pub emergency_admin: T::PalletsOrigin,
307		/// Optional creation deposit and its depositor. Dropped on `remove_psm`, independently of
308		/// any admin reassignment.
309		pub deposit: Option<(T::AccountId, T::Consideration)>,
310	}
311
312	/// On-chain record of an external asset approved on a PSM instance.
313	#[derive(
314		Encode,
315		Decode,
316		DecodeWithMemTracking,
317		MaxEncodedLen,
318		TypeInfo,
319		Clone,
320		Copy,
321		PartialEq,
322		Eq,
323		Debug,
324	)]
325	pub struct ExternalAssetInfo {
326		/// Per-external circuit breaker status.
327		pub status: CircuitBreakerLevel,
328		/// Snapshot of the external asset's decimals at registration time.
329		pub decimals: u8,
330	}
331
332	#[pallet::config]
333	pub trait Config: frame_system::Config {
334		/// Fungibles implementation for both internal and external assets.
335		type Fungibles: FungiblesMutate<Self::AccountId, AssetId = Self::AssetId>
336			+ FungiblesMetadataInspect<Self::AccountId>
337			+ FungiblesRolesInspect<Self::AccountId>;
338
339		/// Consideration for PSM creation. Runtimes can price this from the PSM footprint or
340		/// anything else they choose.
341		type Consideration: Consideration<Self::AccountId, Footprint>;
342
343		/// Origin permitted to create a PSM for a given internal asset; succeeds with the
344		/// optional account that pays the creation consideration. Returning `None` creates without
345		/// a deposit, useful for privileged origins such as Root.
346		type CreateOrigin: EnsureOriginWithArg<
347			<Self as frame_system::Config>::RuntimeOrigin,
348			Self::AssetId,
349			Success = Option<Self::AccountId>,
350		>;
351
352		/// The aggregated origin, tying the runtime origin to [`Config::PalletsOrigin`] so PSM
353		/// admins can be matched against incoming origins.
354		type RuntimeOrigin: OriginTrait<PalletsOrigin = Self::PalletsOrigin>
355			+ From<Self::PalletsOrigin>
356			+ IsType<<Self as frame_system::Config>::RuntimeOrigin>;
357
358		/// The caller origin, overarching type of all pallets' origins. Stored as a PSM's
359		/// `full_admin` / `emergency_admin` and matched against incoming origins.
360		type PalletsOrigin: Parameter
361			+ From<frame_system::RawOrigin<Self::AccountId>>
362			+ CallerTrait<Self::AccountId>
363			+ MaxEncodedLen;
364
365		/// Asset identifier type.
366		type AssetId: Parameter + Member + Clone + MaybeSerializeDeserialize + MaxEncodedLen + Ord;
367
368		/// A type representing the weights required by the dispatchables of this pallet.
369		type WeightInfo: WeightInfo;
370
371		/// PalletId for deriving each PSM instance's reserve sub-account.
372		#[pallet::constant]
373		type PalletId: Get<PalletId>;
374
375		/// Maximum number of approved external assets per PSM instance.
376		#[pallet::constant]
377		type MaxExternals: Get<u32>;
378
379		/// Helper for benchmarks to create an external asset with correct metadata.
380		#[cfg(feature = "runtime-benchmarks")]
381		type BenchmarkHelper: crate::BenchmarkHelper<Self::AssetId, Self::AccountId>;
382	}
383
384	/// [`Config::CreateOrigin`] admitting a signed origin only when it owns the internal asset.
385	/// Prevents creating a PSM over an asset you don't control (PSM mint/burn bypasses the
386	/// asset's issuer checks).
387	pub struct EnsureAssetOwner<T>(core::marker::PhantomData<T>);
388
389	impl<T: Config> EnsureOriginWithArg<<T as frame_system::Config>::RuntimeOrigin, T::AssetId>
390		for EnsureAssetOwner<T>
391	{
392		type Success = Option<T::AccountId>;
393
394		fn try_origin(
395			origin: <T as frame_system::Config>::RuntimeOrigin,
396			internal_asset: &T::AssetId,
397		) -> Result<Self::Success, <T as frame_system::Config>::RuntimeOrigin> {
398			match ensure_signed(origin.clone()) {
399				Ok(who) if T::Fungibles::owner(internal_asset.clone()) == Some(who.clone()) => {
400					Ok(Some(who))
401				},
402				_ => Err(origin),
403			}
404		}
405
406		#[cfg(feature = "runtime-benchmarks")]
407		fn try_successful_origin(
408			internal_asset: &T::AssetId,
409		) -> Result<<T as frame_system::Config>::RuntimeOrigin, ()> {
410			// A signed origin of the asset's current owner satisfies the owner check.
411			let owner = T::Fungibles::owner(internal_asset.clone()).ok_or(())?;
412			Ok(frame_system::RawOrigin::Signed(owner).into())
413		}
414	}
415
416	/// The in-code storage version.
417	const STORAGE_VERSION: StorageVersion = StorageVersion::new(0);
418
419	#[pallet::pallet]
420	#[pallet::storage_version(STORAGE_VERSION)]
421	pub struct Pallet<T>(_);
422
423	/// A reason for this pallet placing a hold on funds.
424	#[pallet::composite_enum]
425	pub enum HoldReason {
426		/// The deposit backing a PSM instance created via `create_psm`.
427		#[codec(index = 0)]
428		CreationDeposit,
429	}
430
431	#[pallet::hooks]
432	impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
433		fn integrity_test() {
434			const ENTROPY_BYTES: usize = 32;
435			assert!(
436				<T::AccountId as MaxEncodedLen>::max_encoded_len() >= ENTROPY_BYTES,
437				"T::AccountId is too small to preserve the full PSM reserve-account entropy",
438			);
439		}
440
441		#[cfg(feature = "try-runtime")]
442		fn try_state(_n: BlockNumberFor<T>) -> Result<(), sp_runtime::TryRuntimeError> {
443			Self::do_try_state()
444		}
445	}
446
447	/// Registered PSM instances, keyed by the internal asset id.
448	#[pallet::storage]
449	pub type Psm<T: Config> = StorageMap<_, Blake2_128Concat, T::AssetId, PsmInfo<T>, OptionQuery>;
450
451	/// Admin origins and creation-deposit bookkeeping per PSM, keyed by the internal
452	/// asset id. Held separately from [`Psm`] so swaps never decode the admin origins.
453	/// Always written and removed together with the corresponding [`Psm`] entry.
454	#[pallet::storage]
455	pub type PsmAdmin<T: Config> =
456		StorageMap<_, Blake2_128Concat, T::AssetId, PsmAdminInfo<T>, OptionQuery>;
457
458	/// Internal-asset debt minted through PSM, per `(internal, external)` pair.
459	#[pallet::storage]
460	pub type PsmDebt<T: Config> = StorageDoubleMap<
461		_,
462		Blake2_128Concat,
463		T::AssetId,
464		Blake2_128Concat,
465		T::AssetId,
466		BalanceOf<T>,
467		ValueQuery,
468	>;
469
470	/// Fee for external → internal swaps (minting), per `(internal, external)` pair.
471	/// Defaults to 0.5%.
472	#[pallet::storage]
473	pub(crate) type MintingFee<T: Config> = StorageDoubleMap<
474		_,
475		Blake2_128Concat,
476		T::AssetId,
477		Blake2_128Concat,
478		T::AssetId,
479		Permill,
480		ValueQuery,
481		DefaultFee,
482	>;
483
484	/// Fee for internal → external swaps (redemption), per `(internal, external)` pair.
485	/// Defaults to 0.5%.
486	#[pallet::storage]
487	pub(crate) type RedemptionFee<T: Config> = StorageDoubleMap<
488		_,
489		Blake2_128Concat,
490		T::AssetId,
491		Blake2_128Concat,
492		T::AssetId,
493		Permill,
494		ValueQuery,
495		DefaultFee,
496	>;
497
498	/// Per-external ceiling weight within a PSM, normalised against the sum of weights
499	/// for the same instance. Zero disables minting for that external.
500	#[pallet::storage]
501	pub(crate) type AssetCeilingWeight<T: Config> = StorageDoubleMap<
502		_,
503		Blake2_128Concat,
504		T::AssetId,
505		Blake2_128Concat,
506		T::AssetId,
507		Permill,
508		ValueQuery,
509	>;
510
511	/// Approved external assets per PSM.
512	#[pallet::storage]
513	pub(crate) type ExternalAssets<T: Config> = StorageDoubleMap<
514		_,
515		Blake2_128Concat,
516		T::AssetId,
517		Blake2_128Concat,
518		T::AssetId,
519		ExternalAssetInfo,
520		OptionQuery,
521	>;
522
523	#[pallet::event]
524	#[pallet::generate_deposit(pub(super) fn deposit_event)]
525	pub enum Event<T: Config> {
526		/// User swapped external asset for internal.
527		Minted {
528			who: T::AccountId,
529			internal_asset: T::AssetId,
530			external_asset: T::AssetId,
531			external_consumed: BalanceOf<T>,
532			internal_received: BalanceOf<T>,
533			internal_fee: BalanceOf<T>,
534		},
535		/// User swapped internal for external asset.
536		Redeemed {
537			who: T::AccountId,
538			internal_asset: T::AssetId,
539			external_asset: T::AssetId,
540			internal_consumed: BalanceOf<T>,
541			external_received: BalanceOf<T>,
542			internal_fee: BalanceOf<T>,
543		},
544		/// Minting fee updated for an asset by governance.
545		MintingFeeUpdated {
546			internal_asset: T::AssetId,
547			external_asset: T::AssetId,
548			old_value: Permill,
549			new_value: Permill,
550		},
551		/// Redemption fee updated for an asset by governance.
552		RedemptionFeeUpdated {
553			internal_asset: T::AssetId,
554			external_asset: T::AssetId,
555			old_value: Permill,
556			new_value: Permill,
557		},
558		/// PSM debt ceiling updated by governance.
559		MaxDebtUpdated {
560			internal_asset: T::AssetId,
561			old_value: BalanceOf<T>,
562			new_value: BalanceOf<T>,
563		},
564		/// Per-asset debt ceiling weight updated by governance.
565		AssetCeilingWeightUpdated {
566			internal_asset: T::AssetId,
567			external_asset: T::AssetId,
568			old_value: Permill,
569			new_value: Permill,
570		},
571		/// Per-asset circuit breaker status updated.
572		AssetStatusUpdated {
573			internal_asset: T::AssetId,
574			external_asset: T::AssetId,
575			status: CircuitBreakerLevel,
576		},
577		/// An external asset was added to the approved list.
578		ExternalAssetAdded { internal_asset: T::AssetId, external_asset: T::AssetId },
579		/// An external asset was removed from the approved list.
580		ExternalAssetRemoved { internal_asset: T::AssetId, external_asset: T::AssetId },
581		/// A PSM instance was created.
582		PsmCreated {
583			internal_asset: T::AssetId,
584			// Boxed: `PalletsOrigin` can be large and would otherwise inflate the `Event` enum.
585			full_admin: Box<T::PalletsOrigin>,
586			emergency_admin: Box<T::PalletsOrigin>,
587			fee_destination: T::AccountId,
588			max_debt: BalanceOf<T>,
589		},
590		/// A PSM instance was removed.
591		PsmRemoved { internal_asset: T::AssetId },
592		/// A PSM's `full_admin` was reassigned.
593		FullAdminChanged {
594			internal_asset: T::AssetId,
595			old_admin: Box<T::PalletsOrigin>,
596			new_admin: Box<T::PalletsOrigin>,
597		},
598		/// A PSM's `emergency_admin` was reassigned.
599		EmergencyAdminChanged {
600			internal_asset: T::AssetId,
601			old_admin: Box<T::PalletsOrigin>,
602			new_admin: Box<T::PalletsOrigin>,
603		},
604	}
605
606	#[pallet::error]
607	pub enum Error<T> {
608		/// PSM doesn't have enough external asset for redemption.
609		InsufficientReserve,
610		/// Swap would exceed PSM debt ceiling.
611		ExceedsMaxPsmDebt,
612		/// Swap amount below the instance's minimum threshold.
613		BelowMinimumSwap,
614		/// Current fee exceeds the caller-provided maximum.
615		FeeTooHigh,
616		/// `create_psm` was called with a zero `min_swap_amount`.
617		ZeroMinSwapAmount,
618		/// Minting operations are disabled (circuit breaker level >= 1).
619		MintingStopped,
620		/// All swap operations are disabled (circuit breaker level = 2).
621		AllSwapsStopped,
622		/// Asset is not an approved external asset.
623		UnsupportedAsset,
624		/// No PSM instance is registered for the given internal asset.
625		PsmNotFound,
626		/// Asset is already in the approved list.
627		AssetAlreadyApproved,
628		/// Asset does not exist.
629		AssetDoesNotExist,
630		/// Cannot remove asset: not in approved list.
631		AssetNotApproved,
632		/// Cannot remove asset: has non-zero PSM debt.
633		AssetHasDebt,
634		/// Operation requires the instance's `full_admin` (Full level); the caller only
635		/// matched the `emergency_admin` (Emergency level).
636		InsufficientPrivilege,
637		/// Maximum number of approved external assets reached.
638		TooManyAssets,
639		/// Live decimals diverged from the snapshot taken at registration or genesis.
640		DecimalsMismatch,
641		/// The asset's decimal precision is outside the supported range.
642		DecimalsRangeExceeded,
643		/// Decimal scaling produced an arithmetic overflow.
644		ConversionOverflow,
645		/// Conversion to the counter-asset rounds to zero; swap would transfer nothing.
646		AmountTooSmallAfterConversion,
647		/// A PSM is already registered for this internal asset.
648		PsmAlreadyExists,
649		/// The PSM has non-zero outstanding debt on at least one approved external.
650		PsmHasDebt,
651		/// The PSM still has approved externals; remove them before removing the PSM.
652		PsmHasApprovedExternals,
653		/// An unexpected invariant violation occurred. This should be reported.
654		Unexpected,
655	}
656
657	#[pallet::call]
658	impl<T: Config> Pallet<T> {
659		/// Swap external asset for internal on a specific PSM instance.
660		///
661		/// ## Dispatch Origin
662		///
663		/// Must be `Signed` by the user performing the swap.
664		///
665		/// ## Details
666		///
667		/// Transfers `external_amount` of `external_asset` from the caller to the
668		/// `internal_asset`'s PSM reserve account, then mints `internal_asset` to the
669		/// caller minus the minting fee. The fee is calculated using ceiling rounding
670		/// (`mul_ceil`), ensuring the protocol never undercharges. The fee is
671		/// transferred to [`PsmInfo::fee_destination`] of the targeted instance.
672		///
673		/// ## Parameters
674		///
675		/// - `internal_asset`: The internal stablecoin that identifies the PSM instance.
676		/// - `external_asset`: The external asset to deposit (must be approved on
677		///   `internal_asset`).
678		/// - `external_amount`: Amount of external asset to deposit.
679		/// - `max_fee`: Maximum minting fee rate accepted by the caller.
680		///
681		/// ## Errors
682		///
683		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
684		/// - [`Error::UnsupportedAsset`]: If `external_asset` is not approved on this PSM.
685		/// - [`Error::MintingStopped`]: If the per-external circuit breaker is at `MintingDisabled`
686		///   or higher.
687		/// - [`Error::BelowMinimumSwap`]: If the internal-equivalent of `external_amount` is below
688		///   the instance's `min_swap_amount`.
689		/// - [`Error::FeeTooHigh`]: If the configured minting fee exceeds `max_fee`.
690		/// - [`Error::ExceedsMaxPsmDebt`]: If minting would exceed this PSM's debt ceiling
691		///   (aggregate or per-asset).
692		/// - [`Error::DecimalsMismatch`]: If live decimals diverged from the snapshot taken at
693		///   registration.
694		/// - [`Error::AmountTooSmallAfterConversion`]: If the conversion to the counter-asset
695		///   rounds to zero; swap would transfer nothing.
696		///
697		/// ## Events
698		///
699		/// - [`Event::Minted`]: Emitted on successful mint.
700		#[pallet::call_index(0)]
701		#[pallet::weight(T::WeightInfo::mint(T::MaxExternals::get()))]
702		pub fn mint(
703			origin: OriginFor<T>,
704			internal_asset: T::AssetId,
705			external_asset: T::AssetId,
706			external_amount: BalanceOf<T>,
707			max_fee: Permill,
708		) -> DispatchResult {
709			let who = ensure_signed(origin)?;
710			let info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
711
712			let external = ExternalAssets::<T>::get(&internal_asset, &external_asset)
713				.ok_or(Error::<T>::UnsupportedAsset)?;
714			ensure!(external.status.allows_minting(), Error::<T>::MintingStopped);
715
716			let (ext_decimals, internal_decimals) =
717				Self::ensure_decimals_match(&info, &internal_asset, &external_asset, &external)?;
718
719			let internal_equivalent =
720				Self::external_to_internal(external_amount, ext_decimals, internal_decimals)?;
721			ensure!(!internal_equivalent.is_zero(), Error::<T>::AmountTooSmallAfterConversion);
722			ensure!(internal_equivalent >= info.min_swap_amount, Error::<T>::BelowMinimumSwap);
723
724			let effective_external =
725				Self::internal_to_external(internal_equivalent, ext_decimals, internal_decimals)?;
726
727			let fee_rate = MintingFee::<T>::get(&internal_asset, &external_asset);
728			ensure!(fee_rate <= max_fee, Error::<T>::FeeTooHigh);
729			let fee = fee_rate.mul_ceil(internal_equivalent);
730			let internal_to_user = internal_equivalent.saturating_sub(fee);
731
732			let current_total_psm_debt = Self::total_psm_debt(&internal_asset);
733			ensure!(
734				current_total_psm_debt.saturating_add(internal_equivalent) <= info.max_debt,
735				Error::<T>::ExceedsMaxPsmDebt
736			);
737
738			let current_debt = PsmDebt::<T>::get(&internal_asset, &external_asset);
739			let max_debt = Self::max_asset_debt(&internal_asset, &external_asset, &info);
740			let new_debt = current_debt.saturating_add(internal_equivalent);
741			ensure!(new_debt <= max_debt, Error::<T>::ExceedsMaxPsmDebt);
742
743			let psm_account = Self::psm_account(&internal_asset);
744			T::Fungibles::transfer(
745				external_asset.clone(),
746				&who,
747				&psm_account,
748				effective_external,
749				Preservation::Expendable,
750			)?;
751			T::Fungibles::mint_into(internal_asset.clone(), &who, internal_to_user)?;
752			if !fee.is_zero() {
753				T::Fungibles::mint_into(internal_asset.clone(), &info.fee_destination, fee)?;
754			}
755
756			PsmDebt::<T>::insert(&internal_asset, &external_asset, new_debt);
757
758			Self::deposit_event(Event::Minted {
759				who,
760				internal_asset,
761				external_asset,
762				external_consumed: effective_external,
763				internal_received: internal_to_user,
764				internal_fee: fee,
765			});
766			Ok(())
767		}
768
769		/// Swap internal for external asset on a specific PSM instance.
770		///
771		/// ## Dispatch Origin
772		///
773		/// Must be `Signed` by the user performing the swap.
774		///
775		/// ## Details
776		///
777		/// Burns `internal_amount` of `internal_asset` from the caller minus fee (transferred
778		/// to the instance's [`PsmInfo::fee_destination`]), then transfers the resulting
779		/// amount in `external_asset` from the PSM reserve to the caller. The fee is
780		/// calculated using ceiling rounding (`mul_ceil`), ensuring the protocol never
781		/// undercharges. Redemptions use the decimals snapshotted when the PSM/external pair
782		/// was registered, allowing existing positions to unwind even if live metadata later
783		/// changes.
784		///
785		/// ## Parameters
786		///
787		/// - `internal_asset`: The internal stablecoin that identifies the PSM instance.
788		/// - `external_asset`: The external asset to receive (must be approved on
789		///   `internal_asset`).
790		/// - `internal_amount`: Amount of `internal_asset` to redeem.
791		/// - `max_fee`: Maximum redemption fee rate accepted by the caller.
792		///
793		/// ## Errors
794		///
795		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
796		/// - [`Error::UnsupportedAsset`]: If `external_asset` is not approved on this PSM.
797		/// - [`Error::AllSwapsStopped`]: If the per-external circuit breaker is at `AllDisabled`.
798		/// - [`Error::BelowMinimumSwap`]: If `internal_amount` is below the instance's
799		///   `min_swap_amount`.
800		/// - [`Error::FeeTooHigh`]: If the configured redemption fee exceeds `max_fee`.
801		/// - [`Error::InsufficientReserve`]: If the PSM holds less of `external_asset` than the
802		///   redemption requires.
803		/// - [`Error::AmountTooSmallAfterConversion`]: If the conversion to the counter-asset
804		///   rounds to zero; swap would transfer nothing.
805		///
806		/// ## Events
807		///
808		/// - [`Event::Redeemed`]: Emitted on successful redemption.
809		#[pallet::call_index(1)]
810		#[pallet::weight(T::WeightInfo::redeem())]
811		pub fn redeem(
812			origin: OriginFor<T>,
813			internal_asset: T::AssetId,
814			external_asset: T::AssetId,
815			internal_amount: BalanceOf<T>,
816			max_fee: Permill,
817		) -> DispatchResult {
818			let who = ensure_signed(origin)?;
819			let info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
820
821			let external = ExternalAssets::<T>::get(&internal_asset, &external_asset)
822				.ok_or(Error::<T>::UnsupportedAsset)?;
823			ensure!(external.status.allows_redemption(), Error::<T>::AllSwapsStopped);
824
825			let ext_decimals = external.decimals;
826			let internal_decimals = info.internal_decimals;
827
828			ensure!(internal_amount >= info.min_swap_amount, Error::<T>::BelowMinimumSwap);
829
830			let fee_rate = RedemptionFee::<T>::get(&internal_asset, &external_asset);
831			ensure!(fee_rate <= max_fee, Error::<T>::FeeTooHigh);
832			let fee = fee_rate.mul_ceil(internal_amount);
833			let internal_net = internal_amount.saturating_sub(fee);
834
835			let external_out =
836				Self::internal_to_external(internal_net, ext_decimals, internal_decimals)?;
837			ensure!(
838				internal_net.is_zero() || !external_out.is_zero(),
839				Error::<T>::AmountTooSmallAfterConversion
840			);
841			// `effective_internal_net` is the internal value that round-trips to `external_out`;
842			// it is what we actually burn and what the tracked debt decreases by. Any truncation
843			// dust stays in the caller's internal balance, symmetric with `mint`, which takes
844			// only the round-tripped share of the external amount.
845			let effective_internal_net =
846				Self::external_to_internal(external_out, ext_decimals, internal_decimals)?;
847
848			let current_debt = PsmDebt::<T>::get(&internal_asset, &external_asset);
849			ensure!(current_debt >= effective_internal_net, Error::<T>::InsufficientReserve);
850
851			let reserve = Self::get_reserve(&internal_asset, &external_asset);
852			if reserve < external_out {
853				defensive!("PSM reserve is less than expected output amount");
854				return Err(Error::<T>::Unexpected.into());
855			}
856
857			if !fee.is_zero() {
858				T::Fungibles::transfer(
859					internal_asset.clone(),
860					&who,
861					&info.fee_destination,
862					fee,
863					Preservation::Expendable,
864				)?;
865			}
866
867			if !effective_internal_net.is_zero() {
868				T::Fungibles::burn_from(
869					internal_asset.clone(),
870					&who,
871					effective_internal_net,
872					Preservation::Expendable,
873					Precision::Exact,
874					Fortitude::Polite,
875				)?;
876			}
877
878			let psm_account = Self::psm_account(&internal_asset);
879			if !external_out.is_zero() {
880				T::Fungibles::transfer(
881					external_asset.clone(),
882					&psm_account,
883					&who,
884					external_out,
885					Preservation::Expendable,
886				)?;
887			}
888
889			PsmDebt::<T>::mutate(&internal_asset, &external_asset, |debt| {
890				*debt = debt.saturating_sub(effective_internal_net);
891			});
892
893			Self::deposit_event(Event::Redeemed {
894				who,
895				internal_asset,
896				external_asset,
897				internal_consumed: effective_internal_net.saturating_add(fee),
898				external_received: external_out,
899				internal_fee: fee,
900			});
901			Ok(())
902		}
903
904		/// Create a PSM for a given internal asset.
905		///
906		/// If [`Config::CreateOrigin`] resolves to `Some(account)`, takes a
907		/// [`Config::Consideration`] deposit from that account for the instance's footprint
908		/// (refunded on `remove_psm`). If it resolves to `None`, no deposit is taken. The
909		/// `full_admin` and `emergency_admin` origins are set from the provided arguments and may
910		/// later be reassigned via [`Pallet::set_full_admin`] / [`Pallet::set_emergency_admin`].
911		///
912		/// ## Dispatch Origin
913		///
914		/// [`Config::CreateOrigin`], parameterised by `internal_asset`. With the recommended
915		/// [`EnsureAssetOwner`] this is a signed origin that owns `internal_asset`.
916		///
917		/// ## Parameters
918		///
919		/// - `internal_asset`: The internal stablecoin keying the new PSM. Must exist in the
920		///   fungibles backend; must not already have a PSM registered.
921		/// - `full_admin`: Origin granted full management of the new PSM.
922		/// - `emergency_admin`: Origin granted emergency management of the new PSM.
923		/// - `fee_destination`: Account that will receive mint/redeem fees.
924		/// - `max_debt`: Initial absolute internal-asset debt ceiling.
925		/// - `min_swap_amount`: Minimum swap amount for this instance, in internal-asset units.
926		///   Must be non-zero.
927		///
928		/// ## Errors
929		///
930		/// - [`DispatchError::BadOrigin`]: The origin is not permitted by [`Config::CreateOrigin`].
931		/// - [`Error::PsmAlreadyExists`]: A PSM is already registered for `internal_asset`.
932		/// - [`Error::ZeroMinSwapAmount`]: `min_swap_amount` is zero.
933		/// - [`Error::AssetDoesNotExist`]: The internal asset does not exist.
934		/// - Any error from establishing the [`Config::Consideration`] deposit when one is needed
935		///   (e.g. the account cannot afford it).
936		///
937		/// ## Events
938		///
939		/// - [`Event::PsmCreated`].
940		#[pallet::call_index(2)]
941		#[pallet::weight(T::WeightInfo::create_psm())]
942		pub fn create_psm(
943			origin: OriginFor<T>,
944			internal_asset: T::AssetId,
945			full_admin: Box<T::PalletsOrigin>,
946			emergency_admin: Box<T::PalletsOrigin>,
947			fee_destination: T::AccountId,
948			max_debt: BalanceOf<T>,
949			min_swap_amount: BalanceOf<T>,
950		) -> DispatchResult {
951			let maybe_depositor = T::CreateOrigin::ensure_origin(origin, &internal_asset)?;
952			ensure!(!Psm::<T>::contains_key(&internal_asset), Error::<T>::PsmAlreadyExists);
953			ensure!(!min_swap_amount.is_zero(), Error::<T>::ZeroMinSwapAmount);
954			ensure!(
955				T::Fungibles::asset_exists(internal_asset.clone()),
956				Error::<T>::AssetDoesNotExist
957			);
958
959			let deposit = maybe_depositor
960				.map(|depositor| {
961					T::Consideration::new(&depositor, Footprint::from_parts(1, 0))
962						.map(|ticket| (depositor, ticket))
963				})
964				.transpose()?;
965
966			let full_admin = *full_admin;
967			let emergency_admin = *emergency_admin;
968			let internal_decimals = T::Fungibles::decimals(internal_asset.clone());
969			Psm::<T>::insert(
970				&internal_asset,
971				PsmInfo::<T> {
972					fee_destination: fee_destination.clone(),
973					max_debt,
974					min_swap_amount,
975					internal_decimals,
976					external_count: 0,
977				},
978			);
979			PsmAdmin::<T>::insert(
980				&internal_asset,
981				PsmAdminInfo::<T> {
982					full_admin: full_admin.clone(),
983					emergency_admin: emergency_admin.clone(),
984					deposit,
985				},
986			);
987			// Acquire a provider reference on the reserve account and the fee destination for the
988			// lifetime of this PSM, so they can hold non-sufficient assets (external collateral /
989			// minted fees). Released in `remove_psm`. Unconditional (rather than
990			// `ensure_account_exists`) so the inc/dec is symmetric even when an account already
991			// exists or is shared across PSMs.
992			frame_system::Pallet::<T>::inc_providers(&Self::psm_account(&internal_asset));
993			frame_system::Pallet::<T>::inc_providers(&fee_destination);
994
995			Self::deposit_event(Event::PsmCreated {
996				internal_asset,
997				full_admin: Box::new(full_admin),
998				emergency_admin: Box::new(emergency_admin),
999				fee_destination,
1000				max_debt,
1001			});
1002			Ok(())
1003		}
1004
1005		/// Remove a PSM. Callable by the current `full_admin`. All approved externals
1006		/// must be removed first and aggregate PSM debt must be zero.
1007		///
1008		/// If a creation deposit was taken, it is always returned to the account that originally
1009		/// paid it, regardless of any later admin reassignment.
1010		///
1011		/// ## Dispatch Origin
1012		///
1013		/// Must match the PSM's `full_admin`.
1014		///
1015		/// ## Parameters
1016		///
1017		/// - `internal_asset`: The PSM instance to remove.
1018		///
1019		/// ## Errors
1020		///
1021		/// - [`Error::PsmNotFound`]: No PSM is registered for `internal_asset`.
1022		/// - [`Error::PsmHasApprovedExternals`]: Approved externals still exist.
1023		/// - [`Error::PsmHasDebt`]: Outstanding aggregate debt is non-zero.
1024		///
1025		/// ## Events
1026		///
1027		/// - [`Event::PsmRemoved`].
1028		#[pallet::call_index(3)]
1029		#[pallet::weight(T::WeightInfo::remove_psm())]
1030		pub fn remove_psm(origin: OriginFor<T>, internal_asset: T::AssetId) -> DispatchResult {
1031			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_remove_psm())?;
1032			let info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1033			ensure!(info.external_count == 0, Error::<T>::PsmHasApprovedExternals);
1034			ensure!(Self::total_psm_debt(&internal_asset).is_zero(), Error::<T>::PsmHasDebt);
1035
1036			let PsmAdminInfo { deposit, .. } =
1037				PsmAdmin::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1038			if let Some((depositor, ticket)) = deposit {
1039				ticket.drop(&depositor)?;
1040			}
1041
1042			Psm::<T>::remove(&internal_asset);
1043			PsmAdmin::<T>::remove(&internal_asset);
1044
1045			// Release the provider references acquired in `create_psm`. Reaps each account when
1046			// empty; a `ConsumerRemaining` error just means it still holds funds and must stay
1047			// alive, so the result is intentionally discarded.
1048			frame_system::Pallet::<T>::dec_providers(&Self::psm_account(&internal_asset)).ok();
1049			frame_system::Pallet::<T>::dec_providers(&info.fee_destination).ok();
1050
1051			Self::deposit_event(Event::PsmRemoved { internal_asset });
1052			Ok(())
1053		}
1054
1055		/// Set the minting fee for an `(internal_asset, external_asset)` pair.
1056		///
1057		/// ## Dispatch Origin
1058		///
1059		/// Must match the PSM instance's `full_admin` (the `Full` privilege level).
1060		///
1061		/// ## Parameters
1062		///
1063		/// - `internal_asset`: The PSM instance to configure.
1064		/// - `external_asset`: The external asset whose minting fee is being updated.
1065		/// - `fee`: The new minting fee.
1066		///
1067		/// ## Errors
1068		///
1069		/// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges.
1070		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on `internal_asset`.
1071		///
1072		/// ## Events
1073		///
1074		/// - [`Event::MintingFeeUpdated`]: Emitted with old and new values.
1075		#[pallet::call_index(4)]
1076		#[pallet::weight(T::WeightInfo::set_minting_fee())]
1077		pub fn set_minting_fee(
1078			origin: OriginFor<T>,
1079			internal_asset: T::AssetId,
1080			external_asset: T::AssetId,
1081			fee: Permill,
1082		) -> DispatchResult {
1083			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_fees())?;
1084			ensure!(
1085				ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1086				Error::<T>::AssetNotApproved
1087			);
1088			let old_value = MintingFee::<T>::get(&internal_asset, &external_asset);
1089			MintingFee::<T>::insert(&internal_asset, &external_asset, fee);
1090			Self::deposit_event(Event::MintingFeeUpdated {
1091				internal_asset,
1092				external_asset,
1093				old_value,
1094				new_value: fee,
1095			});
1096			Ok(())
1097		}
1098
1099		/// Set the redemption fee for an `(internal_asset, external_asset)` pair.
1100		///
1101		/// ## Dispatch Origin
1102		///
1103		/// Must match the PSM instance's `full_admin` (the `Full` privilege level).
1104		///
1105		/// ## Parameters
1106		///
1107		/// - `internal_asset`: The PSM instance to configure.
1108		/// - `external_asset`: The external asset whose redemption fee is being updated.
1109		/// - `fee`: The new redemption fee.
1110		///
1111		/// ## Errors
1112		///
1113		/// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges.
1114		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on `internal_asset`.
1115		///
1116		/// ## Events
1117		///
1118		/// - [`Event::RedemptionFeeUpdated`]: Emitted with old and new values.
1119		#[pallet::call_index(5)]
1120		#[pallet::weight(T::WeightInfo::set_redemption_fee())]
1121		pub fn set_redemption_fee(
1122			origin: OriginFor<T>,
1123			internal_asset: T::AssetId,
1124			external_asset: T::AssetId,
1125			fee: Permill,
1126		) -> DispatchResult {
1127			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_fees())?;
1128			ensure!(
1129				ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1130				Error::<T>::AssetNotApproved
1131			);
1132			let old_value = RedemptionFee::<T>::get(&internal_asset, &external_asset);
1133			RedemptionFee::<T>::insert(&internal_asset, &external_asset, fee);
1134			Self::deposit_event(Event::RedemptionFeeUpdated {
1135				internal_asset,
1136				external_asset,
1137				old_value,
1138				new_value: fee,
1139			});
1140			Ok(())
1141		}
1142
1143		/// Set the PSM debt ceiling per internal asset, shared across all approved external
1144		/// assets.
1145		///
1146		/// ## Dispatch Origin
1147		///
1148		/// Must match the PSM instance's `full_admin`; only the `Full` privilege level may use
1149		/// this call.
1150		///
1151		/// ## Parameters
1152		///
1153		/// - `internal_asset`: The PSM instance to configure.
1154		/// - `value`: The new absolute debt ceiling, in internal-asset units.
1155		///
1156		/// ## Errors
1157		///
1158		/// - [`Error::InsufficientPrivilege`]: If the origin level cannot set the debt ceiling.
1159		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
1160		///
1161		/// ## Events
1162		///
1163		/// - [`Event::MaxDebtUpdated`]: Emitted with old and new values.
1164		#[pallet::call_index(6)]
1165		#[pallet::weight(T::WeightInfo::set_max_debt())]
1166		pub fn set_max_debt(
1167			origin: OriginFor<T>,
1168			internal_asset: T::AssetId,
1169			value: BalanceOf<T>,
1170		) -> DispatchResult {
1171			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_max_debt())?;
1172			// `max_debt` only gates minting: lowering it below outstanding debt does not claw
1173			// anything back, it just pauses minting until redemptions bring the debt back under
1174			// the new ceiling.
1175			let old_value =
1176				Psm::<T>::try_mutate(&internal_asset, |maybe| -> Result<_, DispatchError> {
1177					let info = maybe.as_mut().ok_or(Error::<T>::PsmNotFound)?;
1178					Ok(core::mem::replace(&mut info.max_debt, value))
1179				})?;
1180			Self::deposit_event(Event::MaxDebtUpdated {
1181				internal_asset,
1182				old_value,
1183				new_value: value,
1184			});
1185			Ok(())
1186		}
1187
1188		/// Set the circuit breaker per external asset on a PSM instance.
1189		///
1190		/// ## Dispatch Origin
1191		///
1192		/// Must match the PSM instance's `full_admin` or `emergency_admin`; either the
1193		/// `Full` or `Emergency` privilege level may use this call.
1194		///
1195		/// ## Parameters
1196		///
1197		/// - `internal_asset`: The PSM instance to configure.
1198		/// - `external_asset`: The external asset whose status is being updated.
1199		/// - `status`: The new circuit breaker level for that external.
1200		///
1201		/// ## Errors
1202		///
1203		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on `internal_asset`.
1204		///
1205		/// ## Events
1206		///
1207		/// - [`Event::AssetStatusUpdated`]: Emitted on a successful update.
1208		#[pallet::call_index(7)]
1209		#[pallet::weight(T::WeightInfo::set_asset_status())]
1210		pub fn set_asset_status(
1211			origin: OriginFor<T>,
1212			internal_asset: T::AssetId,
1213			external_asset: T::AssetId,
1214			status: CircuitBreakerLevel,
1215		) -> DispatchResult {
1216			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_circuit_breaker())?;
1217			ExternalAssets::<T>::try_mutate(
1218				&internal_asset,
1219				&external_asset,
1220				|maybe| -> DispatchResult {
1221					let info = maybe.as_mut().ok_or(Error::<T>::AssetNotApproved)?;
1222					info.status = status;
1223					Ok(())
1224				},
1225			)?;
1226			Self::deposit_event(Event::AssetStatusUpdated {
1227				internal_asset,
1228				external_asset,
1229				status,
1230			});
1231			Ok(())
1232		}
1233
1234		/// Set the ceiling weight per external asset on a PSM instance.
1235		///
1236		/// Weights are normalised against the sum of weights within the same instance:
1237		/// `max_asset_debt = (weight / sum_of_weights) * info.max_debt`.
1238		///
1239		/// ## Dispatch Origin
1240		///
1241		/// Must match the PSM instance's `full_admin`; only the `Full` privilege level may use
1242		/// this call.
1243		///
1244		/// ## Parameters
1245		///
1246		/// - `internal_asset`: The PSM instance to configure.
1247		/// - `external_asset`: The external asset whose ceiling weight is being updated.
1248		/// - `weight`: The new ceiling weight. Zero disables minting for this external.
1249		///
1250		/// ## Errors
1251		///
1252		/// - [`Error::InsufficientPrivilege`]: If the origin level cannot set ceiling weights.
1253		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on `internal_asset`.
1254		///
1255		/// ## Events
1256		///
1257		/// - [`Event::AssetCeilingWeightUpdated`]: Emitted with old and new values.
1258		#[pallet::call_index(8)]
1259		#[pallet::weight(T::WeightInfo::set_asset_ceiling_weight())]
1260		pub fn set_asset_ceiling_weight(
1261			origin: OriginFor<T>,
1262			internal_asset: T::AssetId,
1263			external_asset: T::AssetId,
1264			weight: Permill,
1265		) -> DispatchResult {
1266			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_asset_ceiling())?;
1267			ensure!(
1268				ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1269				Error::<T>::AssetNotApproved
1270			);
1271			// Reweighting renormalises every external's ceiling; an external left below its new
1272			// ceiling simply can't be minted until redemptions bring its debt back down.
1273			let old_value = AssetCeilingWeight::<T>::get(&internal_asset, &external_asset);
1274			AssetCeilingWeight::<T>::insert(&internal_asset, &external_asset, weight);
1275			Self::deposit_event(Event::AssetCeilingWeightUpdated {
1276				internal_asset,
1277				external_asset,
1278				old_value,
1279				new_value: weight,
1280			});
1281			Ok(())
1282		}
1283
1284		/// Approve an external asset for a given internal asset.
1285		///
1286		/// Snapshots the external asset's live decimals at registration time and
1287		/// increments [`PsmInfo::external_count`].
1288		///
1289		/// ## Dispatch Origin
1290		///
1291		/// Must match the PSM instance's `full_admin` (the `Full` privilege level).
1292		///
1293		/// ## Parameters
1294		///
1295		/// - `internal_asset`: The PSM instance to approve the external on.
1296		/// - `external_asset`: The external asset to approve.
1297		///
1298		/// ## Errors
1299		///
1300		/// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges.
1301		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
1302		/// - [`Error::TooManyAssets`]: If the PSM is already at [`Config::MaxExternals`].
1303		/// - [`Error::AssetAlreadyApproved`]: If `external_asset` is already approved on this PSM.
1304		/// - [`Error::AssetDoesNotExist`]: If `external_asset` does not exist in the underlying
1305		///   fungibles backend.
1306		/// - [`Error::DecimalsMismatch`]: If the internal asset's live decimals diverged from the
1307		///   snapshot in [`PsmInfo`].
1308		/// - [`Error::DecimalsRangeExceeded`]: If `|asset_decimals − internal_decimals|` exceeds
1309		///   [`MAX_DECIMALS_DIFF`].
1310		///
1311		/// ## Events
1312		///
1313		/// - [`Event::ExternalAssetAdded`]: Emitted on a successful approval.
1314		#[pallet::call_index(9)]
1315		#[pallet::weight(T::WeightInfo::add_external_asset())]
1316		pub fn add_external_asset(
1317			origin: OriginFor<T>,
1318			internal_asset: T::AssetId,
1319			external_asset: T::AssetId,
1320		) -> DispatchResult {
1321			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_assets())?;
1322			let mut info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1323			ensure!(
1324				!ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1325				Error::<T>::AssetAlreadyApproved
1326			);
1327			ensure!(info.external_count < T::MaxExternals::get(), Error::<T>::TooManyAssets);
1328			ensure!(
1329				T::Fungibles::asset_exists(external_asset.clone()),
1330				Error::<T>::AssetDoesNotExist
1331			);
1332
1333			let asset_decimals = T::Fungibles::decimals(external_asset.clone());
1334			ensure!(
1335				T::Fungibles::decimals(internal_asset.clone()) == info.internal_decimals,
1336				Error::<T>::DecimalsMismatch
1337			);
1338			ensure!(
1339				(asset_decimals.abs_diff(info.internal_decimals) as u32) <= MAX_DECIMALS_DIFF,
1340				Error::<T>::DecimalsRangeExceeded
1341			);
1342
1343			ExternalAssets::<T>::insert(
1344				&internal_asset,
1345				&external_asset,
1346				ExternalAssetInfo {
1347					status: CircuitBreakerLevel::AllEnabled,
1348					decimals: asset_decimals,
1349				},
1350			);
1351			info.external_count = info.external_count.saturating_add(1);
1352			Psm::<T>::insert(&internal_asset, info);
1353
1354			Self::deposit_event(Event::ExternalAssetAdded { internal_asset, external_asset });
1355			Ok(())
1356		}
1357
1358		/// Remove an external asset from a PSM instance.
1359		///
1360		/// Wipes the external's per-instance state (status, decimals, fees, ceiling
1361		/// weight, debt counter) and decrements [`PsmInfo::external_count`]. The
1362		/// external must have zero outstanding debt on this instance.
1363		///
1364		/// ## Dispatch Origin
1365		///
1366		/// Must match the PSM instance's `full_admin` (the `Full` privilege level).
1367		///
1368		/// ## Parameters
1369		///
1370		/// - `internal_asset`: The PSM instance to remove the external from.
1371		/// - `external_asset`: The external asset to remove.
1372		///
1373		/// ## Errors
1374		///
1375		/// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges.
1376		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
1377		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on this PSM.
1378		/// - [`Error::AssetHasDebt`]: If the external still has non-zero outstanding debt.
1379		///
1380		/// ## Events
1381		///
1382		/// - [`Event::ExternalAssetRemoved`]: Emitted on a successful removal.
1383		#[pallet::call_index(10)]
1384		#[pallet::weight(T::WeightInfo::remove_external_asset())]
1385		pub fn remove_external_asset(
1386			origin: OriginFor<T>,
1387			internal_asset: T::AssetId,
1388			external_asset: T::AssetId,
1389		) -> DispatchResult {
1390			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_assets())?;
1391			let mut info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1392			ensure!(
1393				ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1394				Error::<T>::AssetNotApproved
1395			);
1396			ensure!(
1397				PsmDebt::<T>::get(&internal_asset, &external_asset).is_zero(),
1398				Error::<T>::AssetHasDebt
1399			);
1400			ExternalAssets::<T>::remove(&internal_asset, &external_asset);
1401			MintingFee::<T>::remove(&internal_asset, &external_asset);
1402			RedemptionFee::<T>::remove(&internal_asset, &external_asset);
1403			AssetCeilingWeight::<T>::remove(&internal_asset, &external_asset);
1404			PsmDebt::<T>::remove(&internal_asset, &external_asset);
1405			info.external_count = info.external_count.saturating_sub(1);
1406			Psm::<T>::insert(&internal_asset, info);
1407
1408			Self::deposit_event(Event::ExternalAssetRemoved { internal_asset, external_asset });
1409			Ok(())
1410		}
1411
1412		/// Reassign the PSM's `full_admin`. Callable by the current `full_admin`.
1413		///
1414		/// ## Dispatch Origin
1415		///
1416		/// Must match the PSM's current `full_admin`.
1417		///
1418		/// ## Parameters
1419		///
1420		/// - `internal_asset`: The PSM whose `full_admin` is being changed.
1421		/// - `new_admin`: The new `full_admin` origin.
1422		///
1423		/// ## Errors
1424		///
1425		/// - [`Error::PsmNotFound`]: No PSM is registered for `internal_asset`.
1426		///
1427		/// ## Events
1428		///
1429		/// - [`Event::FullAdminChanged`].
1430		#[pallet::call_index(11)]
1431		#[pallet::weight(T::WeightInfo::set_full_admin())]
1432		pub fn set_full_admin(
1433			origin: OriginFor<T>,
1434			internal_asset: T::AssetId,
1435			new_admin: Box<T::PalletsOrigin>,
1436		) -> DispatchResult {
1437			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_admins())?;
1438			let new_admin = *new_admin;
1439			let old_admin = PsmAdmin::<T>::try_mutate(
1440				&internal_asset,
1441				|maybe| -> Result<T::PalletsOrigin, DispatchError> {
1442					let admin = maybe.as_mut().ok_or(Error::<T>::PsmNotFound)?;
1443					let old = core::mem::replace(&mut admin.full_admin, new_admin.clone());
1444					Ok(old)
1445				},
1446			)?;
1447			Self::deposit_event(Event::FullAdminChanged {
1448				internal_asset,
1449				old_admin: Box::new(old_admin),
1450				new_admin: Box::new(new_admin),
1451			});
1452			Ok(())
1453		}
1454
1455		/// Reassign the PSM's `emergency_admin`. Callable by the current `full_admin`.
1456		///
1457		/// ## Dispatch Origin
1458		///
1459		/// Must match the PSM's current `full_admin`.
1460		///
1461		/// ## Parameters
1462		///
1463		/// - `internal_asset`: The PSM whose `emergency_admin` is being changed.
1464		/// - `new_admin`: The new `emergency_admin` origin.
1465		///
1466		/// ## Errors
1467		///
1468		/// - [`Error::PsmNotFound`]: No PSM is registered for `internal_asset`.
1469		///
1470		/// ## Events
1471		///
1472		/// - [`Event::EmergencyAdminChanged`].
1473		#[pallet::call_index(12)]
1474		#[pallet::weight(T::WeightInfo::set_emergency_admin())]
1475		pub fn set_emergency_admin(
1476			origin: OriginFor<T>,
1477			internal_asset: T::AssetId,
1478			new_admin: Box<T::PalletsOrigin>,
1479		) -> DispatchResult {
1480			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_admins())?;
1481			let new_admin = *new_admin;
1482			let old_admin = PsmAdmin::<T>::try_mutate(
1483				&internal_asset,
1484				|maybe| -> Result<T::PalletsOrigin, DispatchError> {
1485					let admin = maybe.as_mut().ok_or(Error::<T>::PsmNotFound)?;
1486					let old = core::mem::replace(&mut admin.emergency_admin, new_admin.clone());
1487					Ok(old)
1488				},
1489			)?;
1490			Self::deposit_event(Event::EmergencyAdminChanged {
1491				internal_asset,
1492				old_admin: Box::new(old_admin),
1493				new_admin: Box::new(new_admin),
1494			});
1495			Ok(())
1496		}
1497	}
1498
1499	impl<T: Config> Pallet<T> {
1500		/// Derive the reserve account for a PSM instance from the full hash of the pallet-id
1501		/// domain separator, pallet id, and internal asset.
1502		pub fn psm_account(internal_asset: &T::AssetId) -> T::AccountId {
1503			let entropy = (<PalletId as TypeId>::TYPE_ID, T::PalletId::get(), internal_asset)
1504				.using_encoded(sp_io::hashing::blake2_256);
1505			T::AccountId::decode(&mut TrailingZeroInput::new(entropy.as_ref()))
1506				.expect("All byte sequences are valid `AccountId`s; qed")
1507		}
1508
1509		/// PSM debt ceiling for an instance, read from the stored [`PsmInfo`]. Returns
1510		/// zero if no PSM is installed for `internal_asset`.
1511		#[cfg(test)]
1512		pub(crate) fn max_psm_debt(internal_asset: &T::AssetId) -> BalanceOf<T> {
1513			Psm::<T>::get(internal_asset).map(|p| p.max_debt).unwrap_or_default()
1514		}
1515
1516		/// Calculate max debt for a specific external on a PSM.
1517		///
1518		/// Weights are normalised against the sum of weights within the same instance to
1519		/// fill the instance's `max_debt` ceiling. Returns zero if the external has no
1520		/// configured weight or weights sum to zero.
1521		pub(crate) fn max_asset_debt(
1522			internal_asset: &T::AssetId,
1523			external_asset: &T::AssetId,
1524			info: &PsmInfo<T>,
1525		) -> BalanceOf<T> {
1526			let asset_weight = AssetCeilingWeight::<T>::get(internal_asset, external_asset);
1527			let total_weight = Self::total_ceiling_weight(internal_asset);
1528			Self::normalised_ceiling(asset_weight, total_weight, info.max_debt)
1529		}
1530
1531		/// Sum of the configured ceiling weights across a PSM's approved externals.
1532		fn total_ceiling_weight(internal_asset: &T::AssetId) -> u32 {
1533			AssetCeilingWeight::<T>::iter_prefix(internal_asset)
1534				.map(|(_, w)| w.deconstruct())
1535				.fold(0u32, |acc, x| acc.saturating_add(x))
1536		}
1537
1538		/// A single external's normalised debt ceiling: its share of the total weight applied
1539		/// to `max_debt`. Zero if the external (or the PSM as a whole) carries no weight.
1540		fn normalised_ceiling(
1541			asset_weight: Permill,
1542			total_weight: u32,
1543			max_debt: BalanceOf<T>,
1544		) -> BalanceOf<T> {
1545			let weight = asset_weight.deconstruct();
1546			if weight == 0 || total_weight == 0 {
1547				return BalanceOf::<T>::zero();
1548			}
1549			Perbill::from_rational(weight, total_weight).mul_floor(max_debt)
1550		}
1551
1552		/// Total internal-asset debt minted through a PSM instance.
1553		pub(crate) fn total_psm_debt(internal_asset: &T::AssetId) -> BalanceOf<T> {
1554			PsmDebt::<T>::iter_prefix_values(internal_asset)
1555				.fold(BalanceOf::<T>::zero(), |acc, debt| acc.saturating_add(debt))
1556		}
1557
1558		/// Whether an external is approved on a PSM instance.
1559		#[cfg(test)]
1560		pub(crate) fn is_approved_asset(
1561			internal_asset: &T::AssetId,
1562			external_asset: &T::AssetId,
1563		) -> bool {
1564			ExternalAssets::<T>::contains_key(internal_asset, external_asset)
1565		}
1566
1567		/// Balance of an external held by a PSM instance's reserve account.
1568		pub(crate) fn get_reserve(
1569			internal_asset: &T::AssetId,
1570			external_asset: &T::AssetId,
1571		) -> BalanceOf<T> {
1572			T::Fungibles::balance(external_asset.clone(), &Self::psm_account(internal_asset))
1573		}
1574
1575		/// Convert an amount denominated in external-asset units into internal units.
1576		///
1577		/// Scales by `10^(ext_decimals - internal_decimals)` — multiplies up when internal has more
1578		/// decimals, floor-divides when it has fewer. Returns [`Error::ConversionOverflow`] if
1579		/// the scaling factor or the product does not fit in the balance type.
1580		pub(crate) fn external_to_internal(
1581			amount: BalanceOf<T>,
1582			ext_decimals: u8,
1583			internal_decimals: u8,
1584		) -> Result<BalanceOf<T>, Error<T>> {
1585			use core::cmp::Ordering::*;
1586			match ext_decimals.cmp(&internal_decimals) {
1587				Equal => Ok(amount),
1588				Less => {
1589					let diff = (internal_decimals - ext_decimals) as u32;
1590					let factor = Self::pow10(diff)?;
1591					amount.checked_mul(&factor).ok_or(Error::<T>::ConversionOverflow)
1592				},
1593				Greater => {
1594					let diff = (ext_decimals - internal_decimals) as u32;
1595					let factor = Self::pow10(diff)?;
1596					Ok(amount.checked_div(&factor).unwrap_or_else(BalanceOf::<T>::zero))
1597				},
1598			}
1599		}
1600
1601		/// Convert an amount denominated in internal units into external-asset units.
1602		///
1603		/// Inverse of [`Self::external_to_internal`]. Floor-divides when internal has more
1604		/// decimals, multiplies up when it has fewer.
1605		pub(crate) fn internal_to_external(
1606			amount: BalanceOf<T>,
1607			ext_decimals: u8,
1608			internal_decimals: u8,
1609		) -> Result<BalanceOf<T>, Error<T>> {
1610			use core::cmp::Ordering::*;
1611			match ext_decimals.cmp(&internal_decimals) {
1612				Equal => Ok(amount),
1613				Less => {
1614					let diff = (internal_decimals - ext_decimals) as u32;
1615					let factor = Self::pow10(diff)?;
1616					Ok(amount.checked_div(&factor).unwrap_or_else(BalanceOf::<T>::zero))
1617				},
1618				Greater => {
1619					let diff = (ext_decimals - internal_decimals) as u32;
1620					let factor = Self::pow10(diff)?;
1621					amount.checked_mul(&factor).ok_or(Error::<T>::ConversionOverflow)
1622				},
1623			}
1624		}
1625
1626		/// Compute `10^exp` as a [`BalanceOf`]. Returns [`Error::ConversionOverflow`] if the result
1627		/// does not fit in `u128` or in `BalanceOf<T>`.
1628		fn pow10(exp: u32) -> Result<BalanceOf<T>, Error<T>> {
1629			let factor_u128 = 10u128.checked_pow(exp).ok_or(Error::<T>::ConversionOverflow)?;
1630			factor_u128.try_into().map_err(|_| Error::<T>::ConversionOverflow)
1631		}
1632
1633		/// Verify the live decimals for an external still match the snapshot taken at
1634		/// registration on this PSM, and that the internal asset's live decimals still
1635		/// match the snapshot stored in [`PsmInfo`].
1636		pub(crate) fn ensure_decimals_match(
1637			info: &PsmInfo<T>,
1638			internal_asset: &T::AssetId,
1639			external_asset: &T::AssetId,
1640			external: &ExternalAssetInfo,
1641		) -> Result<(u8, u8), DispatchError> {
1642			ensure!(
1643				T::Fungibles::decimals(external_asset.clone()) == external.decimals,
1644				Error::<T>::DecimalsMismatch
1645			);
1646			ensure!(
1647				T::Fungibles::decimals(internal_asset.clone()) == info.internal_decimals,
1648				Error::<T>::DecimalsMismatch
1649			);
1650			Ok((external.decimals, info.internal_decimals))
1651		}
1652
1653		/// Authorise an operation on the PSM keyed by `internal_asset`.
1654		///
1655		/// Matches the incoming origin's caller against the PSM's stored
1656		/// [`PsmAdminInfo::full_admin`] (yielding `Full`) or [`PsmAdminInfo::emergency_admin`]
1657		/// (yielding `Emergency`). The resolved level is then checked against `required`. No
1658		/// other authority can manage a PSM.
1659		pub(crate) fn ensure_psm_admin(
1660			origin: OriginFor<T>,
1661			internal_asset: &T::AssetId,
1662			required: impl Fn(PsmManagerLevel) -> bool,
1663		) -> DispatchResult {
1664			let admin = PsmAdmin::<T>::get(internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1665			let caller = <T as Config>::RuntimeOrigin::from(origin).into_caller();
1666			let level = if caller == admin.full_admin {
1667				PsmManagerLevel::Full
1668			} else if caller == admin.emergency_admin {
1669				PsmManagerLevel::Emergency
1670			} else {
1671				return Err(DispatchError::BadOrigin);
1672			};
1673			ensure!(required(level), Error::<T>::InsufficientPrivilege);
1674			Ok(())
1675		}
1676
1677		#[cfg(any(feature = "try-runtime", test))]
1678		pub(crate) fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> {
1679			use sp_runtime::traits::CheckedAdd;
1680
1681			for (internal_asset, info) in Psm::<T>::iter() {
1682				// 0. Every PSM has its paired admin record.
1683				ensure!(
1684					PsmAdmin::<T>::contains_key(&internal_asset),
1685					"PSM instance without a paired PsmAdmin record"
1686				);
1687
1688				let mut counted = 0u32;
1689				for (external_asset, external) in ExternalAssets::<T>::iter_prefix(&internal_asset)
1690				{
1691					counted = counted.saturating_add(1);
1692
1693					// 1. Per-external reserve covers tracked debt.
1694					let debt = PsmDebt::<T>::get(&internal_asset, &external_asset);
1695					let reserve = Self::get_reserve(&internal_asset, &external_asset);
1696					let debt_as_external =
1697						Self::internal_to_external(debt, external.decimals, info.internal_decimals)
1698							.map_err(|_| "Failed to convert tracked debt to external units")?;
1699					ensure!(
1700						reserve >= debt_as_external,
1701						"PSM reserve is less than tracked debt for an asset"
1702					);
1703				}
1704
1705				// 2. Cached `external_count` matches the iterated externals.
1706				ensure!(
1707					info.external_count == counted,
1708					"PsmInfo.external_count does not match the approved externals"
1709				);
1710
1711				// 3. Sum of per-asset debts equals the aggregate helper.
1712				let mut sum = BalanceOf::<T>::zero();
1713				for (_, debt) in PsmDebt::<T>::iter_prefix(&internal_asset) {
1714					sum = sum.checked_add(&debt).ok_or("PSM debt overflow when summing")?;
1715				}
1716				ensure!(
1717					sum == Self::total_psm_debt(&internal_asset),
1718					"sum of per-asset debts disagrees with total_psm_debt"
1719				);
1720			}
1721
1722			// 5. No orphaned per-asset state outside registered PSMs.
1723			for (internal_asset, _, _) in ExternalAssets::<T>::iter() {
1724				ensure!(
1725					Psm::<T>::contains_key(&internal_asset),
1726					"Orphaned ExternalAssets row without parent PSM"
1727				);
1728			}
1729			for (internal_asset, _, _) in PsmDebt::<T>::iter() {
1730				ensure!(
1731					Psm::<T>::contains_key(&internal_asset),
1732					"Orphaned PsmDebt row without parent PSM"
1733				);
1734			}
1735			for (internal_asset, _) in PsmAdmin::<T>::iter() {
1736				ensure!(
1737					Psm::<T>::contains_key(&internal_asset),
1738					"Orphaned PsmAdmin row without parent PSM"
1739				);
1740			}
1741
1742			Ok(())
1743		}
1744	}
1745}