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 fifteen 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 fifteen 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::{DepositConsequence, Fortitude, Precision, Preservation, Provenance},
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		/// The PSM reserve account cannot receive the external asset.
684		ReserveCannotReceive,
685	}
686
687	#[pallet::call]
688	impl<T: Config> Pallet<T> {
689		/// Swap external asset for internal on a specific PSM instance.
690		///
691		/// ## Dispatch Origin
692		///
693		/// Must be `Signed` by the user performing the swap.
694		///
695		/// ## Details
696		///
697		/// Transfers `external_amount` of `external_asset` from the caller to the
698		/// `internal_asset`'s PSM reserve account, then mints `internal_asset` to the
699		/// caller minus the minting fee. The fee is calculated using ceiling rounding
700		/// (`mul_ceil`), ensuring the protocol never undercharges. The fee is
701		/// transferred to [`PsmInfo::fee_destination`] of the targeted instance, and waived
702		/// if that account cannot receive it.
703		///
704		/// ## Parameters
705		///
706		/// - `internal_asset`: The internal stablecoin that identifies the PSM instance.
707		/// - `external_asset`: The external asset to deposit (must be approved on
708		///   `internal_asset`).
709		/// - `external_amount`: Amount of external asset to deposit.
710		/// - `max_fee`: Maximum minting fee rate accepted by the caller.
711		///
712		/// ## Errors
713		///
714		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
715		/// - [`Error::UnsupportedAsset`]: If `external_asset` is not approved on this PSM.
716		/// - [`Error::MintingStopped`]: If the per-external circuit breaker is at `MintingDisabled`
717		///   or higher.
718		/// - [`Error::BelowMinimumSwap`]: If the internal-equivalent of `external_amount` is below
719		///   the instance's `min_swap_amount`.
720		/// - [`Error::FeeTooHigh`]: If the configured minting fee exceeds `max_fee`.
721		/// - [`Error::ExceedsMaxPsmDebt`]: If minting would exceed this PSM's debt ceiling
722		///   (aggregate or per-asset).
723		/// - [`Error::AmountTooSmallAfterConversion`]: If the conversion to the counter-asset
724		///   rounds to zero; swap would transfer nothing.
725		///
726		/// ## Events
727		///
728		/// - [`Event::Minted`]: Emitted on successful mint.
729		#[pallet::call_index(0)]
730		#[pallet::weight(T::WeightInfo::mint(T::MaxExternals::get()))]
731		pub fn mint(
732			origin: OriginFor<T>,
733			internal_asset: T::AssetId,
734			external_asset: T::AssetId,
735			external_amount: BalanceOf<T>,
736			max_fee: Permill,
737		) -> DispatchResult {
738			let who = ensure_signed(origin)?;
739			let info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
740
741			let external = ExternalAssets::<T>::get(&internal_asset, &external_asset)
742				.ok_or(Error::<T>::UnsupportedAsset)?;
743			ensure!(external.status.allows_minting(), Error::<T>::MintingStopped);
744
745			let ext_decimals = external.decimals;
746			let internal_decimals = info.internal_decimals;
747
748			let internal_equivalent =
749				Self::external_to_internal(external_amount, ext_decimals, internal_decimals)?;
750			ensure!(!internal_equivalent.is_zero(), Error::<T>::AmountTooSmallAfterConversion);
751			ensure!(internal_equivalent >= info.min_swap_amount, Error::<T>::BelowMinimumSwap);
752
753			let effective_external =
754				Self::internal_to_external(internal_equivalent, ext_decimals, internal_decimals)?;
755
756			let fee_rate = MintingFee::<T>::get(&internal_asset, &external_asset);
757			ensure!(fee_rate <= max_fee, Error::<T>::FeeTooHigh);
758			let fee = fee_rate.mul_ceil(internal_equivalent);
759			// Waive the fee if the fee destination cannot receive it, so it never blocks swaps.
760			let fee = match T::Fungibles::can_deposit(
761				internal_asset.clone(),
762				&info.fee_destination,
763				fee,
764				Provenance::Minted,
765			) {
766				DepositConsequence::Success => fee,
767				_ => Zero::zero(),
768			};
769			let internal_to_user = internal_equivalent.saturating_sub(fee);
770
771			let current_total_psm_debt = Self::total_psm_debt(&internal_asset);
772			ensure!(
773				current_total_psm_debt.saturating_add(internal_equivalent) <= info.max_debt,
774				Error::<T>::ExceedsMaxPsmDebt
775			);
776
777			let current_debt = PsmDebt::<T>::get(&internal_asset, &external_asset);
778			let max_debt = Self::max_asset_debt(&internal_asset, &external_asset, &info);
779			let new_debt = current_debt.saturating_add(internal_equivalent);
780			ensure!(new_debt <= max_debt, Error::<T>::ExceedsMaxPsmDebt);
781
782			let psm_account = Self::psm_account(&internal_asset);
783			T::Fungibles::transfer(
784				external_asset.clone(),
785				&who,
786				&psm_account,
787				effective_external,
788				Preservation::Expendable,
789			)?;
790			T::Fungibles::mint_into(internal_asset.clone(), &who, internal_to_user)?;
791			if !fee.is_zero() {
792				T::Fungibles::mint_into(internal_asset.clone(), &info.fee_destination, fee)?;
793			}
794
795			PsmDebt::<T>::insert(&internal_asset, &external_asset, new_debt);
796
797			Self::deposit_event(Event::Minted {
798				who,
799				internal_asset,
800				external_asset,
801				external_consumed: effective_external,
802				internal_received: internal_to_user,
803				internal_fee: fee,
804			});
805			Ok(())
806		}
807
808		/// Swap internal for external asset on a specific PSM instance.
809		///
810		/// ## Dispatch Origin
811		///
812		/// Must be `Signed` by the user performing the swap.
813		///
814		/// ## Details
815		///
816		/// Burns `internal_amount` of `internal_asset` from the caller minus fee (transferred
817		/// to the instance's [`PsmInfo::fee_destination`], or waived if that account cannot
818		/// receive it), then transfers the resulting amount in `external_asset` from the PSM
819		/// reserve to the caller. The fee is
820		/// calculated using ceiling rounding (`mul_ceil`), ensuring the protocol never
821		/// undercharges. Redemptions use the decimals snapshotted when the PSM/external pair
822		/// was registered, allowing existing positions to unwind even if live metadata later
823		/// changes.
824		///
825		/// ## Parameters
826		///
827		/// - `internal_asset`: The internal stablecoin that identifies the PSM instance.
828		/// - `external_asset`: The external asset to receive (must be approved on
829		///   `internal_asset`).
830		/// - `internal_amount`: Amount of `internal_asset` to redeem.
831		/// - `max_fee`: Maximum redemption fee rate accepted by the caller.
832		///
833		/// ## Errors
834		///
835		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
836		/// - [`Error::UnsupportedAsset`]: If `external_asset` is not approved on this PSM.
837		/// - [`Error::AllSwapsStopped`]: If the per-external circuit breaker is at `AllDisabled`.
838		/// - [`Error::BelowMinimumSwap`]: If `internal_amount` is below the instance's
839		///   `min_swap_amount`.
840		/// - [`Error::FeeTooHigh`]: If the configured redemption fee exceeds `max_fee`.
841		/// - [`Error::InsufficientReserve`]: If the PSM holds less of `external_asset` than the
842		///   redemption requires.
843		/// - [`Error::AmountTooSmallAfterConversion`]: If the conversion to the counter-asset
844		///   rounds to zero; swap would transfer nothing.
845		///
846		/// ## Events
847		///
848		/// - [`Event::Redeemed`]: Emitted on successful redemption.
849		#[pallet::call_index(1)]
850		#[pallet::weight(T::WeightInfo::redeem())]
851		pub fn redeem(
852			origin: OriginFor<T>,
853			internal_asset: T::AssetId,
854			external_asset: T::AssetId,
855			internal_amount: BalanceOf<T>,
856			max_fee: Permill,
857		) -> DispatchResult {
858			let who = ensure_signed(origin)?;
859			let info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
860
861			let external = ExternalAssets::<T>::get(&internal_asset, &external_asset)
862				.ok_or(Error::<T>::UnsupportedAsset)?;
863			ensure!(external.status.allows_redemption(), Error::<T>::AllSwapsStopped);
864
865			let ext_decimals = external.decimals;
866			let internal_decimals = info.internal_decimals;
867
868			ensure!(internal_amount >= info.min_swap_amount, Error::<T>::BelowMinimumSwap);
869
870			let fee_rate = RedemptionFee::<T>::get(&internal_asset, &external_asset);
871			ensure!(fee_rate <= max_fee, Error::<T>::FeeTooHigh);
872			let fee = fee_rate.mul_ceil(internal_amount);
873			// Waive the fee if the fee destination cannot receive it, so it never blocks swaps.
874			let mut fee = match T::Fungibles::can_deposit(
875				internal_asset.clone(),
876				&info.fee_destination,
877				fee,
878				Provenance::Extant,
879			) {
880				DepositConsequence::Success => fee,
881				_ => Zero::zero(),
882			};
883			let internal_net = internal_amount.saturating_sub(fee);
884
885			let external_out =
886				Self::internal_to_external(internal_net, ext_decimals, internal_decimals)?;
887			ensure!(
888				internal_net.is_zero() || !external_out.is_zero(),
889				Error::<T>::AmountTooSmallAfterConversion
890			);
891			// `effective_internal_net` is the internal value that round-trips to `external_out`;
892			// it is what we actually burn and what the tracked debt decreases by. Any truncation
893			// dust stays in the caller's internal balance, symmetric with `mint`, which takes
894			// only the round-tripped share of the external amount.
895			let effective_internal_net =
896				Self::external_to_internal(external_out, ext_decimals, internal_decimals)?;
897
898			let current_debt = PsmDebt::<T>::get(&internal_asset, &external_asset);
899			ensure!(current_debt >= effective_internal_net, Error::<T>::InsufficientReserve);
900
901			let reserve = Self::get_reserve(&internal_asset, &external_asset);
902			if reserve < external_out {
903				defensive!("PSM reserve is less than expected output amount");
904				return Err(Error::<T>::Unexpected.into());
905			}
906
907			// `burn_from` would also burn a remainder below `min_balance`, which this pair's debt
908			// may not cover. Send it to the fee destination along with the fee instead, if the fee
909			// destination can receive it.
910			let remainder = T::Fungibles::balance(internal_asset.clone(), &who)
911				.saturating_sub(fee.saturating_add(effective_internal_net));
912
913			let with_remainder = fee.saturating_add(remainder);
914			if remainder < T::Fungibles::minimum_balance(internal_asset.clone()) &&
915				T::Fungibles::can_deposit(
916					internal_asset.clone(),
917					&info.fee_destination,
918					with_remainder,
919					Provenance::Extant,
920				) == DepositConsequence::Success
921			{
922				fee = with_remainder;
923			}
924
925			if !fee.is_zero() {
926				T::Fungibles::transfer(
927					internal_asset.clone(),
928					&who,
929					&info.fee_destination,
930					fee,
931					Preservation::Expendable,
932				)?;
933			}
934
935			let burned = if !effective_internal_net.is_zero() {
936				T::Fungibles::burn_from(
937					internal_asset.clone(),
938					&who,
939					effective_internal_net,
940					Preservation::Expendable,
941					Precision::Exact,
942					Fortitude::Polite,
943				)?
944			} else {
945				BalanceOf::<T>::zero()
946			};
947
948			let psm_account = Self::psm_account(&internal_asset);
949			if !external_out.is_zero() {
950				T::Fungibles::transfer(
951					external_asset.clone(),
952					&psm_account,
953					&who,
954					external_out,
955					Preservation::Expendable,
956				)?;
957			}
958
959			PsmDebt::<T>::mutate(&internal_asset, &external_asset, |debt| {
960				*debt = debt.saturating_sub(burned);
961			});
962
963			Self::deposit_event(Event::Redeemed {
964				who,
965				internal_asset,
966				external_asset,
967				internal_consumed: burned.saturating_add(fee),
968				external_received: external_out,
969				internal_fee: fee,
970			});
971			Ok(())
972		}
973
974		/// Create a PSM for a given internal asset.
975		///
976		/// If [`Config::CreateOrigin`] resolves to `Some(account)`, takes a
977		/// [`Config::Consideration`] deposit from that account for the instance's footprint
978		/// (refunded on `remove_psm`). If it resolves to `None`, no deposit is taken. The
979		/// `full_admin` and `emergency_admin` origins are set from the provided arguments and may
980		/// later be reassigned via [`Pallet::set_full_admin`] / [`Pallet::set_emergency_admin`].
981		///
982		/// ## Dispatch Origin
983		///
984		/// [`Config::CreateOrigin`], parameterised by `internal_asset`. With the recommended
985		/// [`EnsureAssetOwner`] this is a signed origin that owns `internal_asset`.
986		///
987		/// ## Parameters
988		///
989		/// - `internal_asset`: The internal stablecoin keying the new PSM. Must exist in the
990		///   fungibles backend; must not already have a PSM registered.
991		/// - `full_admin`: Origin granted full management of the new PSM.
992		/// - `emergency_admin`: Origin granted emergency management of the new PSM.
993		/// - `fee_destination`: Account that will receive mint/redeem fees. Fees are waived while
994		///   it cannot receive the internal asset.
995		/// - `max_debt`: Initial absolute internal-asset debt ceiling.
996		/// - `min_swap_amount`: Minimum swap amount for this instance, in internal-asset units.
997		///   Must be non-zero.
998		///
999		/// ## Errors
1000		///
1001		/// - [`DispatchError::BadOrigin`]: The origin is not permitted by [`Config::CreateOrigin`].
1002		/// - [`Error::InvalidAdminOrigin`]: Either admin is the system `None` origin.
1003		/// - [`Error::PsmAlreadyExists`]: A PSM is already registered for `internal_asset`.
1004		/// - [`Error::ZeroMinSwapAmount`]: `min_swap_amount` is zero.
1005		/// - [`Error::AssetDoesNotExist`]: The internal asset does not exist.
1006		/// - Any error from establishing the [`Config::Consideration`] deposit when one is needed
1007		///   (e.g. the account cannot afford it).
1008		///
1009		/// ## Events
1010		///
1011		/// - [`Event::PsmCreated`].
1012		#[pallet::call_index(2)]
1013		#[pallet::weight(T::WeightInfo::create_psm())]
1014		pub fn create_psm(
1015			origin: OriginFor<T>,
1016			internal_asset: T::AssetId,
1017			full_admin: Box<T::PalletsOrigin>,
1018			emergency_admin: Box<T::PalletsOrigin>,
1019			fee_destination: T::AccountId,
1020			max_debt: BalanceOf<T>,
1021			min_swap_amount: BalanceOf<T>,
1022		) -> DispatchResult {
1023			let maybe_depositor = T::CreateOrigin::ensure_origin(origin, &internal_asset)?;
1024			ensure!(
1025				!full_admin.is_none() && !emergency_admin.is_none(),
1026				Error::<T>::InvalidAdminOrigin
1027			);
1028			ensure!(!Psm::<T>::contains_key(&internal_asset), Error::<T>::PsmAlreadyExists);
1029			ensure!(!min_swap_amount.is_zero(), Error::<T>::ZeroMinSwapAmount);
1030			ensure!(
1031				T::Fungibles::asset_exists(internal_asset.clone()),
1032				Error::<T>::AssetDoesNotExist
1033			);
1034
1035			let deposit = maybe_depositor
1036				.map(|depositor| {
1037					T::Consideration::new(&depositor, Self::psm_creation_footprint())
1038						.map(|ticket| (depositor, ticket))
1039				})
1040				.transpose()?;
1041
1042			let full_admin = *full_admin;
1043			let emergency_admin = *emergency_admin;
1044			let internal_decimals = T::Fungibles::decimals(internal_asset.clone());
1045			Psm::<T>::insert(
1046				&internal_asset,
1047				PsmInfo::<T> {
1048					fee_destination: fee_destination.clone(),
1049					max_debt,
1050					min_swap_amount,
1051					internal_decimals,
1052					external_count: 0,
1053				},
1054			);
1055			PsmAdmin::<T>::insert(
1056				&internal_asset,
1057				PsmAdminInfo::<T> {
1058					full_admin: full_admin.clone(),
1059					emergency_admin: emergency_admin.clone(),
1060					deposit,
1061				},
1062			);
1063			Self::deposit_event(Event::PsmCreated {
1064				internal_asset,
1065				full_admin: Box::new(full_admin),
1066				emergency_admin: Box::new(emergency_admin),
1067				fee_destination,
1068				max_debt,
1069			});
1070			Ok(())
1071		}
1072
1073		/// Remove a PSM. Callable by the current `full_admin`. All approved externals
1074		/// must be removed first and aggregate PSM debt must be zero.
1075		///
1076		/// If a creation deposit was taken, it is always returned to the account that originally
1077		/// paid it, regardless of any later admin reassignment.
1078		///
1079		/// ## Dispatch Origin
1080		///
1081		/// Must match the PSM's `full_admin`.
1082		///
1083		/// ## Parameters
1084		///
1085		/// - `internal_asset`: The PSM instance to remove.
1086		///
1087		/// ## Errors
1088		///
1089		/// - [`Error::PsmNotFound`]: No PSM is registered for `internal_asset`.
1090		/// - [`Error::PsmHasApprovedExternals`]: Approved externals still exist.
1091		/// - [`Error::PsmHasDebt`]: Outstanding aggregate debt is non-zero.
1092		///
1093		/// ## Events
1094		///
1095		/// - [`Event::PsmRemoved`].
1096		#[pallet::call_index(3)]
1097		#[pallet::weight(T::WeightInfo::remove_psm())]
1098		pub fn remove_psm(origin: OriginFor<T>, internal_asset: T::AssetId) -> DispatchResult {
1099			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_remove_psm())?;
1100			let info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1101			ensure!(info.external_count == 0, Error::<T>::PsmHasApprovedExternals);
1102			ensure!(Self::total_psm_debt(&internal_asset).is_zero(), Error::<T>::PsmHasDebt);
1103
1104			let PsmAdminInfo { deposit, .. } =
1105				PsmAdmin::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1106			if let Some((depositor, ticket)) = deposit {
1107				ticket.drop(&depositor)?;
1108			}
1109
1110			Psm::<T>::remove(&internal_asset);
1111			PsmAdmin::<T>::remove(&internal_asset);
1112
1113			Self::deposit_event(Event::PsmRemoved { internal_asset });
1114			Ok(())
1115		}
1116
1117		/// Set the minting fee for an `(internal_asset, external_asset)` pair.
1118		///
1119		/// ## Dispatch Origin
1120		///
1121		/// Must match the PSM instance's `full_admin` (the `Full` privilege level).
1122		///
1123		/// ## Parameters
1124		///
1125		/// - `internal_asset`: The PSM instance to configure.
1126		/// - `external_asset`: The external asset whose minting fee is being updated.
1127		/// - `fee`: The new minting fee.
1128		///
1129		/// ## Errors
1130		///
1131		/// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges.
1132		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on `internal_asset`.
1133		///
1134		/// ## Events
1135		///
1136		/// - [`Event::MintingFeeUpdated`]: Emitted with old and new values.
1137		#[pallet::call_index(4)]
1138		#[pallet::weight(T::WeightInfo::set_minting_fee())]
1139		pub fn set_minting_fee(
1140			origin: OriginFor<T>,
1141			internal_asset: T::AssetId,
1142			external_asset: T::AssetId,
1143			fee: Permill,
1144		) -> DispatchResult {
1145			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_fees())?;
1146			ensure!(
1147				ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1148				Error::<T>::AssetNotApproved
1149			);
1150			let old_value = MintingFee::<T>::get(&internal_asset, &external_asset);
1151			MintingFee::<T>::insert(&internal_asset, &external_asset, fee);
1152			Self::deposit_event(Event::MintingFeeUpdated {
1153				internal_asset,
1154				external_asset,
1155				old_value,
1156				new_value: fee,
1157			});
1158			Ok(())
1159		}
1160
1161		/// Set the redemption fee for an `(internal_asset, external_asset)` pair.
1162		///
1163		/// ## Dispatch Origin
1164		///
1165		/// Must match the PSM instance's `full_admin` (the `Full` privilege level).
1166		///
1167		/// ## Parameters
1168		///
1169		/// - `internal_asset`: The PSM instance to configure.
1170		/// - `external_asset`: The external asset whose redemption fee is being updated.
1171		/// - `fee`: The new redemption fee.
1172		///
1173		/// ## Errors
1174		///
1175		/// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges.
1176		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on `internal_asset`.
1177		///
1178		/// ## Events
1179		///
1180		/// - [`Event::RedemptionFeeUpdated`]: Emitted with old and new values.
1181		#[pallet::call_index(5)]
1182		#[pallet::weight(T::WeightInfo::set_redemption_fee())]
1183		pub fn set_redemption_fee(
1184			origin: OriginFor<T>,
1185			internal_asset: T::AssetId,
1186			external_asset: T::AssetId,
1187			fee: Permill,
1188		) -> DispatchResult {
1189			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_fees())?;
1190			ensure!(
1191				ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1192				Error::<T>::AssetNotApproved
1193			);
1194			let old_value = RedemptionFee::<T>::get(&internal_asset, &external_asset);
1195			RedemptionFee::<T>::insert(&internal_asset, &external_asset, fee);
1196			Self::deposit_event(Event::RedemptionFeeUpdated {
1197				internal_asset,
1198				external_asset,
1199				old_value,
1200				new_value: fee,
1201			});
1202			Ok(())
1203		}
1204
1205		/// Set the PSM debt ceiling per internal asset, shared across all approved external
1206		/// assets.
1207		///
1208		/// ## Dispatch Origin
1209		///
1210		/// Must match the PSM instance's `full_admin`; only the `Full` privilege level may use
1211		/// this call.
1212		///
1213		/// ## Parameters
1214		///
1215		/// - `internal_asset`: The PSM instance to configure.
1216		/// - `value`: The new absolute debt ceiling, in internal-asset units.
1217		///
1218		/// ## Errors
1219		///
1220		/// - [`Error::InsufficientPrivilege`]: If the origin level cannot set the debt ceiling.
1221		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
1222		///
1223		/// ## Events
1224		///
1225		/// - [`Event::MaxDebtUpdated`]: Emitted with old and new values.
1226		#[pallet::call_index(6)]
1227		#[pallet::weight(T::WeightInfo::set_max_debt())]
1228		pub fn set_max_debt(
1229			origin: OriginFor<T>,
1230			internal_asset: T::AssetId,
1231			value: BalanceOf<T>,
1232		) -> DispatchResult {
1233			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_max_debt())?;
1234			// `max_debt` only gates minting: lowering it below outstanding debt does not claw
1235			// anything back, it just pauses minting until redemptions bring the debt back under
1236			// the new ceiling.
1237			let old_value =
1238				Psm::<T>::try_mutate(&internal_asset, |maybe| -> Result<_, DispatchError> {
1239					let info = maybe.as_mut().ok_or(Error::<T>::PsmNotFound)?;
1240					Ok(core::mem::replace(&mut info.max_debt, value))
1241				})?;
1242			Self::deposit_event(Event::MaxDebtUpdated {
1243				internal_asset,
1244				old_value,
1245				new_value: value,
1246			});
1247			Ok(())
1248		}
1249
1250		/// Set the circuit breaker per external asset on a PSM instance.
1251		///
1252		/// ## Dispatch Origin
1253		///
1254		/// Must match the PSM instance's `full_admin` or `emergency_admin`; either the
1255		/// `Full` or `Emergency` privilege level may use this call.
1256		///
1257		/// ## Parameters
1258		///
1259		/// - `internal_asset`: The PSM instance to configure.
1260		/// - `external_asset`: The external asset whose status is being updated.
1261		/// - `status`: The new circuit breaker level for that external.
1262		///
1263		/// ## Errors
1264		///
1265		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on `internal_asset`.
1266		///
1267		/// ## Events
1268		///
1269		/// - [`Event::AssetStatusUpdated`]: Emitted on a successful update.
1270		#[pallet::call_index(7)]
1271		#[pallet::weight(T::WeightInfo::set_asset_status())]
1272		pub fn set_asset_status(
1273			origin: OriginFor<T>,
1274			internal_asset: T::AssetId,
1275			external_asset: T::AssetId,
1276			status: CircuitBreakerLevel,
1277		) -> DispatchResult {
1278			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_circuit_breaker())?;
1279			ExternalAssets::<T>::try_mutate(
1280				&internal_asset,
1281				&external_asset,
1282				|maybe| -> DispatchResult {
1283					let info = maybe.as_mut().ok_or(Error::<T>::AssetNotApproved)?;
1284					info.status = status;
1285					Ok(())
1286				},
1287			)?;
1288			Self::deposit_event(Event::AssetStatusUpdated {
1289				internal_asset,
1290				external_asset,
1291				status,
1292			});
1293			Ok(())
1294		}
1295
1296		/// Set the ceiling weight per external asset on a PSM instance.
1297		///
1298		/// Weights are normalised against the sum of weights within the same instance:
1299		/// `max_asset_debt = (weight / sum_of_weights) * info.max_debt`.
1300		///
1301		/// ## Dispatch Origin
1302		///
1303		/// Must match the PSM instance's `full_admin`; only the `Full` privilege level may use
1304		/// this call.
1305		///
1306		/// ## Parameters
1307		///
1308		/// - `internal_asset`: The PSM instance to configure.
1309		/// - `external_asset`: The external asset whose ceiling weight is being updated.
1310		/// - `weight`: The new ceiling weight. Zero disables minting for this external.
1311		///
1312		/// ## Errors
1313		///
1314		/// - [`Error::InsufficientPrivilege`]: If the origin level cannot set ceiling weights.
1315		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on `internal_asset`.
1316		///
1317		/// ## Events
1318		///
1319		/// - [`Event::AssetCeilingWeightUpdated`]: Emitted with old and new values.
1320		#[pallet::call_index(8)]
1321		#[pallet::weight(T::WeightInfo::set_asset_ceiling_weight())]
1322		pub fn set_asset_ceiling_weight(
1323			origin: OriginFor<T>,
1324			internal_asset: T::AssetId,
1325			external_asset: T::AssetId,
1326			weight: Permill,
1327		) -> DispatchResult {
1328			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_asset_ceiling())?;
1329			ensure!(
1330				ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1331				Error::<T>::AssetNotApproved
1332			);
1333			// Reweighting renormalises every external's ceiling; an external left below its new
1334			// ceiling simply can't be minted until redemptions bring its debt back down.
1335			let old_value = AssetCeilingWeight::<T>::get(&internal_asset, &external_asset);
1336			AssetCeilingWeight::<T>::insert(&internal_asset, &external_asset, weight);
1337			Self::deposit_event(Event::AssetCeilingWeightUpdated {
1338				internal_asset,
1339				external_asset,
1340				old_value,
1341				new_value: weight,
1342			});
1343			Ok(())
1344		}
1345
1346		/// Approve an external asset for a given internal asset.
1347		///
1348		/// Snapshots the external asset's live decimals at registration time and
1349		/// increments [`PsmInfo::external_count`].
1350		///
1351		/// The reserve account ([`Pallet::reserve_account`]) must already be able to receive
1352		/// `external_asset`. For a non-sufficient asset, create its asset account first (e.g. via
1353		/// `touch_other`).
1354		///
1355		/// ## Dispatch Origin
1356		///
1357		/// Must match the PSM instance's `full_admin` (the `Full` privilege level).
1358		///
1359		/// ## Parameters
1360		///
1361		/// - `internal_asset`: The PSM instance to approve the external on.
1362		/// - `external_asset`: The external asset to approve.
1363		///
1364		/// ## Errors
1365		///
1366		/// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges.
1367		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
1368		/// - [`Error::TooManyAssets`]: If the PSM is already at [`Config::MaxExternals`].
1369		/// - [`Error::AssetAlreadyApproved`]: If `external_asset` is already approved on this PSM.
1370		/// - [`Error::AssetDoesNotExist`]: If `external_asset` does not exist in the underlying
1371		///   fungibles backend.
1372		/// - [`Error::DecimalsRangeExceeded`]: If `|asset_decimals − internal_decimals|` exceeds
1373		///   [`MAX_DECIMALS_DIFF`].
1374		/// - [`Error::ReserveCannotReceive`]: If the reserve account cannot receive
1375		///   `external_asset`.
1376		///
1377		/// ## Events
1378		///
1379		/// - [`Event::ExternalAssetAdded`]: Emitted on a successful approval.
1380		#[pallet::call_index(9)]
1381		#[pallet::weight(T::WeightInfo::add_external_asset())]
1382		pub fn add_external_asset(
1383			origin: OriginFor<T>,
1384			internal_asset: T::AssetId,
1385			external_asset: T::AssetId,
1386		) -> DispatchResult {
1387			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_assets())?;
1388			let mut info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1389			ensure!(
1390				!ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1391				Error::<T>::AssetAlreadyApproved
1392			);
1393			ensure!(info.external_count < T::MaxExternals::get(), Error::<T>::TooManyAssets);
1394			ensure!(
1395				T::Fungibles::asset_exists(external_asset.clone()),
1396				Error::<T>::AssetDoesNotExist
1397			);
1398
1399			let asset_decimals = T::Fungibles::decimals(external_asset.clone());
1400			ensure!(
1401				(asset_decimals.abs_diff(info.internal_decimals) as u32) <= MAX_DECIMALS_DIFF,
1402				Error::<T>::DecimalsRangeExceeded
1403			);
1404
1405			ensure!(
1406				T::Fungibles::can_deposit(
1407					external_asset.clone(),
1408					&Self::psm_account(&internal_asset),
1409					T::Fungibles::minimum_balance(external_asset.clone()),
1410					Provenance::Extant,
1411				) == DepositConsequence::Success,
1412				Error::<T>::ReserveCannotReceive
1413			);
1414
1415			ExternalAssets::<T>::insert(
1416				&internal_asset,
1417				&external_asset,
1418				ExternalAssetInfo {
1419					status: CircuitBreakerLevel::AllEnabled,
1420					decimals: asset_decimals,
1421				},
1422			);
1423			info.external_count = info.external_count.saturating_add(1);
1424			Psm::<T>::insert(&internal_asset, info);
1425
1426			Self::deposit_event(Event::ExternalAssetAdded { internal_asset, external_asset });
1427			Ok(())
1428		}
1429
1430		/// Remove an external asset from a PSM instance.
1431		///
1432		/// Wipes the external's per-instance state (status, decimals, fees, ceiling
1433		/// weight, debt counter) and decrements [`PsmInfo::external_count`]. The
1434		/// external must have zero outstanding debt on this instance.
1435		///
1436		/// ## Dispatch Origin
1437		///
1438		/// Must match the PSM instance's `full_admin` (the `Full` privilege level).
1439		///
1440		/// ## Parameters
1441		///
1442		/// - `internal_asset`: The PSM instance to remove the external from.
1443		/// - `external_asset`: The external asset to remove.
1444		///
1445		/// ## Errors
1446		///
1447		/// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges.
1448		/// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`.
1449		/// - [`Error::AssetNotApproved`]: If `external_asset` is not approved on this PSM.
1450		/// - [`Error::AssetHasDebt`]: If the external still has non-zero outstanding debt.
1451		///
1452		/// ## Events
1453		///
1454		/// - [`Event::ExternalAssetRemoved`]: Emitted on a successful removal.
1455		#[pallet::call_index(10)]
1456		#[pallet::weight(T::WeightInfo::remove_external_asset())]
1457		pub fn remove_external_asset(
1458			origin: OriginFor<T>,
1459			internal_asset: T::AssetId,
1460			external_asset: T::AssetId,
1461		) -> DispatchResult {
1462			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_assets())?;
1463			let mut info = Psm::<T>::get(&internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1464			ensure!(
1465				ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1466				Error::<T>::AssetNotApproved
1467			);
1468			ensure!(
1469				PsmDebt::<T>::get(&internal_asset, &external_asset).is_zero(),
1470				Error::<T>::AssetHasDebt
1471			);
1472			ExternalAssets::<T>::remove(&internal_asset, &external_asset);
1473			MintingFee::<T>::remove(&internal_asset, &external_asset);
1474			RedemptionFee::<T>::remove(&internal_asset, &external_asset);
1475			AssetCeilingWeight::<T>::remove(&internal_asset, &external_asset);
1476			PsmDebt::<T>::remove(&internal_asset, &external_asset);
1477			info.external_count = info.external_count.saturating_sub(1);
1478			Psm::<T>::insert(&internal_asset, info);
1479
1480			Self::deposit_event(Event::ExternalAssetRemoved { internal_asset, external_asset });
1481			Ok(())
1482		}
1483
1484		/// Reassign the PSM's `full_admin`. Callable by the current `full_admin`.
1485		///
1486		/// ## Dispatch Origin
1487		///
1488		/// Must match the PSM's current `full_admin`.
1489		///
1490		/// ## Parameters
1491		///
1492		/// - `internal_asset`: The PSM whose `full_admin` is being changed.
1493		/// - `new_admin`: The new `full_admin` origin.
1494		///
1495		/// ## Errors
1496		///
1497		/// - [`Error::PsmNotFound`]: No PSM is registered for `internal_asset`.
1498		/// - [`Error::InvalidAdminOrigin`]: `new_admin` is the system `None` origin.
1499		///
1500		/// ## Events
1501		///
1502		/// - [`Event::FullAdminChanged`].
1503		#[pallet::call_index(11)]
1504		#[pallet::weight(T::WeightInfo::set_full_admin())]
1505		pub fn set_full_admin(
1506			origin: OriginFor<T>,
1507			internal_asset: T::AssetId,
1508			new_admin: Box<T::PalletsOrigin>,
1509		) -> DispatchResult {
1510			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_admins())?;
1511			ensure!(!new_admin.is_none(), Error::<T>::InvalidAdminOrigin);
1512			let new_admin = *new_admin;
1513			let old_admin = PsmAdmin::<T>::try_mutate(
1514				&internal_asset,
1515				|maybe| -> Result<T::PalletsOrigin, DispatchError> {
1516					let admin = maybe.as_mut().ok_or(Error::<T>::PsmNotFound)?;
1517					let old = core::mem::replace(&mut admin.full_admin, new_admin.clone());
1518					Ok(old)
1519				},
1520			)?;
1521			Self::deposit_event(Event::FullAdminChanged {
1522				internal_asset,
1523				old_admin: Box::new(old_admin),
1524				new_admin: Box::new(new_admin),
1525			});
1526			Ok(())
1527		}
1528
1529		/// Reassign the PSM's `emergency_admin`. Callable by the current `full_admin`.
1530		///
1531		/// ## Dispatch Origin
1532		///
1533		/// Must match the PSM's current `full_admin`.
1534		///
1535		/// ## Parameters
1536		///
1537		/// - `internal_asset`: The PSM whose `emergency_admin` is being changed.
1538		/// - `new_admin`: The new `emergency_admin` origin.
1539		///
1540		/// ## Errors
1541		///
1542		/// - [`Error::PsmNotFound`]: No PSM is registered for `internal_asset`.
1543		/// - [`Error::InvalidAdminOrigin`]: `new_admin` is the system `None` origin.
1544		///
1545		/// ## Events
1546		///
1547		/// - [`Event::EmergencyAdminChanged`].
1548		#[pallet::call_index(12)]
1549		#[pallet::weight(T::WeightInfo::set_emergency_admin())]
1550		pub fn set_emergency_admin(
1551			origin: OriginFor<T>,
1552			internal_asset: T::AssetId,
1553			new_admin: Box<T::PalletsOrigin>,
1554		) -> DispatchResult {
1555			Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_admins())?;
1556			ensure!(!new_admin.is_none(), Error::<T>::InvalidAdminOrigin);
1557			let new_admin = *new_admin;
1558			let old_admin = PsmAdmin::<T>::try_mutate(
1559				&internal_asset,
1560				|maybe| -> Result<T::PalletsOrigin, DispatchError> {
1561					let admin = maybe.as_mut().ok_or(Error::<T>::PsmNotFound)?;
1562					let old = core::mem::replace(&mut admin.emergency_admin, new_admin.clone());
1563					Ok(old)
1564				},
1565			)?;
1566			Self::deposit_event(Event::EmergencyAdminChanged {
1567				internal_asset,
1568				old_admin: Box::new(old_admin),
1569				new_admin: Box::new(new_admin),
1570			});
1571			Ok(())
1572		}
1573	}
1574
1575	#[pallet::view_functions]
1576	impl<T: Config> Pallet<T> {
1577		/// The reserve account of the PSM instance for `internal_asset`.
1578		pub fn reserve_account(internal_asset: T::AssetId) -> T::AccountId {
1579			Self::psm_account(&internal_asset)
1580		}
1581	}
1582
1583	impl<T: Config> Pallet<T> {
1584		/// Derive the reserve account for a PSM instance from the full hash of the pallet-id
1585		/// domain separator, pallet id, and internal asset.
1586		pub fn psm_account(internal_asset: &T::AssetId) -> T::AccountId {
1587			let entropy = (<PalletId as TypeId>::TYPE_ID, T::PalletId::get(), internal_asset)
1588				.using_encoded(sp_io::hashing::blake2_256);
1589			T::AccountId::decode(&mut TrailingZeroInput::new(entropy.as_ref()))
1590				.expect("All byte sequences are valid `AccountId`s; qed")
1591		}
1592
1593		/// The footprint of a PSM instance as written by [`Pallet::create_psm`]: the [`Psm`]
1594		/// and [`PsmAdmin`] entries, both keyed by the internal asset id.
1595		///
1596		/// Excludes per-external storage added later via [`Pallet::add_external_asset`];
1597		/// externals are bounded by [`Config::MaxExternals`] and admin-gated rather than
1598		/// deposit-backed.
1599		pub fn psm_creation_footprint() -> Footprint {
1600			Footprint::from_parts(
1601				2,
1602				T::AssetId::max_encoded_len()
1603					.saturating_mul(2)
1604					.saturating_add(PsmInfo::<T>::max_encoded_len())
1605					.saturating_add(PsmAdminInfo::<T>::max_encoded_len()),
1606			)
1607		}
1608
1609		/// PSM debt ceiling for an instance, read from the stored [`PsmInfo`]. Returns
1610		/// zero if no PSM is installed for `internal_asset`.
1611		#[cfg(test)]
1612		pub(crate) fn max_psm_debt(internal_asset: &T::AssetId) -> BalanceOf<T> {
1613			Psm::<T>::get(internal_asset).map(|p| p.max_debt).unwrap_or_default()
1614		}
1615
1616		/// Calculate max debt for a specific external on a PSM.
1617		///
1618		/// Weights are normalised against the sum of weights within the same instance to
1619		/// fill the instance's `max_debt` ceiling. Returns zero if the external has no
1620		/// configured weight or weights sum to zero.
1621		pub(crate) fn max_asset_debt(
1622			internal_asset: &T::AssetId,
1623			external_asset: &T::AssetId,
1624			info: &PsmInfo<T>,
1625		) -> BalanceOf<T> {
1626			let asset_weight = AssetCeilingWeight::<T>::get(internal_asset, external_asset);
1627			let total_weight = Self::total_ceiling_weight(internal_asset);
1628			Self::normalised_ceiling(asset_weight, total_weight, info.max_debt)
1629		}
1630
1631		/// Sum of the configured ceiling weights across a PSM's approved externals.
1632		fn total_ceiling_weight(internal_asset: &T::AssetId) -> u32 {
1633			AssetCeilingWeight::<T>::iter_prefix(internal_asset)
1634				.map(|(_, w)| w.deconstruct())
1635				.fold(0u32, |acc, x| acc.saturating_add(x))
1636		}
1637
1638		/// A single external's normalised debt ceiling: its share of the total weight applied
1639		/// to `max_debt`. Zero if the external (or the PSM as a whole) carries no weight.
1640		fn normalised_ceiling(
1641			asset_weight: Permill,
1642			total_weight: u32,
1643			max_debt: BalanceOf<T>,
1644		) -> BalanceOf<T> {
1645			let weight = asset_weight.deconstruct();
1646			if weight == 0 || total_weight == 0 {
1647				return BalanceOf::<T>::zero();
1648			}
1649			Perbill::from_rational(weight, total_weight).mul_floor(max_debt)
1650		}
1651
1652		/// Total internal-asset debt minted through a PSM instance.
1653		pub(crate) fn total_psm_debt(internal_asset: &T::AssetId) -> BalanceOf<T> {
1654			PsmDebt::<T>::iter_prefix_values(internal_asset)
1655				.fold(BalanceOf::<T>::zero(), |acc, debt| acc.saturating_add(debt))
1656		}
1657
1658		/// Whether an external is approved on a PSM instance.
1659		#[cfg(test)]
1660		pub(crate) fn is_approved_asset(
1661			internal_asset: &T::AssetId,
1662			external_asset: &T::AssetId,
1663		) -> bool {
1664			ExternalAssets::<T>::contains_key(internal_asset, external_asset)
1665		}
1666
1667		/// Balance of an external held by a PSM instance's reserve account.
1668		pub(crate) fn get_reserve(
1669			internal_asset: &T::AssetId,
1670			external_asset: &T::AssetId,
1671		) -> BalanceOf<T> {
1672			T::Fungibles::balance(external_asset.clone(), &Self::psm_account(internal_asset))
1673		}
1674
1675		/// Convert an amount denominated in external-asset units into internal units.
1676		///
1677		/// Scales by `10^(ext_decimals - internal_decimals)` — multiplies up when internal has more
1678		/// decimals, floor-divides when it has fewer. Returns [`Error::ConversionOverflow`] if
1679		/// the scaling factor or the product does not fit in the balance type.
1680		pub(crate) fn external_to_internal(
1681			amount: BalanceOf<T>,
1682			ext_decimals: u8,
1683			internal_decimals: u8,
1684		) -> Result<BalanceOf<T>, Error<T>> {
1685			use core::cmp::Ordering::*;
1686			match ext_decimals.cmp(&internal_decimals) {
1687				Equal => Ok(amount),
1688				Less => {
1689					let diff = (internal_decimals - ext_decimals) as u32;
1690					let factor = Self::pow10(diff)?;
1691					amount.checked_mul(&factor).ok_or(Error::<T>::ConversionOverflow)
1692				},
1693				Greater => {
1694					let diff = (ext_decimals - internal_decimals) as u32;
1695					let factor = Self::pow10(diff)?;
1696					Ok(amount.checked_div(&factor).unwrap_or_else(BalanceOf::<T>::zero))
1697				},
1698			}
1699		}
1700
1701		/// Convert an amount denominated in internal units into external-asset units.
1702		///
1703		/// Inverse of [`Self::external_to_internal`]. Floor-divides when internal has more
1704		/// decimals, multiplies up when it has fewer.
1705		pub(crate) fn internal_to_external(
1706			amount: BalanceOf<T>,
1707			ext_decimals: u8,
1708			internal_decimals: u8,
1709		) -> Result<BalanceOf<T>, Error<T>> {
1710			use core::cmp::Ordering::*;
1711			match ext_decimals.cmp(&internal_decimals) {
1712				Equal => Ok(amount),
1713				Less => {
1714					let diff = (internal_decimals - ext_decimals) as u32;
1715					let factor = Self::pow10(diff)?;
1716					Ok(amount.checked_div(&factor).unwrap_or_else(BalanceOf::<T>::zero))
1717				},
1718				Greater => {
1719					let diff = (ext_decimals - internal_decimals) as u32;
1720					let factor = Self::pow10(diff)?;
1721					amount.checked_mul(&factor).ok_or(Error::<T>::ConversionOverflow)
1722				},
1723			}
1724		}
1725
1726		/// Compute `10^exp` as a [`BalanceOf`]. Returns [`Error::ConversionOverflow`] if the result
1727		/// does not fit in `u128` or in `BalanceOf<T>`.
1728		fn pow10(exp: u32) -> Result<BalanceOf<T>, Error<T>> {
1729			let factor_u128 = 10u128.checked_pow(exp).ok_or(Error::<T>::ConversionOverflow)?;
1730			factor_u128.try_into().map_err(|_| Error::<T>::ConversionOverflow)
1731		}
1732
1733		/// Authorise an operation on the PSM keyed by `internal_asset`.
1734		///
1735		/// Matches the incoming origin's caller against the PSM's stored
1736		/// [`PsmAdminInfo::full_admin`] (yielding `Full`) or [`PsmAdminInfo::emergency_admin`]
1737		/// (yielding `Emergency`). The resolved level is then checked against `required`. No
1738		/// other authority can manage a PSM.
1739		///
1740		/// We reject the `None` origin to prevent the block author from controlling PSMs with an
1741		/// admin set to `None`.
1742		pub(crate) fn ensure_psm_admin(
1743			origin: OriginFor<T>,
1744			internal_asset: &T::AssetId,
1745			required: impl Fn(PsmManagerLevel) -> bool,
1746		) -> DispatchResult {
1747			let admin = PsmAdmin::<T>::get(internal_asset).ok_or(Error::<T>::PsmNotFound)?;
1748			let caller = <T as Config>::RuntimeOrigin::from(origin).into_caller();
1749			ensure!(!caller.is_none(), DispatchError::BadOrigin);
1750			let level = if caller == admin.full_admin {
1751				PsmManagerLevel::Full
1752			} else if caller == admin.emergency_admin {
1753				PsmManagerLevel::Emergency
1754			} else {
1755				return Err(DispatchError::BadOrigin);
1756			};
1757			ensure!(required(level), Error::<T>::InsufficientPrivilege);
1758			Ok(())
1759		}
1760
1761		/// Test the storage invariants of the pallet.
1762		///
1763		/// This function runs with the `try-runtime` feature, and in tests. The
1764		/// checks occur at three levels:
1765		///
1766		/// * Checks 1 to 4, 10 and 11 run for each PSM instance.
1767		/// * Checks 5 to 9 run for each approved external asset of an instance.
1768		/// * Checks 12 to 15 run once, across all of the storage.
1769		///
1770		/// Checks 2, 5, 8, 9 and 15 are advisory. They write a warning to the
1771		/// log, and they do not return an error. A permitted action can create
1772		/// each of these states: a metadata change by an asset owner for checks
1773		/// 2 and 5, a parameter change by governance for checks 8, 9 and 15.
1774		#[cfg(any(feature = "try-runtime", test))]
1775		pub(crate) fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> {
1776			use sp_runtime::traits::CheckedAdd;
1777
1778			for (internal_asset, info) in Psm::<T>::iter() {
1779				// Check 1: the instance has its paired admin record.
1780				ensure!(
1781					PsmAdmin::<T>::contains_key(&internal_asset),
1782					"PSM instance without a paired PsmAdmin record"
1783				);
1784
1785				// Check 2 (advisory): live internal decimals match the PsmInfo snapshot.
1786				// Swaps use the recorded value, so drift does not change the pallet's
1787				// arithmetic; it changes what off-chain readers of live metadata see.
1788				let live_internal_decimals = T::Fungibles::decimals(internal_asset.clone());
1789				if live_internal_decimals != info.internal_decimals {
1790					log::warn!(
1791						target: "runtime::psm",
1792						"PSM {:?}: internal asset live decimals ({}) differ from the PsmInfo \
1793						snapshot ({}); swaps keep using the snapshot",
1794						internal_asset, live_internal_decimals, info.internal_decimals,
1795					);
1796				}
1797
1798				// Check 3: the minimum swap amount of the instance is not zero.
1799				// `create_psm` refuses a zero value. A migration or a manual genesis
1800				// file can bypass that check. A zero value permits swaps of dust.
1801				ensure!(!info.min_swap_amount.is_zero(), "PSM instance has a zero min_swap_amount");
1802
1803				// Check 4: the issuance of the internal asset covers the debt of this
1804				// instance. The PSM mints the internal asset for each mint call, and
1805				// burns it for each redeem call. A lower issuance therefore has two
1806				// possible causes: something burned the asset outside of the PSM, or
1807				// the debt records are wrong.
1808				ensure!(
1809					T::Fungibles::total_issuance(internal_asset.clone()) >=
1810						Self::total_psm_debt(&internal_asset),
1811					"Total internal issuance is less than total PSM debt"
1812				);
1813
1814				let mut counted = 0u32;
1815				let mut approved_debt = BalanceOf::<T>::zero();
1816				for (external_asset, external) in ExternalAssets::<T>::iter_prefix(&internal_asset)
1817				{
1818					counted = counted.saturating_add(1);
1819					let debt = PsmDebt::<T>::get(&internal_asset, &external_asset);
1820					let reserve = Self::get_reserve(&internal_asset, &external_asset);
1821
1822					// Check 5 (advisory): live external decimals match the registration
1823					// snapshot. Same rationale as check 2: the asset owner can change
1824					// metadata at any time, and swaps keep using the snapshot.
1825					let live_external_decimals = T::Fungibles::decimals(external_asset.clone());
1826					if live_external_decimals != external.decimals {
1827						log::warn!(
1828							target: "runtime::psm",
1829							"Asset {:?}/{:?}: live decimals ({}) differ from the registration \
1830							snapshot ({}); swaps keep using the snapshot",
1831							internal_asset, external_asset,
1832							live_external_decimals, external.decimals,
1833						);
1834					}
1835
1836					// Check 6: the reserve covers tracked debt, compared in external units.
1837					let debt_as_external =
1838						Self::internal_to_external(debt, external.decimals, info.internal_decimals)
1839							.map_err(|_| "Failed to convert tracked debt to external units")?;
1840					ensure!(
1841						reserve >= debt_as_external,
1842						"PSM reserve is less than tracked debt for an asset"
1843					);
1844
1845					// Check 7: summing approved debt must not overflow.
1846					approved_debt = approved_debt
1847						.checked_add(&debt)
1848						.ok_or("PSM debt overflow when summing approved externals")?;
1849
1850					// Check 8 (advisory): the debt of one asset is above its ceiling.
1851					// Governance can set a weight or `max_debt` below the debt. One
1852					// documented procedure does this on purpose: to retire an asset,
1853					// governance disables minting and sets the weight to zero, which
1854					// drives the ceiling to zero while the debt drains. The warning
1855					// still fires in that state; it is advisory for this reason.
1856					let ceiling = Self::max_asset_debt(&internal_asset, &external_asset, &info);
1857					if debt > ceiling {
1858						log::warn!(
1859							target: "runtime::psm",
1860							"Asset {:?}/{:?} debt ({:?}) exceeds ceiling ({:?})",
1861							internal_asset, external_asset, debt, ceiling,
1862						);
1863					}
1864
1865					// Check 9 (advisory): the ceiling weight and the debt are zero,
1866					// but the reserve has a balance. A donation or an error can do
1867					// this.
1868					if AssetCeilingWeight::<T>::get(&internal_asset, &external_asset).is_zero() &&
1869						debt.is_zero() && !reserve.is_zero()
1870					{
1871						log::warn!(
1872							target: "runtime::psm",
1873							"Asset {:?}/{:?} has zero ceiling, zero debt, but non-zero reserve {:?}",
1874							internal_asset, external_asset, reserve,
1875						);
1876					}
1877				}
1878
1879				// Check 10: the cached external_count matches the approved externals.
1880				ensure!(
1881					info.external_count == counted,
1882					"PsmInfo.external_count does not match the approved externals"
1883				);
1884
1885				// Check 11: the approved-external count is within the configured bound.
1886				ensure!(
1887					counted <= T::MaxExternals::get(),
1888					"ExternalAssets count exceeds MaxExternals for a PSM"
1889				);
1890			}
1891
1892			// Check 12: no `PsmDebt` row with a value exists for a pair that the PSM
1893			// does not approve. This check replaces an earlier comparison between
1894			// `total_psm_debt` and a sum of the same rows. That comparison always
1895			// held true, because both values add the same rows.
1896			for (internal_asset, external_asset, debt) in PsmDebt::<T>::iter() {
1897				if !debt.is_zero() {
1898					ensure!(
1899						ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1900						"PsmDebt entry exists for non-approved pair"
1901					);
1902				}
1903			}
1904
1905			// Check 13: no per-instance row survives without its parent PSM.
1906			for (internal_asset, _, _) in ExternalAssets::<T>::iter() {
1907				ensure!(
1908					Psm::<T>::contains_key(&internal_asset),
1909					"Orphaned ExternalAssets row without parent PSM"
1910				);
1911			}
1912			for (internal_asset, _, _) in PsmDebt::<T>::iter() {
1913				ensure!(
1914					Psm::<T>::contains_key(&internal_asset),
1915					"Orphaned PsmDebt row without parent PSM"
1916				);
1917			}
1918			for (internal_asset, _) in PsmAdmin::<T>::iter() {
1919				ensure!(
1920					Psm::<T>::contains_key(&internal_asset),
1921					"Orphaned PsmAdmin row without parent PSM"
1922				);
1923			}
1924
1925			// Check 14: no fee row or weight row exists for a pair that the PSM
1926			// does not approve. The fee extrinsics refuse assets that are not
1927			// approved, and remove_external_asset clears all three rows. A stray
1928			// weight row also shrinks the ceilings of the approved assets, because
1929			// the ceiling formula divides by the sum of the weights.
1930			for (internal_asset, external_asset, _) in MintingFee::<T>::iter() {
1931				ensure!(
1932					ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1933					"MintingFee entry exists for non-approved pair"
1934				);
1935			}
1936			for (internal_asset, external_asset, _) in RedemptionFee::<T>::iter() {
1937				ensure!(
1938					ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1939					"RedemptionFee entry exists for non-approved pair"
1940				);
1941			}
1942			for (internal_asset, external_asset, _) in AssetCeilingWeight::<T>::iter() {
1943				ensure!(
1944					ExternalAssets::<T>::contains_key(&internal_asset, &external_asset),
1945					"AssetCeilingWeight entry exists for non-approved pair"
1946				);
1947			}
1948
1949			// Check 15 (advisory): the debt of an instance is above its ceiling.
1950			// Governance can set `max_debt` below the debt.
1951			for (internal_asset, info) in Psm::<T>::iter() {
1952				let total = Self::total_psm_debt(&internal_asset);
1953				if total > info.max_debt {
1954					log::warn!(
1955						target: "runtime::psm",
1956						"PSM {:?}: total debt ({:?}) exceeds ceiling ({:?})",
1957						internal_asset, total, info.max_debt,
1958					);
1959				}
1960			}
1961
1962			Ok(())
1963		}
1964	}
1965}