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}