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}