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