diff --git a/Cargo.lock b/Cargo.lock index 2c27c964476de..40bff13ebd279 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1786,7 +1786,6 @@ dependencies = [ "pallet-nfts-runtime-api", "pallet-nomination-pools", "pallet-nomination-pools-runtime-api", - "pallet-parameters", "pallet-pgas-allowance", "pallet-preimage", "pallet-proxy", @@ -19808,6 +19807,7 @@ dependencies = [ "pallet-psm", "pallet-psm-remote-tests", "sp-core 28.0.0", + "sp-runtime 31.0.1", "sp-tracing 16.0.0", "staging-xcm", "tokio", diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/Cargo.toml b/cumulus/parachains/runtimes/assets/asset-hub-westend/Cargo.toml index 714aa194b51b0..c0f7907d5141e 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/Cargo.toml +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/Cargo.toml @@ -58,7 +58,6 @@ pallet-nfts = { workspace = true } pallet-nfts-runtime-api = { workspace = true } pallet-nomination-pools = { workspace = true } pallet-nomination-pools-runtime-api = { workspace = true } -pallet-parameters = { workspace = true } pallet-pgas-allowance = { workspace = true } pallet-preimage = { workspace = true } pallet-proxy = { workspace = true } @@ -210,7 +209,6 @@ runtime-benchmarks = [ "pallet-nft-fractionalization/runtime-benchmarks", "pallet-nfts/runtime-benchmarks", "pallet-nomination-pools/runtime-benchmarks", - "pallet-parameters/runtime-benchmarks", "pallet-pgas-allowance/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", "pallet-proxy/runtime-benchmarks", @@ -296,7 +294,6 @@ try-runtime = [ "pallet-nft-fractionalization/try-runtime", "pallet-nfts/try-runtime", "pallet-nomination-pools/try-runtime", - "pallet-parameters/try-runtime", "pallet-pgas-allowance/try-runtime", "pallet-preimage/try-runtime", "pallet-proxy/try-runtime", @@ -390,7 +387,6 @@ std = [ "pallet-nfts/std", "pallet-nomination-pools-runtime-api/std", "pallet-nomination-pools/std", - "pallet-parameters/std", "pallet-pgas-allowance/std", "pallet-preimage/std", "pallet-proxy/std", diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs index 6428d0e4229d2..624388518ea5f 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs @@ -55,7 +55,6 @@ use cumulus_primitives_core::{ use frame_support::{ construct_runtime, derive_impl, dispatch::DispatchClass, - dynamic_params::{dynamic_pallet_params, dynamic_params}, genesis_builder_helper::{build_state, get_preset}, ord_parameter_types, parameter_types, traits::{ @@ -1422,17 +1421,19 @@ parameter_types! { pub MbmServiceWeight: Weight = Perbill::from_percent(80) * RuntimeBlockWeights::get().max_block; pub FastUnstakeName: &'static str = "FastUnstake"; pub PsmName: &'static str = "Psm"; + pub ParametersName: &'static str = "Parameters"; } -/// One-shot migration: writes `pallet_psm`'s on-chain storage version to v2. +/// One-shot migration: writes `pallet_psm`'s on-chain storage version to v1. /// Required because `RemovePallet` (above in the migration tuple) -/// wipes the pallet's `:__STORAGE_VERSION__:` key, and `InitializePsm` doesn't -/// re-seed it. Without this, try-runtime's post-upgrade check sees in-code = 2, -/// on-chain = 0 and panics. -pub struct SetPsmStorageVersionV2; -impl frame_support::traits::OnRuntimeUpgrade for SetPsmStorageVersionV2 { +/// wipes the pallet's `:__STORAGE_VERSION__:` key, and nothing else re-seeds it +/// (PSMs are now created on demand via the permissionless `create_psm` extrinsic). +/// Without this, try-runtime's post-upgrade check sees in-code = 1, on-chain = 0 +/// and panics. +pub struct SetPsmStorageVersionV1; +impl frame_support::traits::OnRuntimeUpgrade for SetPsmStorageVersionV1 { fn on_runtime_upgrade() -> Weight { - frame_support::traits::StorageVersion::new(2).put::>(); + frame_support::traits::StorageVersion::new(1).put::>(); ::DbWeight::get().writes(1) } @@ -1441,8 +1442,8 @@ impl frame_support::traits::OnRuntimeUpgrade for SetPsmStorageVersionV2 { use frame_support::{ensure, traits::GetStorageVersion}; ensure!( pallet_psm::Pallet::::on_chain_storage_version() == - frame_support::traits::StorageVersion::new(2), - "PSM on-chain storage version was not set to 2" + frame_support::traits::StorageVersion::new(1), + "PSM on-chain storage version was not set to 1" ); Ok(()) } @@ -1573,98 +1574,14 @@ impl pallet_verify_signature::Config for Runtime { type BenchmarkHelper = (); } -// Dynamic parameters configurable via governance. -/// One pUSD (6 decimals). -const PUSD: Balance = 1_000_000; - -#[dynamic_params(RuntimeParameters, pallet_parameters::Parameters::)] -pub mod dynamic_params { - use super::*; - - #[dynamic_pallet_params] - #[codec(index = 0)] - pub mod pusd { - /// Maximum pUSD issuance across the system (50 million pUSD) with precision 1e6. - #[codec(index = 0)] - pub static MaximumIssuance: Balance = 50_000_000 * PUSD; - } -} - -#[cfg(feature = "runtime-benchmarks")] -impl Default for RuntimeParameters { - fn default() -> Self { - use frame_support::traits::Get; - RuntimeParameters::Pusd(dynamic_params::pusd::Parameters::MaximumIssuance( - dynamic_params::pusd::MaximumIssuance, - Some(dynamic_params::pusd::MaximumIssuance::get()), - )) - } -} - -/// Origin check for dynamic parameter changes — only Root can modify. -pub struct DynamicParameterOrigin; -impl frame_support::traits::EnsureOriginWithArg - for DynamicParameterOrigin -{ - type Success = (); - fn try_origin( - origin: RuntimeOrigin, - _key: &RuntimeParametersKey, - ) -> Result { - frame_system::ensure_root(origin.clone()).map_err(|_| origin) - } - #[cfg(feature = "runtime-benchmarks")] - fn try_successful_origin(_key: &RuntimeParametersKey) -> Result { - Ok(RuntimeOrigin::root()) - } -} - -impl pallet_parameters::Config for Runtime { - type RuntimeEvent = RuntimeEvent; - type RuntimeParameters = RuntimeParameters; - type AdminOrigin = DynamicParameterOrigin; - type WeightInfo = weights::pallet_parameters::WeightInfo; -} - // PSM configuration. parameter_types! { - /// The pUSD stablecoin asset ID (trust-backed asset). - pub const PsmStablecoinAssetId: AssetIdForTrustBackedAssets = 50000342; - /// Minimum swap amount for PSM operations (1 pUSD). - pub const PsmMinSwapAmount: Balance = PUSD; + /// Minimum swap amount for PSM operations (1 unit at 6-decimal precision). + pub const PsmMinSwapAmount: Balance = 1_000_000; + /// Native-currency deposit reserved when permissionlessly creating a PSM. + pub const PsmCreationDeposit: Balance = deposit(1, 68); /// PalletId for deriving the PSM system account. pub const PsmPalletId: PalletId = PalletId(*b"py/pegsm"); - /// Fee revenue destination: pUSD insurance fund account. - pub const PsmFeeDestinationPalletId: PalletId = PalletId(*b"pusd/ins"); - pub PsmFeeDestination: AccountId = PsmFeeDestinationPalletId::get().into_account_truncating(); -} - -/// pUSD as a single-asset fungible, backed by trust-backed assets (Instance1). -type PsmInternalAsset = - frame_support::traits::fungible::ItemOf; - -/// EnsureOrigin for PSM management with privilege levels. -/// - Root gets Full privileges (all parameter changes). -/// - MonetaryGuard gets Emergency privileges (circuit breaker only). -pub struct EnsurePsmManager; -impl frame_support::traits::EnsureOrigin for EnsurePsmManager { - type Success = pallet_psm::PsmManagerLevel; - - fn try_origin(o: RuntimeOrigin) -> Result { - // Try Root first. - let o = match o.clone().into() { - Ok(frame_system::RawOrigin::Root) => return Ok(pallet_psm::PsmManagerLevel::Full), - _ => o, - }; - // Try MonetaryGuard — circuit breaker only. - pallet_custom_origins::MonetaryGuard::try_origin(o) - .map(|_| pallet_psm::PsmManagerLevel::Emergency) - } - - #[cfg(feature = "runtime-benchmarks")] - fn try_successful_origin() -> Result { - Ok(RuntimeOrigin::root()) - } } #[cfg(feature = "runtime-benchmarks")] @@ -1703,46 +1620,19 @@ impl pallet_psm::BenchmarkHelper for PsmBenchmarkH impl pallet_psm::Config for Runtime { type Fungibles = LocalAndForeignAssets; + type Currency = Balances; + type RuntimeOrigin = RuntimeOrigin; + type PalletsOrigin = OriginCaller; type AssetId = xcm::v5::Location; - type MaximumIssuance = dynamic_params::pusd::MaximumIssuance; - type ManagerOrigin = EnsurePsmManager; type WeightInfo = weights::pallet_psm::WeightInfo; - type InternalAsset = PsmInternalAsset; - type FeeDestination = PsmFeeDestination; type PalletId = PsmPalletId; type MinSwapAmount = PsmMinSwapAmount; - type MaxExternalAssets = ConstU32<3>; + type MaxExternalAssetsPerPsm = ConstU32<3>; + type CreationDeposit = PsmCreationDeposit; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = PsmBenchmarkHelper; } -/// Initial PSM configuration applied via the init migration. -/// -/// Sets up USDT (trust-backed asset `1984`, addressed by its `Location`) as the -/// first external asset. -pub struct PsmInitialConfig; -impl pallet_psm::migrations::init::InitialPsmConfig for PsmInitialConfig { - fn max_psm_debt_of_total() -> Permill { - // USDT PSM cap is 5M out of 50M total issuance = 10%. - Permill::from_percent(10) - } - fn asset_configs( - ) -> alloc::collections::btree_map::BTreeMap { - use xcm::latest::prelude::*; - let usdt_location = xcm::v5::Location::new(0, [PalletInstance(50), GeneralIndex(1984)]); - [( - usdt_location, - ( - Permill::zero(), // 0% minting fee - Permill::from_rational(1u32, 10_000u32), // 0.01% redemption fee - Permill::from_percent(100), // ceiling weight - ), - )] - .into_iter() - .collect() - } -} - // Create the runtime by composing the FRAME pallets that were previously configured. construct_runtime!( pub enum Runtime @@ -1791,7 +1681,6 @@ construct_runtime!( Indices: pallet_indices = 43, MetaTx: pallet_meta_tx = 44, VerifySignature: pallet_verify_signature = 45, - Parameters: pallet_parameters = 46, Recovery: pallet_recovery = 47, // The main stage. @@ -2006,14 +1895,20 @@ pub type Migrations = ( // start: PSM reset - // `RemovePallet` wipes ALL of PSM's storage (entries + CountedStorageMap - // counters + the storage version key). `InitializePsm` then re-seeds data - // under the new `Location` AssetId, and `SetPsmStorageVersionV2` writes - // the on-chain storage version that `RemovePallet` cleared. + // `RemovePallet` wipes the old PSM deployment (entries + storage version + // key). `SetPsmStorageVersionV1` re-seeds the storage version key that + // `RemovePallet` cleared. frame_support::migrations::RemovePallet::DbWeight>, - pallet_psm::migrations::init::InitializePsm, - SetPsmStorageVersionV2, + SetPsmStorageVersionV1, // end: PSM reset + + // `pallet_parameters` only ever hosted the now-removed system-wide PSM issuance cap + // (the per-PSM `max_debt` replaced it). Wipe its on-chain storage now that the pallet + // is gone from the runtime. + frame_support::migrations::RemovePallet< + ParametersName, + ::DbWeight, + >, pallet_dap::migrations::MigrateV1ToV2< Runtime, DapLastIssuanceTimestamp, @@ -2243,8 +2138,8 @@ mod benches { [pallet_nft_fractionalization, NftFractionalization] [pallet_nfts, Nfts] [pallet_proxy, Proxy] - [pallet_psm, Psm] [pallet_parameters, Parameters] + [pallet_psm, Psm] [pallet_recovery, Recovery] [pallet_session, SessionBench::] [pallet_staking_async, Staking] diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/mod.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/mod.rs index ad094e7e4be79..cdd1d18ccc04b 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/mod.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/mod.rs @@ -47,7 +47,6 @@ pub mod pallet_multisig; pub mod pallet_nft_fractionalization; pub mod pallet_nfts; pub mod pallet_nomination_pools; -pub mod pallet_parameters; pub mod pallet_pgas_allowance; pub mod pallet_preimage; pub mod pallet_proxy; diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_parameters.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_parameters.rs deleted file mode 100644 index 477af4e95a96c..0000000000000 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_parameters.rs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Autogenerated weights for `pallet_parameters` -//! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 -//! DATE: 2026-04-10, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` -//! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `b768023609f0`, CPU: `Intel(R) Xeon(R) CPU @ 2.60GHz` -//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: 1024 - -// Executed Command: -// frame-omni-bencher -// v1 -// benchmark -// pallet -// --extrinsic=* -// --runtime=target/production/wbuild/asset-hub-westend-runtime/asset_hub_westend_runtime.wasm -// --pallet=pallet_parameters -// --header=/__w/polkadot-sdk/polkadot-sdk/cumulus/file_header.txt -// --output=./cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights -// --wasm-execution=compiled -// --steps=50 -// --repeat=20 -// --heap-pages=4096 -// --no-storage-info -// --no-min-squares -// --no-median-slopes - -#![cfg_attr(rustfmt, rustfmt_skip)] -#![allow(unused_parens)] -#![allow(unused_imports)] -#![allow(missing_docs)] - -use frame_support::{traits::Get, weights::Weight}; -use core::marker::PhantomData; - -/// Weight functions for `pallet_parameters`. -pub struct WeightInfo(PhantomData); -impl pallet_parameters::WeightInfo for WeightInfo { - /// Storage: `Parameters::Parameters` (r:1 w:1) - /// Proof: `Parameters::Parameters` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) - fn set_parameter() -> Weight { - // Proof Size summary in bytes: - // Measured: `4` - // Estimated: `3501` - // Minimum execution time: 8_840_000 picoseconds. - Weight::from_parts(9_525_000, 0) - .saturating_add(Weight::from_parts(0, 3501)) - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(1)) - } -} diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_psm.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_psm.rs index 9ea27f0b7aff4..2d93edd0a031e 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_psm.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_psm.rs @@ -137,7 +137,7 @@ impl pallet_psm::WeightInfo for WeightInfo { } /// Storage: `Psm::MaxPsmDebtOfTotal` (r:1 w:1) /// Proof: `Psm::MaxPsmDebtOfTotal` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - fn set_max_psm_debt() -> Weight { + fn set_max_debt() -> Weight { // Proof Size summary in bytes: // Measured: `136` // Estimated: `1489` @@ -211,4 +211,29 @@ impl pallet_psm::WeightInfo for WeightInfo { .saturating_add(T::DbWeight::get().reads(3)) .saturating_add(T::DbWeight::get().writes(6)) } + // Placeholder until benchmarks run. + fn create_psm() -> Weight { + Weight::from_parts(30_000_000, 0) + .saturating_add(Weight::from_parts(0, 3501)) + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(3)) + } + fn remove_psm() -> Weight { + Weight::from_parts(25_000_000, 0) + .saturating_add(Weight::from_parts(0, 3501)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(2)) + } + fn set_full_admin() -> Weight { + Weight::from_parts(20_000_000, 0) + .saturating_add(Weight::from_parts(0, 3501)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } + fn set_emergency_admin() -> Weight { + Weight::from_parts(20_000_000, 0) + .saturating_add(Weight::from_parts(0, 3501)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) + } } diff --git a/polkadot/utils/remote-ext-tests/psm/Cargo.toml b/polkadot/utils/remote-ext-tests/psm/Cargo.toml index a76bea26e98e6..770aad3c15220 100644 --- a/polkadot/utils/remote-ext-tests/psm/Cargo.toml +++ b/polkadot/utils/remote-ext-tests/psm/Cargo.toml @@ -18,6 +18,7 @@ pallet-assets = { workspace = true, default-features = true } pallet-psm = { workspace = true, default-features = true } pallet-psm-remote-tests = { workspace = true } sp-core = { workspace = true, default-features = true } +sp-runtime = { workspace = true, default-features = true } sp-tracing = { workspace = true, default-features = true } xcm = { workspace = true } diff --git a/polkadot/utils/remote-ext-tests/psm/src/main.rs b/polkadot/utils/remote-ext-tests/psm/src/main.rs index c357ae974b37c..f4c4585f34121 100644 --- a/polkadot/utils/remote-ext-tests/psm/src/main.rs +++ b/polkadot/utils/remote-ext-tests/psm/src/main.rs @@ -37,10 +37,18 @@ struct Cli { } fn asset_hub_westend_config(asset_id: u32) -> PsmTestConfigOf { + use frame_support::PalletId; + use sp_runtime::{traits::AccountIdConversion, Permill}; use xcm::latest::prelude::*; PsmTestConfigOf:: { + internal_asset_id: Location::new(0, [PalletInstance(50), GeneralIndex(50_000_342)]), external_asset_id: Location::new(0, [PalletInstance(50), GeneralIndex(asset_id.into())]), internal_asset_decimals: 6, + fee_destination: PalletId(*b"psm/test").into_account_truncating(), + max_debt: 5_000_000 * 1_000_000, // 5M units (6 decimals) + minting_fee: Permill::zero(), + redemption_fee: Permill::from_rational(1u32, 10_000u32), + ceiling_weight: Permill::from_percent(100), assets_pallet_name: "Assets".to_string(), pre_create_hook: None, } @@ -73,10 +81,7 @@ async fn main() { ) .await; - use asset_hub_westend_runtime::PsmInitialConfig; - pallet_psm_remote_tests::mint_and_redeem::( - &mut ext, &config, - ); + pallet_psm_remote_tests::mint_and_redeem::(&mut ext, &config); // Build a fresh externalities for the circuit breaker test so it // starts from clean state (loads from the snapshot, no RPC needed). @@ -86,9 +91,7 @@ async fn main() { ) .await; - pallet_psm_remote_tests::circuit_breaker::( - &mut ext, &config, - ); + pallet_psm_remote_tests::circuit_breaker::(&mut ext, &config); }, } } diff --git a/prdoc/pr_12245.prdoc b/prdoc/pr_12245.prdoc new file mode 100644 index 0000000000000..b4c7b12215e7f --- /dev/null +++ b/prdoc/pr_12245.prdoc @@ -0,0 +1,63 @@ +# Schema: Polkadot SDK PRDoc Schema (prdoc) v1.0.0 +# See doc at https://raw.githubusercontent.com/paritytech/polkadot-sdk/master/prdoc/schema_user.json + +title: 'pallet-psm: multi-instance, permissionless Peg Stability Modules' + +doc: +- audience: Runtime Dev + description: |- + Reworks `pallet-psm` from a single, governance-seeded Peg Stability Module into a + multi-instance design where many PSMs coexist, each created permissionlessly and + managed by its own admins. + + Functional changes: + - A runtime can hold any number of PSMs at once, each keyed by its internal + (stablecoin) asset id. Each PSM can be backed by several external assets; all + per-asset state is keyed by the `(internal_asset, external_asset)` pair. + - PSMs are created permissionlessly via the new `create_psm` extrinsic, which reserves + a refundable deposit (`Config::CreationDeposit`) in the native `Currency`. The creator + becomes the PSM's admin and picks its initial parameters. `remove_psm` returns the + deposit to the original depositor. The caller must be the owner of the internal asset, + so a PSM can only be created over an asset its creator controls (the PSM mints/burns + through the privileged `fungibles` trait path, which bypasses the issuer check). The + owner keeps the asset and may run other minters, e.g. a vault, over it. + - Per-PSM authorization: each PSM stores a `full_admin` and an `emergency_admin` origin, + reassignable via `set_full_admin` / `set_emergency_admin`. This replaces the single, + global governance manager origin. + - Per-instance configuration now lives entirely in storage (the `Psm` and `PsmAdmin` + maps) rather than in `Config` or genesis. The debt ceiling is an absolute balance + rather than a fraction of a system-wide cap. + + Breaking changes for runtimes wiring `pallet-psm`: + - `Config` reworked. Removed: `InternalAsset`, `FeeDestination`, `MaximumIssuance`, and + `ManagerOrigin`. Added: `Currency: ReservableCurrency` (native token used for the + creation deposit), `PalletsOrigin`, and `CreationDeposit`. `MaxExternalAssets` is + renamed `MaxExternalAssetsPerPsm`. + - New extrinsics: `create_psm`, `remove_psm`, `set_full_admin`, `set_emergency_admin`. + `set_max_psm_debt(ratio: Permill)` is renamed `set_max_debt(value: Balance)` with + absolute semantics; the `MaxPsmDebtOfTotalUpdated` event is renamed `MaxDebtUpdated`; + `Error::ExceedsMaxIssuance` is removed and `Error::PsmNotFound` is added. + - The pallet's genesis config and the `InitializePsm` / decimals migration helpers are + removed. There is no in-place data migration: the pallet is redeployed from scratch. + A runtime upgrading an existing deployment must wipe the old storage and reset the + storage version. On Asset Hub Westend this is done with `RemovePallet` followed + by a migration that re-seeds the storage version to `1`. + - `STORAGE_VERSION` is `1`. + + The `frame-support` `PsmInterface` trait (used by consumers such as the Vaults pallet) + gains an `AssetId` associated type, and `reserved_capacity` now takes the asset whose + reserved issuance is being queried. Additionally, `fungibles::UnionOf` now implements + `fungibles::roles::Inspect` (forwarding `owner`/`issuer`/`admin`/`freezer` to the side + that owns the asset), so unions of role-aware backends expose team roles. + + Asset Hub Westend drops `pallet-parameters`: it only ever hosted a system-wide PSM + issuance cap, which the per-PSM `max_debt` ceiling replaced, so the pallet is removed + from the runtime and a `RemovePallet` migration wipes its on-chain storage. + +crates: +- name: pallet-psm + bump: major +- name: frame-support + bump: major +- name: asset-hub-westend-runtime + bump: major diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index 0a15b1003f6f6..aee902f7495fa 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -3099,44 +3099,12 @@ impl pallet_oracle::Config for Runtime { } parameter_types! { - /// The pUSD stablecoin asset ID. - pub const PsmStablecoinAssetId: u32 = 4242; /// Minimum swap amount for PSM operations (100 pUSD = 100 * 10^6). pub const PsmMinSwapAmount: Balance = 100_000_000; /// PalletId for deriving the PSM system account. pub const PsmPalletId: PalletId = PalletId(*b"py/pegsm"); - /// Insurance fund account that receives PSM fee revenue. - pub PsmInsuranceFundAccount: AccountId = - sp_runtime::traits::AccountIdConversion::::into_account_truncating( - &PalletId(*b"py/insur"), - ); -} - -type PsmInternalAsset = ItemOf; - -parameter_types! { - /// No debt ceiling: maximum possible issuance. - pub const NoVaultsCeiling: Balance = Balance::MAX; -} - -/// EnsureOrigin implementation for PSM management that supports privilege levels. -pub struct EnsurePsmManager; -impl frame_support::traits::EnsureOrigin for EnsurePsmManager { - type Success = pallet_psm::PsmManagerLevel; - - fn try_origin(o: RuntimeOrigin) -> Result { - use frame_system::RawOrigin; - - match o.clone().into() { - Ok(RawOrigin::Root) => Ok(pallet_psm::PsmManagerLevel::Full), - _ => Err(o), - } - } - - #[cfg(feature = "runtime-benchmarks")] - fn try_successful_origin() -> Result { - Ok(RuntimeOrigin::root()) - } + /// Native-currency deposit reserved when permissionlessly creating a PSM. + pub const PsmCreationDeposit: Balance = 10 * DOLLARS; } #[cfg(feature = "runtime-benchmarks")] @@ -3170,15 +3138,15 @@ impl pallet_psm::BenchmarkHelper for PsmBenchmarkHelper { /// Configure the PSM (Peg Stability Module) pallet. impl pallet_psm::Config for Runtime { type Fungibles = Assets; + type Currency = Balances; + type RuntimeOrigin = RuntimeOrigin; + type PalletsOrigin = OriginCaller; type AssetId = u32; - type MaximumIssuance = NoVaultsCeiling; - type ManagerOrigin = EnsurePsmManager; type WeightInfo = pallet_psm::weights::SubstrateWeight; - type InternalAsset = PsmInternalAsset; - type FeeDestination = PsmInsuranceFundAccount; type PalletId = PsmPalletId; type MinSwapAmount = PsmMinSwapAmount; - type MaxExternalAssets = ConstU32<10>; + type MaxExternalAssetsPerPsm = ConstU32<10>; + type CreationDeposit = PsmCreationDeposit; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = PsmBenchmarkHelper; } diff --git a/substrate/frame/psm/Cargo.toml b/substrate/frame/psm/Cargo.toml index 484dea4002254..d80322a4782e1 100644 --- a/substrate/frame/psm/Cargo.toml +++ b/substrate/frame/psm/Cargo.toml @@ -23,12 +23,12 @@ frame-support = { workspace = true } frame-system = { workspace = true } log = { workspace = true } scale-info = { features = ["derive"], workspace = true } +sp-io = { workspace = true } sp-runtime = { workspace = true } [dev-dependencies] pallet-assets = { workspace = true, default-features = true } pallet-balances = { workspace = true, default-features = true } -sp-io = { workspace = true, default-features = true } [features] default = ["std"] @@ -39,6 +39,7 @@ std = [ "frame-system/std", "log/std", "scale-info/std", + "sp-io/std", "sp-runtime/std", ] runtime-benchmarks = [ diff --git a/substrate/frame/psm/README.md b/substrate/frame/psm/README.md index b7c1b3290b54f..8fce59ba8b0cb 100644 --- a/substrate/frame/psm/README.md +++ b/substrate/frame/psm/README.md @@ -1,216 +1,240 @@ # PSM Pallet -A Peg Stability Module enabling 1:1 swaps between the runtime's internal -stablecoin and pre-approved external stablecoins on Substrate-based blockchains. +A module hosting one or more Peg Stability Modules. Each PSM enables 1:1 swaps +between a specific internal stablecoin and that PSM's pre-approved external +stablecoins on Substrate-based blockchains. ## Terminology Throughout this pallet two distinct token roles are referenced: -- **Internal** — the stablecoin issued and burned by the PSM. It is a single - asset configured via `Config::InternalAsset` (e.g. a runtime's pUSD). Mint - operations credit the user with the internal asset; redeem operations burn - it. Fees are collected in the internal asset and forwarded to - `FeeDestination`. -- **External** — third-party stablecoins (e.g. USDC, USDT) approved via - `add_external_asset` and held in reserve by the PSM. Users deposit external - to mint internal, and burn internal to redeem external. Multiple external - assets can be approved simultaneously, each identified by `asset_id`. +- **Internal** — the stablecoin a PSM issues and burns (e.g. a runtime's pUSD). + Each PSM instance is keyed by its internal asset id; multiple instances can + coexist, each with its own reserve, debt ceiling, fee destination and + approved externals. Mint operations credit the user with the internal asset; + redeem operations burn it. Fees are collected in the internal asset and + forwarded to that instance's `PsmInfo::fee_destination`. +- **External** — third-party stablecoins (e.g. USDC, USDT) approved on a + specific PSM via `add_external_asset` and held in that PSM's reserve. Users + deposit external to mint internal, and burn internal to redeem external. A + PSM may approve multiple externals, each identified by `asset_id`. ## Overview -The PSM pallet allows users to swap external stablecoins (e.g., USDC, USDT) -for the internal asset and vice versa at a 1:1 rate (minus fees). This creates -a decentralized peg stabilization mechanism where: - -- **Reserves are held**: External stablecoins are held in a pallet-derived account (`PalletId`) -- **The internal asset is minted/burned**: Users receive the internal asset when depositing external - stablecoins, and burn the internal asset when redeeming -- **Fees are routed to `FeeDestination`**: Mint and redeem fees are - collected in the internal asset and transferred to a configurable account -- **Circuit breaker provides emergency control**: Per-asset circuit breaker can disable minting or - all swaps +The PSM pallet hosts one or more PSM instances, each keyed by its internal +asset id. Each instance: + +- **Holds a per-instance reserve account** derived as + `PalletId::into_sub_account_truncating(internal_asset)`. External stablecoins + deposited by users are held there. +- **Mints and burns its own internal asset**. Users receive the internal asset + when depositing external stablecoins, and burn the internal asset when + redeeming. +- **Routes fees to the instance's `fee_destination`**. Mint and redeem fees are + collected in the internal asset and transferred to the per-instance account + recorded in `PsmInfo`. +- **Has independent per-external circuit breakers**. Each approved external on + each instance can be paused without affecting others. ## Swap Lifecycle ### 1. Mint (External → Internal) ```rust -mint(origin, asset_id, external_amount) +mint(origin, internal_asset, asset_id, external_amount) ``` -- Deposits external stablecoin into the PSM account -- Mints the internal asset to the user (minus minting fee) -- Fee is minted as the internal asset and transferred to `FeeDestination` -- Enforces three-tier debt ceiling: system-wide, aggregate PSM, and per-asset -- Requires `external_amount >= MinSwapAmount` +- Deposits `external_amount` of `asset_id` into `internal_asset`'s PSM reserve +- Mints `internal_asset` to the user (minus minting fee) +- Fee is minted as `internal_asset` and transferred to the instance's `fee_destination` +- Enforces the per-instance aggregate `max_debt` and the per-external normalised ceiling +- Requires the swap (in internal units) to be `>= MinSwapAmount` ### 2. Redeem (Internal → External) ```rust -redeem(origin, asset_id, amount) +redeem(origin, internal_asset, asset_id, amount) ``` -- Burns the internal asset from the user equal to the external amount being redeemed -- Transfers external stablecoin from PSM account to user -- Redemption fee is transferred from the user as internal asset to `FeeDestination` -- Limited by tracked PSM debt (not raw reserve balance) +- Burns `amount` of `internal_asset` from the user +- Transfers external stablecoin from the instance's reserve to the user +- Redemption fee is transferred from the user as `internal_asset` to `fee_destination` +- Limited by the per-external tracked debt (`PsmDebt`), not raw reserve balance - Requires `amount >= MinSwapAmount` -## Debt Ceiling Architecture - -Before minting, the PSM checks three ceilings in order: +## Debt Ceiling -1. **System-wide**: `total_issuance(internal_asset) + amount <= MaximumIssuance` -2. **Aggregate PSM**: `total_psm_debt + amount <= MaxPsmDebtOfTotal * MaximumIssuance` -3. **Per-asset**: `asset_debt + amount <= normalized_asset_share_of_psm_ceiling` +Each PSM instance has an absolute internal-asset debt ceiling stored on +`PsmInfo::max_debt`. Within that, per-external ceilings are derived from +ceiling weights: -### PSM Reserved Capacity +``` +max_asset_debt(internal, external) = + (AssetCeilingWeight[internal, external] / sum_of_weights[internal]) + * Psm[internal].max_debt +``` -The PSM's allocation is guaranteed via the `PsmInterface` trait. -The Vaults pallet queries `reserved_capacity()` and enforces an effective -vault ceiling of `MaximumIssuance - reserved_capacity()`, preventing vaults -from consuming PSM's share. +Setting an asset's weight to 0% disables minting for that external and +redistributes its share to the others within the same instance. -### Per-Asset Ceiling +### Reserved Capacity -Per-asset ceilings use a weight-based system: +Capacity reserved by a PSM is exposed via the `PsmInterface` trait: -``` -max_asset_debt = (AssetCeilingWeight[asset_id] / sum_of_all_weights) * max_psm_debt +```rust +fn reserved_capacity(asset: AssetId) -> Balance ``` -Setting an asset's weight to 0% disables minting and redistributes its capacity to other assets. +Returns `Psm[asset].max_debt` for the matching PSM, or zero if no PSM is +registered for that internal asset. Consumers (e.g. the Vaults pallet) read +this to size their own available capacity without trampling PSM's share. ## Fee Structure -Fees are calculated using `Permill::mul_ceil` (rounds up) and transferred as the internal asset to `FeeDestination`: +Fees are stored per `(internal_asset, external_asset)` pair, calculated using +`Permill::mul_ceil` (rounds up), and routed to the instance's `fee_destination`: -- **Minting Fee**: `fee = MintingFee[asset_id].mul_ceil(external_amount)` - -- deducted from internal-asset output, minted to `FeeDestination` -- **Redemption Fee**: `fee = RedemptionFee[asset_id].mul_ceil(amount)` - -- transferred from the user to `FeeDestination` +- **Minting Fee**: `fee = MintingFee[internal, external].mul_ceil(internal_equivalent)` + -- deducted from internal-asset output, minted to `fee_destination` +- **Redemption Fee**: `fee = RedemptionFee[internal, external].mul_ceil(amount)` + -- transferred from the user to `fee_destination` -With 0.5% fees on both sides, arbitrage opportunities exist when the internal asset trades outside $0.995-$1.005. +With 0.5% fees on both sides, arbitrage opportunities exist when the internal +asset trades outside $0.995-$1.005. ## Circuit Breaker -Each approved asset has an independent circuit breaker with three levels: +Each approved external on each instance has an independent circuit breaker +with three levels: | Level | Minting | Redemption | Use Case | | ----------------- | ------- | ---------- | --------------------------------- | | `AllEnabled` | Allowed | Allowed | Normal operation | -| `MintingDisabled` | Blocked | Allowed | Drain debt from problematic asset | -| `AllDisabled` | Blocked | Blocked | Full emergency halt | +| `MintingDisabled` | Blocked | Allowed | Drain debt from a problematic external | +| `AllDisabled` | Blocked | Blocked | Full emergency halt of an external | -The `set_asset_status` extrinsic can be called by both `GeneralAdmin` and `EmergencyAction` origins. +`set_asset_status` is callable at both the `Full` (`full_admin`) and +`Emergency` (`emergency_admin`) levels. ## Governance Operations -| Extrinsic | Required Level | Description | -| -------------------------------------------- | ----------------- | ------------------------------------------------- | -| `set_minting_fee(asset_id, fee)` | Full | Update minting fee for an asset | -| `set_redemption_fee(asset_id, fee)` | Full | Update redemption fee for an asset | -| `set_max_psm_debt(ratio)` | Full | Update global PSM ceiling as % of MaximumIssuance | -| `set_asset_ceiling_weight(asset_id, weight)` | Full | Update per-asset ceiling weight | -| `set_asset_status(asset_id, status)` | Full or Emergency | Set per-asset circuit breaker level | -| `add_external_asset(asset_id)` | Full | Add approved stablecoin (matching decimals) | -| `remove_external_asset(asset_id)` | Full | Remove approved stablecoin (requires zero debt) | +All governance extrinsics take `internal_asset` as the first parameter to +identify the PSM instance being configured. + +| Extrinsic | Required Level | Description | +| ------------------------------------------------------------------ | ----------------- | ------------------------------------------ | +| `set_minting_fee(internal_asset, asset_id, fee)` | Full | Update minting fee for the pair | +| `set_redemption_fee(internal_asset, asset_id, fee)` | Full | Update redemption fee for the pair | +| `set_max_debt(internal_asset, value)` | Full or Emergency | Update absolute debt ceiling for the PSM | +| `set_asset_ceiling_weight(internal_asset, asset_id, weight)` | Full or Emergency | Update per-external ceiling weight | +| `set_asset_status(internal_asset, asset_id, status)` | Full or Emergency | Set per-external circuit breaker level | +| `add_external_asset(internal_asset, asset_id)` | Full | Approve external on a PSM | +| `remove_external_asset(internal_asset, asset_id)` | Full | Remove external from a PSM (zero debt) | ### Privilege Levels -The `ManagerOrigin` returns a privilege level: +Each PSM instance stores two admin origins, both set to the creator on `create_psm` +and reassignable by the `full_admin`. An incoming origin is matched against them to +resolve a privilege level: -- **Full** (via GeneralAdmin): Can modify all parameters -- **Emergency** (via EmergencyAction): Can only modify circuit breaker status +- **Full** (the `full_admin`): can modify all parameters, approve/remove externals, + reassign either admin, and remove the instance +- **Emergency** (the `emergency_admin`): can modify circuit breaker status, ceiling + weights, and debt ceilings only ### Asset Offboarding Workflow -1. `set_asset_ceiling_weight(asset_id, 0%)` -- blocks minting, redistributes capacity -2. Redemptions slowly drain remaining PSM debt -3. Once `PsmDebt[asset_id]` reaches zero, call `remove_external_asset(asset_id)` +For an external `asset_id` on instance `internal_asset`: + +1. `set_asset_status(internal_asset, asset_id, MintingDisabled)` -- halts new minting while + still allowing redemptions +2. Redemptions slowly drain `PsmDebt[internal_asset, asset_id]` +3. Once debt reaches zero, call `remove_external_asset(internal_asset, asset_id)` + +Minting is halted via the circuit breaker, not by zeroing the ceiling weight: +`set_asset_ceiling_weight` rejects any weight that would drop an external's normalised ceiling +below its outstanding debt (`CeilingBelowOutstandingDebt`), so the weight can only be lowered to +zero once the debt has already drained. ### Asset Onboarding Requirements -Before calling `add_external_asset(asset_id)`: +Before calling `add_external_asset(internal_asset, asset_id)`: -- The asset must already exist in the `Fungibles` implementation -- The asset's decimals must match `InternalAsset::decimals()` -- The pallet must still be below `MaxExternalAssets` +- A PSM must already be registered for `internal_asset` +- The external `asset_id` must already exist in the `Fungibles` implementation +- The internal asset's live decimals must still match the snapshot in `PsmInfo` +- `|external_decimals − internal_decimals|` must be within `MAX_DECIMALS_DIFF` +- The PSM must still be below `MaxExternalAssetsPerPsm` ## Configuration ```rust impl pallet_psm::Config for Runtime { type Fungibles = Assets; + type Currency = Balances; + type RuntimeOrigin = RuntimeOrigin; + type PalletsOrigin = OriginCaller; type AssetId = u32; - type MaximumIssuance = MaximumIssuance; - type ManagerOrigin = EnsurePsmManager; type WeightInfo = weights::SubstrateWeight; - type InternalAsset = frame_support::traits::fungible::ItemOf< - Assets, - InternalAssetId, - AccountId, - >; - type FeeDestination = InsuranceFundAccount; type PalletId = PsmPalletId; type MinSwapAmount = MinSwapAmount; - type MaxExternalAssets = ConstU32<10>; + type MaxExternalAssetsPerPsm = ConstU32<10>; + type CreationDeposit = PsmCreationDeposit; } ``` -`Fungibles` must expose metadata for approved assets, and `InternalAsset` -must expose metadata for the internal asset because `add_external_asset` -validates that decimals match before approval. `MaximumIssuance` provides -the system-wide internal-asset cap (typically from the Vaults pallet or a constant). +`Fungibles` must expose metadata for both internal and external assets, because +`add_external_asset` snapshots the external's decimals and the pallet validates +on every swap that live decimals still match. -### Parameters (Set via Governance) +### Per-Instance Parameters (Set via Governance) -| Parameter | Description | Suggested Value | -| -------------------- | -------------------------------------------- | --------------------- | -| `MaxPsmDebtOfTotal` | PSM ceiling as % of MaximumIssuance | 10% | -| `MintingFee` | Fee for external → internal (per asset) | 0.5% | -| `RedemptionFee` | Fee for internal → external (per asset) | 0.5% | -| `AssetCeilingWeight` | Per-asset share of PSM ceiling | 50% each (USDC, USDT) | +| Parameter | Description | Suggested Value | +| -------------------- | -------------------------------------------- | ----------------------- | +| `PsmInfo::max_debt` | Absolute internal-asset debt ceiling | Per-instance, governance-set | +| `MintingFee` | Fee for external → internal (per pair) | 0.5% | +| `RedemptionFee` | Fee for internal → external (per pair) | 0.5% | +| `AssetCeilingWeight` | Per-external share of the PSM's `max_debt` | e.g. 50%/50% (USDC/USDT) | -### Required Constants +### Required Config Constants -- `PalletId`: Unique identifier for deriving the PSM account -- `MinSwapAmount`: Minimum amount for any swap (default: 100 units of the internal asset) -- `MaxExternalAssets`: Maximum number of approved external assets - -Typical runtime helpers used in the configuration above: - -- `InternalAssetId`: Runtime constant used by `ItemOf<..., InternalAssetId, ...>` to bind `InternalAsset` to a specific asset -- `InsuranceFundAccount`: Account that receives internal-asset fees via `FeeDestination` +- `PalletId`: Unique identifier; sub-accounts are derived per instance. +- `MinSwapAmount`: Minimum swap amount in internal-asset units (default suggested: 100 units). +- `MaxExternalAssetsPerPsm`: Maximum number of approved externals per PSM instance. ## Events -- `Minted { who, asset_id, external_amount, received, fee }`: User swapped external stablecoin for the internal asset -- `Redeemed { who, asset_id, paid, external_received, fee }`: User swapped the internal asset for external stablecoin -- `MintingFeeUpdated { asset_id, old_value, new_value }`: Minting fee changed -- `RedemptionFeeUpdated { asset_id, old_value, new_value }`: Redemption fee changed -- `MaxPsmDebtOfTotalUpdated { old_value, new_value }`: Global PSM ceiling changed -- `AssetCeilingWeightUpdated { asset_id, old_value, new_value }`: Per-asset ceiling weight changed -- `AssetStatusUpdated { asset_id, status }`: Circuit breaker level changed -- `ExternalAssetAdded { asset_id }`: New external stablecoin approved -- `ExternalAssetRemoved { asset_id }`: External stablecoin removed +All events carry `internal_asset` so consumers can attribute them to the correct PSM. + +- `Minted { internal_asset, who, asset_id, external_amount, received, fee }` +- `Redeemed { internal_asset, who, asset_id, paid, external_received, fee }` +- `MintingFeeUpdated { internal_asset, asset_id, old_value, new_value }` +- `RedemptionFeeUpdated { internal_asset, asset_id, old_value, new_value }` +- `MaxDebtUpdated { internal_asset, old_value, new_value }` +- `AssetCeilingWeightUpdated { internal_asset, asset_id, old_value, new_value }` +- `AssetStatusUpdated { internal_asset, asset_id, status }` +- `ExternalAssetAdded { internal_asset, asset_id }` +- `ExternalAssetRemoved { internal_asset, asset_id }` ## Errors -- `UnsupportedAsset`: Asset is not in the approved list - `InsufficientReserve`: PSM doesn't have enough external stablecoin for redemption -- `ExceedsMaxIssuance`: Mint would exceed system-wide internal-asset cap -- `ExceedsMaxPsmDebt`: Mint would exceed aggregate PSM ceiling or per-asset ceiling -- `BelowMinimumSwap`: Swap amount below MinSwapAmount -- `MintingStopped`: Minting disabled by circuit breaker -- `AllSwapsStopped`: All swaps disabled by circuit breaker -- `AssetAlreadyApproved`: Asset already in approved list -- `AssetNotApproved`: Asset not in approved list -- `AssetHasDebt`: Cannot remove asset with outstanding debt -- `InsufficientPrivilege`: Emergency origin tried a Full-only operation -- `TooManyAssets`: Maximum number of approved external assets reached -- `DecimalsMismatch`: External asset decimals do not match the internal asset decimals +- `ExceedsMaxPsmDebt`: Mint would exceed the instance's aggregate or per-external ceiling +- `BelowMinimumSwap`: Swap amount below `MinSwapAmount` +- `MintingStopped`: Minting disabled by the per-external circuit breaker +- `AllSwapsStopped`: All swaps disabled by the per-external circuit breaker +- `UnsupportedAsset`: External not approved on this PSM +- `PsmNotFound`: No PSM registered for `internal_asset` +- `AssetAlreadyApproved`: External already approved on this PSM +- `AssetDoesNotExist`: External does not exist in the fungibles backend +- `AssetNotApproved`: External not approved (governance path) +- `AssetHasDebt`: Cannot remove an external with outstanding debt +- `InsufficientPrivilege`: Emergency origin attempted a Full-only operation +- `TooManyAssets`: PSM at `MaxExternalAssetsPerPsm` +- `DecimalsMismatch`: Live decimals diverged from the registration snapshot +- `DecimalsRangeExceeded`: `|external_decimals − internal_decimals|` exceeds `MAX_DECIMALS_DIFF` +- `ConversionOverflow`: Decimal scaling overflowed +- `AmountTooSmallAfterConversion`: Counter-asset conversion rounds to zero - `Unexpected`: An unexpected invariant violation occurred (defensive check) ## Testing diff --git a/substrate/frame/psm/remote-tests/src/lib.rs b/substrate/frame/psm/remote-tests/src/lib.rs index b38dc32c75a40..00aba877250a4 100644 --- a/substrate/frame/psm/remote-tests/src/lib.rs +++ b/substrate/frame/psm/remote-tests/src/lib.rs @@ -23,16 +23,9 @@ use frame_support::{ assert_noop, assert_ok, - traits::{ - fungible::{ - metadata::{Inspect as FungibleMetadataInspect, Mutate as FungibleMetadataMutate}, - Create as FungibleCreate, Inspect as FungibleInspect, - }, - fungibles::{ - metadata::{Inspect as FungiblesMetadataInspect, Mutate as FungiblesMetadataMutate}, - Create as FungiblesCreate, Inspect as FungiblesInspect, Mutate as FungiblesMutate, - }, - Get, + traits::fungibles::{ + metadata::{Inspect as FungiblesMetadataInspect, Mutate as FungiblesMetadataMutate}, + Create as FungiblesCreate, Inspect as FungiblesInspect, Mutate as FungiblesMutate, }, }; use remote_externalities::{Builder, Mode, OfflineConfig, OnlineConfig, SnapshotConfig}; @@ -53,14 +46,30 @@ type BalanceOf = pub type AssetIdOf = ::AssetId; /// [`PsmTestConfig`] for a given runtime. -pub type PsmTestConfigOf = PsmTestConfig>; +pub type PsmTestConfigOf = PsmTestConfig< + AssetIdOf, + ::AccountId, + BalanceOf, +>; /// Configuration for which asset to use as the external stablecoin in tests. -pub struct PsmTestConfig { +pub struct PsmTestConfig { + /// The internal stablecoin asset ID for the PSM instance under test. + pub internal_asset_id: AssetId, /// The external stablecoin asset ID. pub external_asset_id: AssetId, /// The expected decimal precision for the internal asset (e.g., 6). pub internal_asset_decimals: u8, + /// PSM fee destination written into the bootstrapped `PsmInfo`. + pub fee_destination: AccountId, + /// Absolute internal-asset debt ceiling for the PSM instance. + pub max_debt: Balance, + /// Minting fee for the `(internal_asset, external_asset)` pair. + pub minting_fee: sp_runtime::Permill, + /// Redemption fee for the `(internal_asset, external_asset)` pair. + pub redemption_fee: sp_runtime::Permill, + /// Ceiling weight assigned to `external_asset` on the PSM. + pub ceiling_weight: sp_runtime::Permill, /// The pallet name for the assets pallet on the target chain (e.g., "Assets"). /// Used to determine which storage prefixes to fetch from the live chain. pub assets_pallet_name: String, @@ -79,6 +88,7 @@ const SMALL_REDEEM: u128 = 100; /// Common test state returned by [`setup`]. struct TestEnv { + internal_asset_id: Runtime::AssetId, asset_id: Runtime::AssetId, caller: Runtime::AccountId, psm_account: Runtime::AccountId, @@ -87,18 +97,17 @@ struct TestEnv { /// Create internal asset if needed, configure PSM, and fund test accounts. /// Must be called inside `execute_with`. -fn setup(config: &PsmTestConfigOf) -> TestEnv +fn setup(config: &PsmTestConfigOf) -> TestEnv where Runtime: pallet_psm::Config + frame_system::Config, BalanceOf: TryFrom + core::fmt::Debug, Runtime::Fungibles: FungiblesCreate + FungiblesMetadataMutate, - Runtime::InternalAsset: - FungibleCreate + FungibleMetadataMutate, - InitialPsmConfig: pallet_psm::migrations::init::InitialPsmConfig, { let asset_id = config.external_asset_id.clone(); - let psm_account: Runtime::AccountId = Runtime::PalletId::get().into_account_truncating(); + let internal_asset_id = config.internal_asset_id.clone(); + let psm_account: Runtime::AccountId = + pallet_psm::Pallet::::psm_account(&internal_asset_id); // Check that the external asset actually exists on-chain. assert!( @@ -119,8 +128,9 @@ where ); // Create the internal asset if it doesn't exist yet. - if >::minimum_balance().is_zero() - { + if !>::asset_exists( + internal_asset_id.clone(), + ) { // Run pre-create hook (e.g., set NextAssetId for AutoIncAssetId chains). if let Some(hook) = &config.pre_create_hook { hook(); @@ -128,14 +138,16 @@ where let _ = frame_system::Pallet::::inc_providers(&psm_account); - assert_ok!(>::create( + assert_ok!(>::create( + internal_asset_id.clone(), psm_account.clone(), true, 10_000u128.try_into().unwrap_or_else(|_| panic!("balance conversion failed")), )); // Set internal asset metadata using the configured decimals. - assert_ok!(>::set( + assert_ok!(>::set( + internal_asset_id.clone(), &psm_account, b"internal".to_vec(), b"internal".to_vec(), @@ -151,7 +163,9 @@ where // Verify the stable asset and external asset have matching decimals. let internal_decimals = - >::decimals(); + >::decimals( + internal_asset_id.clone(), + ); let external_decimals = >::decimals( asset_id.clone(), @@ -162,9 +176,61 @@ where internal_decimals, external_decimals, ); - // Initialize PSM parameters (idempotent — skips already-configured assets). - as - frame_support::traits::OnRuntimeUpgrade>::on_runtime_upgrade(); + // Bootstrap the PSM by writing the [`PsmInfo`] / [`PsmAdminInfo`] records directly with + // `Root` as both admins, then setting up the external via the public dispatchables + // (dispatched as root, which matches `full_admin`). This avoids needing to fund a + // signer for `create_psm` in the remote-ext environment. + let internal_decimals_u8 = internal_decimals; + let root_origin: ::PalletsOrigin = + frame_system::RawOrigin::::Root.into(); + pallet_psm::Psm::::insert( + &internal_asset_id, + pallet_psm::PsmInfo:: { + fee_destination: config.fee_destination.clone(), + max_debt: config.max_debt, + internal_decimals: internal_decimals_u8, + external_count: 0, + }, + ); + pallet_psm::PsmAdmin::::insert( + &internal_asset_id, + pallet_psm::PsmAdminInfo:: { + full_admin: root_origin.clone(), + emergency_admin: root_origin, + depositor: config.fee_destination.clone(), + deposit: Zero::zero(), + }, + ); + let psm_account_id = pallet_psm::Pallet::::psm_account(&internal_asset_id); + if !frame_system::Pallet::::account_exists(&psm_account_id) { + let _ = frame_system::Pallet::::inc_providers(&psm_account_id); + } + if !frame_system::Pallet::::account_exists(&config.fee_destination) { + let _ = frame_system::Pallet::::inc_providers(&config.fee_destination); + } + assert_ok!(pallet_psm::Pallet::::add_external_asset( + frame_system::RawOrigin::Root.into(), + internal_asset_id.clone(), + asset_id.clone(), + )); + assert_ok!(pallet_psm::Pallet::::set_minting_fee( + frame_system::RawOrigin::Root.into(), + internal_asset_id.clone(), + asset_id.clone(), + config.minting_fee, + )); + assert_ok!(pallet_psm::Pallet::::set_redemption_fee( + frame_system::RawOrigin::Root.into(), + internal_asset_id.clone(), + asset_id.clone(), + config.redemption_fee, + )); + assert_ok!(pallet_psm::Pallet::::set_asset_ceiling_weight( + frame_system::RawOrigin::Root.into(), + internal_asset_id.clone(), + asset_id.clone(), + config.ceiling_weight, + )); // Fund test account. let caller: Runtime::AccountId = @@ -186,7 +252,7 @@ where .try_into() .unwrap_or_else(|_| panic!("balance conversion failed")); - TestEnv { asset_id, caller, psm_account, swap_amount } + TestEnv { internal_asset_id, asset_id, caller, psm_account, swap_amount } } const SNAPSHOT_PATH: &str = "psm_remote_test.snap"; @@ -233,7 +299,7 @@ pub fn clear_ext() { /// 2. Mints internal asset by depositing the external stablecoin /// 3. Redeems internal asset back for the external stablecoin /// 4. Verifies balances, debt tracking, and fee accounting -pub fn mint_and_redeem( +pub fn mint_and_redeem( ext: &mut remote_externalities::RemoteExternalities, config: &PsmTestConfigOf, ) where @@ -242,13 +308,10 @@ pub fn mint_and_redeem( BalanceOf: TryFrom + core::fmt::Debug, Runtime::Fungibles: FungiblesCreate + FungiblesMetadataMutate, - Runtime::InternalAsset: - FungibleCreate + FungibleMetadataMutate, - InitialPsmConfig: pallet_psm::migrations::init::InitialPsmConfig, { ext.execute_with(|| { - let TestEnv { asset_id, caller, psm_account, swap_amount } = - setup::(config); + let TestEnv { internal_asset_id, asset_id, caller, psm_account, swap_amount } = + setup::(config); let balance_before = >::balance( asset_id.clone(), @@ -264,6 +327,7 @@ pub fn mint_and_redeem( // Test mint assert_ok!(pallet_psm::Pallet::::mint( frame_system::RawOrigin::Signed(caller.clone()).into(), + internal_asset_id.clone(), asset_id.clone(), swap_amount, )); @@ -298,17 +362,25 @@ pub fn mint_and_redeem( ); // Redeem all internal asset the caller has. - let internal_balance = Runtime::InternalAsset::balance(&caller); + let internal_balance = + >::balance( + internal_asset_id.clone(), + &caller, + ); let redeem_amount = internal_balance; assert_ok!(pallet_psm::Pallet::::redeem( frame_system::RawOrigin::Signed(caller.clone()).into(), + internal_asset_id.clone(), asset_id, redeem_amount, )); // Verify caller's internal asset was fully spent. - let internal_after = Runtime::InternalAsset::balance(&caller); + let internal_after = >::balance( + internal_asset_id.clone(), + &caller, + ); assert_eq!(internal_after, Zero::zero(), "Caller should have no internal asset remaining"); // Debt should decrease after redeem but not reach zero (fees keep some debt alive). @@ -318,8 +390,13 @@ pub fn mint_and_redeem( assert!(debt_after < total_debt, "Debt should decrease after redeem"); // Fee destination should have received fees. - let fee_dest = Runtime::FeeDestination::get(); - let fee_balance = Runtime::InternalAsset::balance(&fee_dest); + let fee_dest = pallet_psm::Psm::::get(internal_asset_id.clone()) + .expect("PSM installed by setup") + .fee_destination; + let fee_balance = >::balance( + internal_asset_id, + &fee_dest, + ); assert!(fee_balance > Zero::zero(), "Fee destination should have collected fees"); log::info!( @@ -340,7 +417,7 @@ pub fn mint_and_redeem( /// 2. Activates circuit breaker to `MintingDisabled` — verifies mint fails, redeem works /// 3. Activates circuit breaker to `AllDisabled` — verifies both mint and redeem fail /// 4. Deactivates circuit breaker — verifies both operations resume -pub fn circuit_breaker( +pub fn circuit_breaker( ext: &mut remote_externalities::RemoteExternalities, config: &PsmTestConfigOf, ) where @@ -349,17 +426,15 @@ pub fn circuit_breaker( BalanceOf: TryFrom + core::fmt::Debug, Runtime::Fungibles: FungiblesCreate + FungiblesMetadataMutate, - Runtime::InternalAsset: - FungibleCreate + FungibleMetadataMutate, - InitialPsmConfig: pallet_psm::migrations::init::InitialPsmConfig, { ext.execute_with(|| { - let TestEnv { asset_id, caller, swap_amount, .. } = - setup::(config); + let TestEnv { internal_asset_id, asset_id, caller, swap_amount, .. } = + setup::(config); // Mint some internal asset first so we have something to redeem later. assert_ok!(pallet_psm::Pallet::::mint( frame_system::RawOrigin::Signed(caller.clone()).into(), + internal_asset_id.clone(), asset_id.clone(), swap_amount, )); @@ -372,6 +447,7 @@ pub fn circuit_breaker( // Test: MintingDisabled. Mint fails, redeem still works assert_ok!(pallet_psm::Pallet::::set_asset_status( frame_system::RawOrigin::Root.into(), + internal_asset_id.clone(), asset_id.clone(), pallet_psm::CircuitBreakerLevel::MintingDisabled, )); @@ -379,6 +455,7 @@ pub fn circuit_breaker( assert_noop!( pallet_psm::Pallet::::mint( frame_system::RawOrigin::Signed(caller.clone()).into(), + internal_asset_id.clone(), asset_id.clone(), swap_amount, ), @@ -387,6 +464,7 @@ pub fn circuit_breaker( assert_ok!(pallet_psm::Pallet::::redeem( frame_system::RawOrigin::Signed(caller.clone()).into(), + internal_asset_id.clone(), asset_id.clone(), small_redeem, )); @@ -396,6 +474,7 @@ pub fn circuit_breaker( // Test: AllDisabled. Both mint and redeem fail assert_ok!(pallet_psm::Pallet::::set_asset_status( frame_system::RawOrigin::Root.into(), + internal_asset_id.clone(), asset_id.clone(), pallet_psm::CircuitBreakerLevel::AllDisabled, )); @@ -403,6 +482,7 @@ pub fn circuit_breaker( assert_noop!( pallet_psm::Pallet::::mint( frame_system::RawOrigin::Signed(caller.clone()).into(), + internal_asset_id.clone(), asset_id.clone(), swap_amount, ), @@ -412,6 +492,7 @@ pub fn circuit_breaker( assert_noop!( pallet_psm::Pallet::::redeem( frame_system::RawOrigin::Signed(caller.clone()).into(), + internal_asset_id.clone(), asset_id.clone(), small_redeem, ), @@ -423,18 +504,21 @@ pub fn circuit_breaker( // Test: Re-enable. Both operations resume assert_ok!(pallet_psm::Pallet::::set_asset_status( frame_system::RawOrigin::Root.into(), + internal_asset_id.clone(), asset_id.clone(), pallet_psm::CircuitBreakerLevel::AllEnabled, )); assert_ok!(pallet_psm::Pallet::::mint( frame_system::RawOrigin::Signed(caller.clone()).into(), + internal_asset_id.clone(), asset_id.clone(), swap_amount, )); assert_ok!(pallet_psm::Pallet::::redeem( frame_system::RawOrigin::Signed(caller.clone()).into(), + internal_asset_id, asset_id, small_redeem, )); diff --git a/substrate/frame/psm/src/benchmarking.rs b/substrate/frame/psm/src/benchmarking.rs index 6c33d691af40a..86f4a4b071d4e 100644 --- a/substrate/frame/psm/src/benchmarking.rs +++ b/substrate/frame/psm/src/benchmarking.rs @@ -21,9 +21,9 @@ use super::*; use crate::Pallet as Psm; use frame_benchmarking::v2::*; use frame_support::traits::{ - fungible::{metadata::Inspect, Create as FungibleCreate, Inspect as FungibleInspect}, fungibles::{ - Create as FungiblesCreate, Inspect as FungiblesInspect, Mutate as FungiblesMutate, + metadata::Inspect as FungiblesMetadataInspect, Create as FungiblesCreate, + Inspect as FungiblesInspect, Mutate as FungiblesMutate, }, Get, }; @@ -31,80 +31,110 @@ use frame_system::RawOrigin; use pallet::BalanceOf; use sp_runtime::{traits::Zero, Permill, Saturating}; -/// Offset for benchmark asset IDs, chosen to avoid collision with typical -/// genesis asset IDs (e.g. internal asset ID = 1). -const ASSET_ID_OFFSET: u32 = 100; +/// Asset-ID indices passed to `BenchmarkHelper::get_asset_id`. Chosen to avoid +/// collision with typical genesis assets. +const INTERNAL_ASSET_INDEX: u32 = 50; +const EXTERNAL_ASSET_OFFSET: u32 = 100; -/// Ensure the internal asset exists and its decimals snapshot is written. -/// The snapshot is consulted by mint/redeem via `ensure_decimals_match` and by -/// `add_external_asset`; without it those paths fail closed. Returns the live -/// internal decimals so callers can align external-asset metadata with it. -fn ensure_internal_setup() -> u8 +/// Ensure the benchmarked internal asset exists and a PSM record is installed for +/// it. Returns `(internal_asset_id, internal_decimals)`. +fn ensure_internal_setup() -> (T::AssetId, u8) where - T::InternalAsset: FungibleCreate, + T::Fungibles: FungiblesCreate, { let admin: T::AccountId = whitelisted_caller(); let _ = frame_system::Pallet::::inc_providers(&admin); - if T::InternalAsset::minimum_balance().is_zero() { - let _ = T::InternalAsset::create(admin, true, 1u32.into()); + let internal_id: T::AssetId = T::BenchmarkHelper::get_asset_id(INTERNAL_ASSET_INDEX); + if !T::Fungibles::asset_exists(internal_id.clone()) { + let _ = T::Fungibles::create(internal_id.clone(), admin.clone(), true, 1u32.into()); } - let internal_decimals = T::InternalAsset::decimals(); - if !crate::InternalDecimals::::exists() { - crate::InternalDecimals::::put(internal_decimals); + let internal_decimals = T::Fungibles::decimals(internal_id.clone()); + if !crate::Psm::::contains_key(&internal_id) { + crate::Psm::::insert( + &internal_id, + crate::PsmInfo:: { + fee_destination: admin.clone(), + max_debt: BalanceOf::::from(u32::MAX).saturating_mul(1_000_000u32.into()), + internal_decimals, + external_count: 0, + }, + ); + // Admins set to `Root` so the admin benchmarks (dispatched as `RawOrigin::Root`) + // match `full_admin` in `ensure_psm_admin`. + let root_origin: T::PalletsOrigin = RawOrigin::Root.into(); + crate::PsmAdmin::::insert( + &internal_id, + crate::PsmAdminInfo:: { + full_admin: root_origin.clone(), + emergency_admin: root_origin, + depositor: admin, + deposit: Zero::zero(), + }, + ); } - internal_decimals + (internal_id, internal_decimals) } -/// Set up `n` external assets ready for PSM benchmarks. +/// Set up `n` external assets ready for PSM benchmarks. Returns +/// `(internal_asset_id, target_external_id)`. /// -/// Creates the target asset (`ASSET_ID_OFFSET`) and the internal asset, -/// registers `n` external assets (`ASSET_ID_OFFSET..+n`), and -/// configures ceiling weights so the target can absorb the full mint amount. +/// Creates the target external asset (`EXTERNAL_ASSET_OFFSET`) and the internal +/// asset, registers `n` external assets, and configures ceiling weights so the +/// target can absorb the full mint amount. /// -/// Assets beyond the target are filler, they only populate PSM storage so -/// the iterators in `total_psm_debt()` and `max_asset_debt()` touch `n` -/// entries during `mint()`. -fn setup_assets(n: u32) -> T::AssetId +/// Assets beyond the target are filler, they only populate PSM storage so the +/// iterators in `total_psm_debt()` and `max_asset_debt()` touch `n` entries +/// during `mint()`. +fn setup_assets(n: u32) -> (T::AssetId, T::AssetId) where T::Fungibles: FungiblesCreate, - T::InternalAsset: FungibleCreate, { let admin: T::AccountId = whitelisted_caller(); let _ = frame_system::Pallet::::inc_providers(&admin); - let internal_decimals = ensure_internal_setup::(); + let (internal_id, internal_decimals) = ensure_internal_setup::(); // Target asset: create + set metadata via the runtime-provided benchmark // helper. Setting metadata requires reserving a native deposit, which the // helper handles by funding `admin` first — something the fungibles traits // alone cannot express. - let target_id: T::AssetId = T::BenchmarkHelper::get_asset_id(ASSET_ID_OFFSET); + let target_id: T::AssetId = T::BenchmarkHelper::get_asset_id(EXTERNAL_ASSET_OFFSET); if !T::Fungibles::asset_exists(target_id.clone()) { T::BenchmarkHelper::create_asset(target_id.clone(), &admin, internal_decimals); } - crate::MaxPsmDebtOfTotal::::put(Permill::from_percent(100)); // Filler assets only populate PSM storage so mint()'s iterators touch `n` - // entries. They are never swapped against, so their underlying fungibles - // asset does not need to exist and no ExternalDecimals snapshot is required. + // entries. They are never swapped against; we still seed `internal_decimals` + // so the storage shape matches the target row. for i in 0..n { - let id: T::AssetId = T::BenchmarkHelper::get_asset_id(ASSET_ID_OFFSET + i); - crate::ExternalAssets::::insert(&id, CircuitBreakerLevel::AllEnabled); - crate::AssetCeilingWeight::::insert(&id, Permill::from_percent(1)); - crate::PsmDebt::::insert(&id, BalanceOf::::from(1u32)); + let id: T::AssetId = T::BenchmarkHelper::get_asset_id(EXTERNAL_ASSET_OFFSET + i); + crate::ExternalAssets::::insert( + &internal_id, + &id, + crate::ExternalAssetInfo { + status: CircuitBreakerLevel::AllEnabled, + decimals: internal_decimals, + }, + ); + crate::AssetCeilingWeight::::insert(&internal_id, &id, Permill::from_percent(1)); + crate::PsmDebt::::insert(&internal_id, &id, BalanceOf::::from(1u32)); } - // Target-specific: dominant weight so it can absorb the full mint amount, - // and a decimals snapshot so `ensure_decimals_match` passes. - crate::AssetCeilingWeight::::insert(&target_id, Permill::from_percent(100)); - crate::ExternalDecimals::::insert(&target_id, internal_decimals); + // Target-specific: dominant weight so it can absorb the full mint amount. + crate::AssetCeilingWeight::::insert(&internal_id, &target_id, Permill::from_percent(100)); + + // Keep `external_count` consistent with the rows we wrote. + crate::Psm::::mutate(&internal_id, |maybe| { + if let Some(info) = maybe.as_mut() { + info.external_count = n.max(1); + } + }); - target_id + (internal_id, target_id) } #[benchmarks( where T::Fungibles: FungiblesCreate, - T::InternalAsset: FungibleCreate, )] mod benchmarks { use super::*; @@ -113,19 +143,19 @@ mod benchmarks { /// `total_psm_debt()` iterates `PsmDebt` and `max_asset_debt()` iterates /// `AssetCeilingWeight`. #[benchmark] - fn mint(n: Linear<1, { T::MaxExternalAssets::get() }>) -> Result<(), BenchmarkError> { + fn mint(n: Linear<1, { T::MaxExternalAssetsPerPsm::get() }>) -> Result<(), BenchmarkError> { let caller: T::AccountId = whitelisted_caller(); - let asset_id = setup_assets::(n); + let (internal_id, asset_id) = setup_assets::(n); let mint_amount = T::MinSwapAmount::get().saturating_mul(10u32.into()); T::Fungibles::mint_into(asset_id.clone(), &caller, mint_amount.saturating_mul(2u32.into())) .map_err(|_| BenchmarkError::Stop("Failed to fund caller"))?; - let psm_account = Psm::::account_id(); + let psm_account = Psm::::psm_account(&internal_id); let reserve_before = T::Fungibles::balance(asset_id.clone(), &psm_account); #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), asset_id.clone(), mint_amount); + _(RawOrigin::Signed(caller.clone()), internal_id.clone(), asset_id.clone(), mint_amount); assert!(T::Fungibles::balance(asset_id, &psm_account) > reserve_before); Ok(()) @@ -134,7 +164,7 @@ mod benchmarks { #[benchmark] fn redeem() -> Result<(), BenchmarkError> { let caller: T::AccountId = whitelisted_caller(); - let asset_id = setup_assets::(1); + let (internal_id, asset_id) = setup_assets::(1); let setup_amount = T::MinSwapAmount::get().saturating_mul(10u32.into()); let redeem_amount = T::MinSwapAmount::get(); @@ -144,14 +174,19 @@ mod benchmarks { setup_amount.saturating_mul(2u32.into()), ) .map_err(|_| BenchmarkError::Stop("Failed to fund caller"))?; - Psm::::mint(RawOrigin::Signed(caller.clone()).into(), asset_id.clone(), setup_amount) - .map_err(|_| BenchmarkError::Stop("Failed to setup reserve via mint"))?; + Psm::::mint( + RawOrigin::Signed(caller.clone()).into(), + internal_id.clone(), + asset_id.clone(), + setup_amount, + ) + .map_err(|_| BenchmarkError::Stop("Failed to setup reserve via mint"))?; - let psm_account = Psm::::account_id(); + let psm_account = Psm::::psm_account(&internal_id); let reserve_before = T::Fungibles::balance(asset_id.clone(), &psm_account); #[extrinsic_call] - _(RawOrigin::Signed(caller.clone()), asset_id.clone(), redeem_amount); + _(RawOrigin::Signed(caller.clone()), internal_id.clone(), asset_id.clone(), redeem_amount); assert!(T::Fungibles::balance(asset_id, &psm_account) < reserve_before); Ok(()) @@ -159,88 +194,92 @@ mod benchmarks { #[benchmark] fn set_minting_fee() -> Result<(), BenchmarkError> { - let asset_id = setup_assets::(1); + let (internal_id, asset_id) = setup_assets::(1); let new_fee = Permill::from_percent(2); #[extrinsic_call] - _(RawOrigin::Root, asset_id.clone(), new_fee); + _(RawOrigin::Root, internal_id.clone(), asset_id.clone(), new_fee); - assert_eq!(crate::MintingFee::::get(&asset_id), new_fee); + assert_eq!(crate::MintingFee::::get(&internal_id, &asset_id), new_fee); Ok(()) } #[benchmark] fn set_redemption_fee() -> Result<(), BenchmarkError> { - let asset_id = setup_assets::(1); + let (internal_id, asset_id) = setup_assets::(1); let new_fee = Permill::from_percent(2); #[extrinsic_call] - _(RawOrigin::Root, asset_id.clone(), new_fee); + _(RawOrigin::Root, internal_id.clone(), asset_id.clone(), new_fee); - assert_eq!(crate::RedemptionFee::::get(&asset_id), new_fee); + assert_eq!(crate::RedemptionFee::::get(&internal_id, &asset_id), new_fee); Ok(()) } #[benchmark] - fn set_max_psm_debt() -> Result<(), BenchmarkError> { - let new_ratio = Permill::from_percent(20); + fn set_max_debt() -> Result<(), BenchmarkError> { + let (internal_id, _) = ensure_internal_setup::(); + let new_value = BalanceOf::::from(123u32); #[extrinsic_call] - _(RawOrigin::Root, new_ratio); + _(RawOrigin::Root, internal_id.clone(), new_value); - assert_eq!(crate::MaxPsmDebtOfTotal::::get(), new_ratio); + assert_eq!(crate::Psm::::get(&internal_id).unwrap().max_debt, new_value); Ok(()) } #[benchmark] fn set_asset_status() -> Result<(), BenchmarkError> { - let asset_id = setup_assets::(1); + let (internal_id, asset_id) = setup_assets::(1); let new_status = CircuitBreakerLevel::MintingDisabled; #[extrinsic_call] - _(RawOrigin::Root, asset_id.clone(), new_status); + _(RawOrigin::Root, internal_id.clone(), asset_id.clone(), new_status); - assert_eq!(crate::ExternalAssets::::get(&asset_id), Some(new_status)); + assert_eq!( + crate::ExternalAssets::::get(&internal_id, &asset_id).map(|e| e.status), + Some(new_status), + ); Ok(()) } #[benchmark] fn set_asset_ceiling_weight() -> Result<(), BenchmarkError> { - let asset_id = setup_assets::(1); + let (internal_id, asset_id) = setup_assets::(1); let new_weight = Permill::from_percent(50); #[extrinsic_call] - _(RawOrigin::Root, asset_id.clone(), new_weight); + _(RawOrigin::Root, internal_id.clone(), asset_id.clone(), new_weight); - assert_eq!(crate::AssetCeilingWeight::::get(&asset_id), new_weight); + assert_eq!(crate::AssetCeilingWeight::::get(&internal_id, &asset_id), new_weight); Ok(()) } #[benchmark] fn add_external_asset() -> Result<(), BenchmarkError> { - // Seed InternalDecimals and ensure the internal asset exists; the extrinsic + // Seed PsmInfo and ensure the internal asset exists; the extrinsic // reads the snapshot and compares it against live metadata. - let internal_decimals = ensure_internal_setup::(); + let (internal_id, internal_decimals) = ensure_internal_setup::(); let caller: T::AccountId = whitelisted_caller(); - let new_asset_id: T::AssetId = T::BenchmarkHelper::get_asset_id(ASSET_ID_OFFSET); + let new_asset_id: T::AssetId = T::BenchmarkHelper::get_asset_id(EXTERNAL_ASSET_OFFSET); T::BenchmarkHelper::create_asset(new_asset_id.clone(), &caller, internal_decimals); #[extrinsic_call] - _(RawOrigin::Root, new_asset_id.clone()); + _(RawOrigin::Root, internal_id.clone(), new_asset_id.clone()); - assert!(crate::ExternalAssets::::contains_key(&new_asset_id)); + assert!(crate::ExternalAssets::::contains_key(&internal_id, &new_asset_id)); Ok(()) } #[benchmark] fn remove_external_asset() -> Result<(), BenchmarkError> { - let asset_id = setup_assets::(1); - crate::PsmDebt::::remove(&asset_id); + let (internal_id, asset_id) = setup_assets::(1); + crate::PsmDebt::::remove(&internal_id, &asset_id); #[extrinsic_call] - _(RawOrigin::Root, asset_id.clone()); + _(RawOrigin::Root, internal_id.clone(), asset_id.clone()); - assert!(!crate::ExternalAssets::::contains_key(&asset_id)); + assert!(!crate::ExternalAssets::::contains_key(&internal_id, &asset_id)); Ok(()) } diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 1e1205cf03d19..e54a6c35c7d60 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -17,8 +17,8 @@ //! # Peg Stability Module (PSM) Pallet //! -//! A module enabling 1:1 swaps between the runtime's internal stablecoin and pre-approved -//! external stablecoins. +//! A module hosting one or more Peg Stability Modules. Each PSM enables 1:1 swaps between a +//! specific internal stablecoin and that PSM's pre-approved external stablecoins. //! //! ## Pallet API //! @@ -29,61 +29,64 @@ //! //! Throughout this pallet two distinct token roles are referenced: //! -//! * **Internal** — the stablecoin issued and burned by the PSM. It is a single asset configured -//! via [`Config::InternalAsset`] (e.g. a runtime's pUSD). Mint operations credit the user with -//! the internal asset; redeem operations burn it. Fees are collected in the internal asset and -//! forwarded to [`Config::FeeDestination`]. -//! * **External** — third-party stablecoins (e.g. USDC, USDT) approved via -//! [`Pallet::add_external_asset`] and held in reserve by the PSM. Users deposit external to mint -//! internal, and burn internal to redeem external. Multiple external assets can be approved -//! simultaneously, each identified by `asset_id`. +//! * **Internal** — the stablecoin a PSM issues and burns (e.g. a runtime's pUSD). Each PSM +//! instance is keyed by its internal asset id; multiple instances can coexist, each with its own +//! reserve, debt ceiling, fee destination and approved externals. Mint operations credit the user +//! with the internal asset; redeem operations burn it. Fees are collected in the internal asset +//! and forwarded to that instance's [`PsmInfo::fee_destination`]. +//! * **External** — third-party stablecoins (e.g. USDC, USDT) approved on a specific PSM via +//! [`Pallet::add_external_asset`] and held in that PSM's reserve. Users deposit external to mint +//! internal, and burn internal to redeem external. A PSM may approve multiple externals, each +//! identified by `asset_id`. //! //! ## Overview //! -//! The PSM strengthens the internal asset's peg by providing arbitrage opportunities: +//! A PSM strengthens its internal asset's peg by providing arbitrage opportunities: //! - When the internal asset trades **above** $1: Users swap external stablecoins for the internal -//! asset and sell for profit +//! asset and sell for profit. //! - When the internal asset trades **below** $1: Users buy cheap internal asset and swap for -//! external stablecoins +//! external stablecoins. //! //! This creates a price corridor bounded by the minting and redemption fees. //! //! ### Key Concepts //! -//! * **Minting**: Deposit external stablecoin → receive internal asset (minus fee) -//! * **Redemption**: Burn internal asset → receive external stablecoin (minus fee) -//! * **Reserve**: External stablecoin balance held by the PSM account (derived, not stored) -//! * **PSM Debt**: Total internal asset minted through PSM, backed 1:1 by external stablecoins -//! * **Circuit Breaker**: Emergency control to disable minting or all swaps -//! -//! ### Supported Assets -//! -//! The PSM supports multiple pre-approved external stablecoins (e.g., USDC, USDT). -//! Each swap operation specifies which asset to use via the `asset_id` parameter. +//! * **PSM instance**: A configured Peg Stability Module, keyed by its internal asset id and +//! described by [`PsmInfo`]. Each instance has its own reserve account derived as +//! `PalletId::into_sub_account_truncating(blake2_256(internal_asset.encode()))` — the hash gives +//! a fixed-size seed so arbitrary asset ids (e.g. XCM `Location`s) do not collide. +//! * **Minting**: Deposit external stablecoin → receive internal asset (minus fee). +//! * **Redemption**: Burn internal asset → receive external stablecoin (minus fee). +//! * **Reserve**: External stablecoin balance held by a PSM's reserve account (derived, not +//! stored). +//! * **PSM Debt**: Total internal asset minted through a PSM, backed 1:1 by external stablecoins in +//! that PSM's reserve. +//! * **Circuit Breaker**: Per-external emergency control to disable minting or all swaps. //! //! ### Fee Structure //! -//! * **Minting Fee (`MintingFee`)**: Deducted from internal-asset output during minting +//! * **Minting Fee (`MintingFee`)**: Deducted from internal-asset output during minting, configured +//! per `(internal_asset, external_asset)` pair. //! * **Redemption Fee (`RedemptionFee`)**: Deducted from external stablecoin output during -//! redemption +//! redemption, configured per `(internal_asset, external_asset)` pair. //! -//! Fees are collected in the internal asset and transferred to [`Config::FeeDestination`]. +//! Fees are collected in the internal asset and transferred to the instance's +//! [`PsmInfo::fee_destination`]. //! //! ### Example //! //! ```ignore -//! // Mint internal asset by depositing USDC -//! Psm::mint(RuntimeOrigin::signed(user), USDC_ASSET_ID, 1000 * UNIT)?; +//! // Mint internal asset by depositing USDC on the pUSD PSM +//! Psm::mint(RuntimeOrigin::signed(user), PUSD_ASSET_ID, USDC_ASSET_ID, 1000 * UNIT)?; //! -//! // Redeem USDC by burning the internal asset -//! Psm::redeem(RuntimeOrigin::signed(user), USDC_ASSET_ID, 1000 * UNIT)?; +//! // Redeem USDC by burning pUSD +//! Psm::redeem(RuntimeOrigin::signed(user), PUSD_ASSET_ID, USDC_ASSET_ID, 1000 * UNIT)?; //! ``` #![cfg_attr(not(feature = "std"), no_std)] extern crate alloc; -pub mod migrations; pub mod weights; #[cfg(feature = "runtime-benchmarks")] @@ -113,22 +116,19 @@ pub trait BenchmarkHelper { pub mod pallet { pub use frame_support::traits::tokens::stable::PsmInterface; - use alloc::collections::btree_map::BTreeMap; use codec::DecodeWithMemTracking; use frame_support::{ pallet_prelude::*, traits::{ - fungible::{ - metadata::Inspect as FungibleMetadataInspect, Inspect as FungibleInspect, - Mutate as FungibleMutate, - }, fungibles::{ - metadata::Inspect as FungiblesMetadataInspect, Inspect as FungiblesInspect, + metadata::Inspect as FungiblesMetadataInspect, + roles::Inspect as FungiblesRolesInspect, Inspect as FungiblesInspect, Mutate as FungiblesMutate, }, tokens::{Fortitude, Precision, Preservation}, + CallerTrait, OriginTrait, ReservableCurrency, }, - DefaultNoBound, PalletId, + PalletId, }; use frame_system::pallet_prelude::*; use sp_runtime::{ @@ -174,10 +174,11 @@ pub mod pallet { } } - /// Privilege level returned by ManagerOrigin. + /// Privilege level of an origin acting on a PSM instance. /// - /// Enables tiered authorization where different origins have different - /// capabilities for managing PSM parameters. + /// Resolved by matching the incoming origin against the instance's stored + /// [`PsmAdminInfo::full_admin`] (`Full`) or [`PsmAdminInfo::emergency_admin`] + /// (`Emergency`), enabling tiered authorization over the instance's parameters. #[derive( Encode, Decode, @@ -192,12 +193,13 @@ pub mod pallet { Default, )] pub enum PsmManagerLevel { - /// Full administrative access via GeneralAdmin origin. - /// Can modify all parameters including fees, ceilings, and asset management. + /// Full administrative access, held by the instance's `full_admin`. + /// Can modify all parameters including fees, ceilings, and asset management, + /// reassign admins, and remove the instance. #[default] Full, - /// Emergency access via EmergencyAction origin. - /// Can modify circuit breaker status and asset ceiling weights. + /// Emergency access, held by the instance's `emergency_admin`. + /// Can modify circuit breaker status, the debt ceiling, and asset ceiling weights. Emergency, } @@ -213,9 +215,9 @@ pub mod pallet { matches!(self, PsmManagerLevel::Full | PsmManagerLevel::Emergency) } - /// Whether this level allows modifying the global PSM debt ratio. - /// Both Full and Emergency levels can set the max PSM debt. - pub const fn can_set_max_psm_debt(&self) -> bool { + /// Whether this level allows modifying the PSM debt ceiling. + /// Both Full and Emergency levels can set the max debt. + pub const fn can_set_max_debt(&self) -> bool { matches!(self, PsmManagerLevel::Full | PsmManagerLevel::Emergency) } @@ -235,6 +237,12 @@ pub mod pallet { ::AccountId, >>::Balance; + /// Native balance (used for PSM creation deposits). + pub(crate) type NativeBalanceOf = + <::Currency as frame_support::traits::Currency< + ::AccountId, + >>::Balance; + /// Suggested fee of 0.5% for minting and redemption. pub(crate) struct DefaultFee; impl Get for DefaultFee { @@ -248,53 +256,110 @@ pub mod pallet { /// so realistic balances cannot overflow during conversion. pub const MAX_DECIMALS_DIFF: u32 = 24; + /// On-chain record of a PSM instance. + #[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq, Debug, + )] + #[scale_info(skip_type_params(T))] + pub struct PsmInfo { + /// Account receiving minting and redemption fees, denominated in the internal asset. + pub fee_destination: T::AccountId, + /// Absolute internal-asset debt ceiling. + pub max_debt: BalanceOf, + /// Snapshot of the internal asset's decimals at install time. + pub internal_decimals: u8, + /// Number of approved external assets attached to this instance. + pub external_count: u32, + } + + /// Cold, admin-only record for a PSM instance, kept out of [`PsmInfo`] so the swap + /// hot path (`mint`/`redeem`) never decodes the (potentially large) `PalletsOrigin` + /// admins. Written and removed in lockstep with the [`Psm`] entry. + #[derive( + Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Clone, PartialEq, Eq, Debug, + )] + #[scale_info(skip_type_params(T))] + pub struct PsmAdminInfo { + /// Origin with `Full` management privileges over this PSM. Set to + /// `Signed(signer)` on `create_psm` and reassignable to any origin via + /// `set_full_admin`. + pub full_admin: T::PalletsOrigin, + /// Origin with `Emergency` management privileges over this PSM. Set to + /// `Signed(signer)` on `create_psm` and reassignable to any origin via + /// `set_emergency_admin`. + pub emergency_admin: T::PalletsOrigin, + /// Account that paid (and reserved) the creation deposit. The deposit is always + /// returned here on `remove_psm`, independently of any admin reassignment. + pub depositor: T::AccountId, + /// Native-balance deposit reserved from `depositor` on `create_psm` and returned + /// to them on `remove_psm`. + pub deposit: NativeBalanceOf, + } + + /// On-chain record of an external asset approved on a PSM instance. + #[derive( + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + Clone, + Copy, + PartialEq, + Eq, + Debug, + )] + pub struct ExternalAssetInfo { + /// Per-external circuit breaker status. + pub status: CircuitBreakerLevel, + /// Snapshot of the external asset's decimals at registration time. + pub decimals: u8, + } + #[pallet::config] pub trait Config: frame_system::Config { /// Fungibles implementation for both internal and external stablecoins. type Fungibles: FungiblesMutate - + FungiblesMetadataInspect; + + FungiblesMetadataInspect + + FungiblesRolesInspect; - /// Asset identifier type. - type AssetId: Parameter + Member + Clone + MaybeSerializeDeserialize + MaxEncodedLen + Ord; + /// Native currency used to reserve PSM creation deposits. + type Currency: ReservableCurrency; - /// Maximum allowed internal issuance across the entire system. - type MaximumIssuance: Get>; + /// The aggregated origin, tying the runtime origin to [`Config::PalletsOrigin`] so PSM + /// admins can be matched against incoming origins. + type RuntimeOrigin: OriginTrait + + From + + IsType<::RuntimeOrigin>; - /// Origin allowed to update PSM parameters. - /// - /// Returns `PsmManagerLevel` to distinguish privilege levels: - /// - `Full` (via GeneralAdmin): Can modify all parameters - /// - `Emergency` (via EmergencyAction): Can modify circuit breaker status, per-asset - /// ceiling weights, and the global max PSM debt ratio. - type ManagerOrigin: EnsureOrigin; + /// The caller origin, overarching type of all pallets' origins. Stored as a PSM's + /// `full_admin` / `emergency_admin` and matched against incoming origins. + type PalletsOrigin: Parameter + + From> + + CallerTrait + + MaxEncodedLen; + + /// Asset identifier type. + type AssetId: Parameter + Member + Clone + MaybeSerializeDeserialize + MaxEncodedLen + Ord; /// A type representing the weights required by the dispatchables of this pallet. type WeightInfo: WeightInfo; - /// The internal asset as a single-asset `fungible` type. - /// - /// Typically `ItemOf`. - /// Must use the same `Balance` type as `Asset`. - type InternalAsset: FungibleMutate> - + FungibleMetadataInspect; - - /// Account that receives internal fees from minting and redemption. - /// - /// Must exist before any swap; initialized at genesis and migration - /// via `Pallet::ensure_account_exists`. - type FeeDestination: Get; - - /// PalletId for deriving the PSM account. + /// PalletId for deriving each PSM instance's reserve sub-account. #[pallet::constant] type PalletId: Get; - /// Minimum swap amount. + /// Minimum swap amount, in internal-asset units. #[pallet::constant] type MinSwapAmount: Get>; - /// Maximum number of approved external assets. + /// Maximum number of approved external assets per PSM instance. + #[pallet::constant] + type MaxExternalAssetsPerPsm: Get; + + /// Native-currency deposit reserved on `create_psm` and returned on `remove_psm`. #[pallet::constant] - type MaxExternalAssets: Get; + type CreationDeposit: Get>; /// Helper for benchmarks to create an external asset with correct metadata. #[cfg(feature = "runtime-benchmarks")] @@ -302,7 +367,7 @@ pub mod pallet { } /// The in-code storage version. - const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); + const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] @@ -320,99 +385,88 @@ pub mod pallet { } } - /// internal minted through PSM per external asset, denominated in internal units. + /// Registered PSM instances, keyed by the internal asset id. #[pallet::storage] - pub type PsmDebt = - StorageMap<_, Blake2_128Concat, T::AssetId, BalanceOf, ValueQuery>; + pub type Psm = StorageMap<_, Blake2_128Concat, T::AssetId, PsmInfo, OptionQuery>; - /// Fee for external → internal swaps (minting) per asset. Suggested value is 0.5%. + /// Admin origins and creation-deposit bookkeeping per PSM, keyed by the internal + /// asset id. Held separately from [`Psm`] so swaps never decode the admin origins. + /// Always written and removed together with the corresponding [`Psm`] entry. #[pallet::storage] - pub(crate) type MintingFee = - StorageMap<_, Blake2_128Concat, T::AssetId, Permill, ValueQuery, DefaultFee>; + pub type PsmAdmin = + StorageMap<_, Blake2_128Concat, T::AssetId, PsmAdminInfo, OptionQuery>; - /// Fee for internal → external swaps (redemption) per asset. Suggested value is 0.5%. + /// Internal-asset debt minted through PSM, per `(internal, external)` pair. #[pallet::storage] - pub(crate) type RedemptionFee = - StorageMap<_, Blake2_128Concat, T::AssetId, Permill, ValueQuery, DefaultFee>; - - /// Max PSM debt as percentage of MaximumIssuance (global ceiling). + pub type PsmDebt = StorageDoubleMap< + _, + Blake2_128Concat, + T::AssetId, + Blake2_128Concat, + T::AssetId, + BalanceOf, + ValueQuery, + >; + + /// Fee for external → internal swaps (minting), per `(internal, external)` pair. + /// Defaults to 0.5%. #[pallet::storage] - pub(crate) type MaxPsmDebtOfTotal = StorageValue<_, Permill, ValueQuery>; - - /// Per-asset ceiling weight. Weights are normalized against the sum of all weights. - /// Zero means minting is disabled for this asset. + pub(crate) type MintingFee = StorageDoubleMap< + _, + Blake2_128Concat, + T::AssetId, + Blake2_128Concat, + T::AssetId, + Permill, + ValueQuery, + DefaultFee, + >; + + /// Fee for internal → external swaps (redemption), per `(internal, external)` pair. + /// Defaults to 0.5%. #[pallet::storage] - pub(crate) type AssetCeilingWeight = - StorageMap<_, Blake2_128Concat, T::AssetId, Permill, ValueQuery>; - - /// Set of approved external stablecoin asset IDs with their operational status. - /// Key existence indicates the asset is approved; the value is the circuit breaker level. - #[pallet::storage] - pub(crate) type ExternalAssets = - CountedStorageMap<_, Blake2_128Concat, T::AssetId, CircuitBreakerLevel, OptionQuery>; - - /// Snapshot of each approved external asset's decimals at registration. - /// Used to detect runtime drift from the registered precision. + pub(crate) type RedemptionFee = StorageDoubleMap< + _, + Blake2_128Concat, + T::AssetId, + Blake2_128Concat, + T::AssetId, + Permill, + ValueQuery, + DefaultFee, + >; + + /// Per-external ceiling weight within a PSM, normalised against the sum of weights + /// for the same instance. Zero disables minting for that external. #[pallet::storage] - pub(crate) type ExternalDecimals = - StorageMap<_, Blake2_128Concat, T::AssetId, u8, OptionQuery>; - - /// Snapshot of the internal asset's decimals taken at genesis. - /// Set once during genesis build; present for the lifetime of the pallet. + pub(crate) type AssetCeilingWeight = StorageDoubleMap< + _, + Blake2_128Concat, + T::AssetId, + Blake2_128Concat, + T::AssetId, + Permill, + ValueQuery, + >; + + /// Approved external assets per PSM. #[pallet::storage] - pub(crate) type InternalDecimals = StorageValue<_, u8, OptionQuery>; - - /// Genesis configuration for the PSM pallet. - #[pallet::genesis_config] - #[derive(DefaultNoBound)] - pub struct GenesisConfig { - /// Max PSM debt as percentage of total maximum issuance. - pub max_psm_debt_of_total: Permill, - /// Per-asset configuration: asset_id -> (minting_fee, redemption_fee, - /// ceiling_weight). Keys also define the set of approved external assets. - pub asset_configs: BTreeMap, - #[serde(skip)] - pub _marker: core::marker::PhantomData, - } - - #[pallet::genesis_build] - impl BuildGenesisConfig for GenesisConfig { - fn build(&self) { - assert!( - self.asset_configs.len() as u32 <= T::MaxExternalAssets::get(), - "PSM genesis: asset_configs ({}) exceeds MaxExternalAssets ({})", - self.asset_configs.len(), - T::MaxExternalAssets::get(), - ); - MaxPsmDebtOfTotal::::put(self.max_psm_debt_of_total); - let internal_decimals = T::InternalAsset::decimals(); - InternalDecimals::::put(internal_decimals); - for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in &self.asset_configs { - let asset_decimals = T::Fungibles::decimals(asset_id.clone()); - let diff = asset_decimals.abs_diff(internal_decimals) as u32; - assert!( - diff <= MAX_DECIMALS_DIFF, - "PSM genesis: asset {:?} decimals diff ({}) exceeds MAX_DECIMALS_DIFF ({})", - asset_id, - diff, - MAX_DECIMALS_DIFF, - ); - ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); - ExternalDecimals::::insert(asset_id, asset_decimals); - MintingFee::::insert(asset_id, minting_fee); - RedemptionFee::::insert(asset_id, redemption_fee); - AssetCeilingWeight::::insert(asset_id, ceiling_weight); - } - Pallet::::ensure_account_exists(&Pallet::::account_id()); - Pallet::::ensure_account_exists(&T::FeeDestination::get()); - } - } + pub(crate) type ExternalAssets = StorageDoubleMap< + _, + Blake2_128Concat, + T::AssetId, + Blake2_128Concat, + T::AssetId, + ExternalAssetInfo, + OptionQuery, + >; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { /// User swapped external stablecoin for internal. Minted { + internal_asset: T::AssetId, who: T::AccountId, asset_id: T::AssetId, external_amount: BalanceOf, @@ -421,6 +475,7 @@ pub mod pallet { }, /// User swapped internal for external stablecoin. Redeemed { + internal_asset: T::AssetId, who: T::AccountId, asset_id: T::AssetId, paid: BalanceOf, @@ -428,19 +483,64 @@ pub mod pallet { fee: BalanceOf, }, /// Minting fee updated for an asset by governance. - MintingFeeUpdated { asset_id: T::AssetId, old_value: Permill, new_value: Permill }, + MintingFeeUpdated { + internal_asset: T::AssetId, + asset_id: T::AssetId, + old_value: Permill, + new_value: Permill, + }, /// Redemption fee updated for an asset by governance. - RedemptionFeeUpdated { asset_id: T::AssetId, old_value: Permill, new_value: Permill }, - /// Max PSM debt ratio updated by governance. - MaxPsmDebtOfTotalUpdated { old_value: Permill, new_value: Permill }, + RedemptionFeeUpdated { + internal_asset: T::AssetId, + asset_id: T::AssetId, + old_value: Permill, + new_value: Permill, + }, + /// PSM debt ceiling updated by governance. + MaxDebtUpdated { + internal_asset: T::AssetId, + old_value: BalanceOf, + new_value: BalanceOf, + }, /// Per-asset debt ceiling weight updated by governance. - AssetCeilingWeightUpdated { asset_id: T::AssetId, old_value: Permill, new_value: Permill }, + AssetCeilingWeightUpdated { + internal_asset: T::AssetId, + asset_id: T::AssetId, + old_value: Permill, + new_value: Permill, + }, /// Per-asset circuit breaker status updated. - AssetStatusUpdated { asset_id: T::AssetId, status: CircuitBreakerLevel }, + AssetStatusUpdated { + internal_asset: T::AssetId, + asset_id: T::AssetId, + status: CircuitBreakerLevel, + }, /// An external asset was added to the approved list. - ExternalAssetAdded { asset_id: T::AssetId }, + ExternalAssetAdded { internal_asset: T::AssetId, asset_id: T::AssetId }, /// An external asset was removed from the approved list. - ExternalAssetRemoved { asset_id: T::AssetId }, + ExternalAssetRemoved { internal_asset: T::AssetId, asset_id: T::AssetId }, + /// A PSM instance was created. + PsmCreated { + internal_asset: T::AssetId, + full_admin: T::PalletsOrigin, + emergency_admin: T::PalletsOrigin, + fee_destination: T::AccountId, + max_debt: BalanceOf, + }, + /// A PSM instance was removed. + PsmRemoved { internal_asset: T::AssetId }, + /// A PSM's `full_admin` was reassigned. + FullAdminChanged { + internal_asset: T::AssetId, + old_admin: T::PalletsOrigin, + new_admin: T::PalletsOrigin, + }, + /// A PSM's `emergency_admin` was reassigned. + EmergencyAdminChanged { + internal_asset: T::AssetId, + old_admin: T::PalletsOrigin, + new_admin: T::PalletsOrigin, + }, } #[pallet::error] @@ -449,6 +549,10 @@ pub mod pallet { InsufficientReserve, /// Swap would exceed PSM debt ceiling. ExceedsMaxPsmDebt, + /// A `max_debt` or ceiling-weight change would leave some external's outstanding debt + /// above its normalised ceiling. Ceilings are hard caps and may not be set beneath + /// outstanding debt. + CeilingBelowOutstandingDebt, /// Swap amount below minimum threshold. BelowMinimumSwap, /// Minting operations are disabled (circuit breaker level >= 1). @@ -457,17 +561,20 @@ pub mod pallet { AllSwapsStopped, /// Asset is not an approved external stablecoin. UnsupportedAsset, - /// Mint would exceed system-wide maximum internal issuance. - ExceedsMaxIssuance, + /// No PSM instance is registered for the given internal asset. + PsmNotFound, /// Asset is already in the approved list. AssetAlreadyApproved, /// Asset does not exist. AssetDoesNotExist, + /// The caller is not the owner of the internal asset, so it cannot create a PSM over it. + NotAssetOwner, /// Cannot remove asset: not in approved list. AssetNotApproved, /// Cannot remove asset: has non-zero PSM debt. AssetHasDebt, - /// Operation requires Full manager level (GeneralAdmin), not Emergency. + /// Operation requires the instance's `full_admin` (Full level); the caller only + /// matched the `emergency_admin` (Emergency level). InsufficientPrivilege, /// Maximum number of approved external assets reached. TooManyAssets, @@ -479,13 +586,19 @@ pub mod pallet { ConversionOverflow, /// Conversion to the counter-asset rounds to zero; swap would transfer nothing. AmountTooSmallAfterConversion, + /// A PSM is already registered for this internal asset. + PsmAlreadyExists, + /// The PSM has non-zero outstanding debt on at least one approved external. + PsmHasDebt, + /// The PSM still has approved externals; remove them before removing the PSM. + PsmHasApprovedExternals, /// An unexpected invariant violation occurred. This should be reported. Unexpected, } #[pallet::call] impl Pallet { - /// Swap external stablecoin for internal. + /// Swap external stablecoin for internal on a specific PSM instance. /// /// ## Dispatch Origin /// @@ -493,88 +606,77 @@ pub mod pallet { /// /// ## Details /// - /// Transfers `external_amount` of the specified external stablecoin from the caller - /// to the PSM account, then mints internal to the caller minus the minting fee. - /// The fee is calculated using ceiling rounding (`mul_ceil`), ensuring the - /// protocol never undercharges. The fee is transferred to [`Config::FeeDestination`]. + /// Transfers `external_amount` of `asset_id` from the caller to the + /// `internal_asset`'s PSM reserve account, then mints `internal_asset` to the + /// caller minus the minting fee. The fee is calculated using ceiling rounding + /// (`mul_ceil`), ensuring the protocol never undercharges. The fee is + /// transferred to [`PsmInfo::fee_destination`] of the targeted instance. /// /// ## Parameters /// - /// - `asset_id`: The external stablecoin to deposit (must be in `ExternalAssets`) - /// - `external_amount`: Amount of external stablecoin to deposit + /// - `internal_asset`: The internal stablecoin that identifies the PSM instance. + /// - `asset_id`: The external stablecoin to deposit (must be approved on `internal_asset`). + /// - `external_amount`: Amount of external stablecoin to deposit. /// /// ## Errors /// - /// - [`Error::UnsupportedAsset`]: If `asset_id` is not an approved external stablecoin - /// - [`Error::MintingStopped`]: If circuit breaker is at `MintingDisabled` or higher - /// - [`Error::BelowMinimumSwap`]: If `external_amount` is below [`Config::MinSwapAmount`] - /// - [`Error::ExceedsMaxIssuance`]: If minting would exceed system-wide internal issuance - /// cap - /// - [`Error::ExceedsMaxPsmDebt`]: If minting would exceed PSM debt ceiling (aggregate or - /// per-asset) - /// - [`Error::DecimalsMismatch`]: If the asset's decimals do not match the internal asset's - /// decimals - /// - [`Error::AmountTooSmallAfterConversion`]: if the conversion to the counter-asset - /// rounds to zero; swap would transfer nothing + /// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`. + /// - [`Error::UnsupportedAsset`]: If `asset_id` is not approved on this PSM. + /// - [`Error::MintingStopped`]: If the per-external circuit breaker is at `MintingDisabled` + /// or higher. + /// - [`Error::BelowMinimumSwap`]: If `external_amount` is below [`Config::MinSwapAmount`]. + /// - [`Error::ExceedsMaxPsmDebt`]: If minting would exceed this PSM's debt ceiling + /// (aggregate or per-asset). + /// - [`Error::DecimalsMismatch`]: If live decimals diverged from the snapshot taken at + /// registration. + /// - [`Error::AmountTooSmallAfterConversion`]: If the conversion to the counter-asset + /// rounds to zero; swap would transfer nothing. /// /// ## Events /// - /// - [`Event::Minted`]: Emitted on successful mint + /// - [`Event::Minted`]: Emitted on successful mint. #[pallet::call_index(0)] - #[pallet::weight(T::WeightInfo::mint(T::MaxExternalAssets::get()))] + #[pallet::weight(T::WeightInfo::mint(T::MaxExternalAssetsPerPsm::get()))] pub fn mint( origin: OriginFor, + internal_asset: T::AssetId, asset_id: T::AssetId, external_amount: BalanceOf, ) -> DispatchResult { let who = ensure_signed(origin)?; + let info = Psm::::get(&internal_asset).ok_or(Error::::PsmNotFound)?; - // Check asset is approved and minting is enabled - let asset_status = - ExternalAssets::::get(&asset_id).ok_or(Error::::UnsupportedAsset)?; - ensure!(asset_status.allows_minting(), Error::::MintingStopped); + let external = ExternalAssets::::get(&internal_asset, &asset_id) + .ok_or(Error::::UnsupportedAsset)?; + ensure!(external.status.allows_minting(), Error::::MintingStopped); - // Guard against runtime drift in live decimals. - let (ext_decimals, internal_decimals) = Self::ensure_decimals_match(asset_id.clone())?; + let (ext_decimals, internal_decimals) = + Self::ensure_decimals_match(&info, &internal_asset, &asset_id, &external)?; - // Normalize to internal units for all internal accounting. let internal_equivalent = Self::external_to_internal(external_amount, ext_decimals, internal_decimals)?; ensure!(!internal_equivalent.is_zero(), Error::::AmountTooSmallAfterConversion); ensure!(internal_equivalent >= T::MinSwapAmount::get(), Error::::BelowMinimumSwap); - // Round-trip back to external units. Truncation dust stays in the user's wallet — only - // `effective_external` enters the reserve. let effective_external = Self::internal_to_external(internal_equivalent, ext_decimals, internal_decimals)?; - let fee = MintingFee::::get(&asset_id).mul_ceil(internal_equivalent); + let fee = + MintingFee::::get(&internal_asset, &asset_id).mul_ceil(internal_equivalent); let internal_to_user = internal_equivalent.saturating_sub(fee); - // Total new issuance = internal_to_user + fee = internal_equivalent. - let current_total_issuance = T::InternalAsset::total_issuance(); - let max_issuance = T::MaximumIssuance::get(); + let current_total_psm_debt = Self::total_psm_debt(&internal_asset); ensure!( - current_total_issuance.saturating_add(internal_equivalent) <= max_issuance, - Error::::ExceedsMaxIssuance - ); - - // Check aggregate PSM ceiling across all assets (internal units). - let current_total_psm_debt = Self::total_psm_debt(); - let max_psm = Self::max_psm_debt(); - ensure!( - current_total_psm_debt.saturating_add(internal_equivalent) <= max_psm, + current_total_psm_debt.saturating_add(internal_equivalent) <= info.max_debt, Error::::ExceedsMaxPsmDebt ); - // Check per-asset ceiling (redistributes from disabled assets). - let current_debt = PsmDebt::::get(&asset_id); - let max_debt = Self::max_asset_debt(asset_id.clone()); + let current_debt = PsmDebt::::get(&internal_asset, &asset_id); + let max_debt = Self::max_asset_debt(&internal_asset, &asset_id, &info); let new_debt = current_debt.saturating_add(internal_equivalent); ensure!(new_debt <= max_debt, Error::::ExceedsMaxPsmDebt); - let psm_account = Self::account_id(); - + let psm_account = Self::psm_account(&internal_asset); T::Fungibles::transfer( asset_id.clone(), &who, @@ -582,25 +684,25 @@ pub mod pallet { effective_external, Preservation::Expendable, )?; - T::InternalAsset::mint_into(&who, internal_to_user)?; + T::Fungibles::mint_into(internal_asset.clone(), &who, internal_to_user)?; if !fee.is_zero() { - T::InternalAsset::mint_into(&T::FeeDestination::get(), fee)?; + T::Fungibles::mint_into(internal_asset.clone(), &info.fee_destination, fee)?; } - PsmDebt::::insert(&asset_id, new_debt); + PsmDebt::::insert(&internal_asset, &asset_id, new_debt); Self::deposit_event(Event::Minted { + internal_asset, who, asset_id, external_amount: effective_external, received: internal_to_user, fee, }); - Ok(()) } - /// Swap internal for external stablecoin. + /// Swap internal for external stablecoin on a specific PSM instance. /// /// ## Dispatch Origin /// @@ -608,61 +710,59 @@ pub mod pallet { /// /// ## Details /// - /// Burns `amount` internal from the caller minus fee (transferred to - /// [`Config::FeeDestination`]), then transfers the resulting amount in external - /// stablecoin from PSM to the caller. The fee is calculated using ceiling rounding - /// (`mul_ceil`), ensuring the protocol never undercharges. + /// Burns `amount` of `internal_asset` from the caller minus fee (transferred to + /// the instance's [`PsmInfo::fee_destination`]), then transfers the resulting + /// amount in `asset_id` from the PSM reserve to the caller. The fee is + /// calculated using ceiling rounding (`mul_ceil`), ensuring the protocol never + /// undercharges. /// /// ## Parameters /// - /// - `asset_id`: The external stablecoin to receive (must be in `ExternalAssets`) - /// - `amount`: Amount of internal to redeem + /// - `internal_asset`: The internal stablecoin that identifies the PSM instance. + /// - `asset_id`: The external stablecoin to receive (must be approved on `internal_asset`). + /// - `amount`: Amount of `internal_asset` to redeem. /// /// ## Errors /// - /// - [`Error::UnsupportedAsset`]: If `asset_id` is not an approved external stablecoin - /// - [`Error::AllSwapsStopped`]: If circuit breaker is at `AllDisabled` - /// - [`Error::BelowMinimumSwap`]: If `amount` is below [`Config::MinSwapAmount`] - /// - [`Error::InsufficientReserve`]: If PSM has insufficient external stablecoin - /// - [`Error::DecimalsMismatch`]: If the asset's decimals do not match the internal asset's - /// decimals - /// - [`Error::AmountTooSmallAfterConversion`]: if the conversion to the counter-asset - /// rounds to zero; swap would transfer nothing + /// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`. + /// - [`Error::UnsupportedAsset`]: If `asset_id` is not approved on this PSM. + /// - [`Error::AllSwapsStopped`]: If the per-external circuit breaker is at `AllDisabled`. + /// - [`Error::BelowMinimumSwap`]: If `amount` is below [`Config::MinSwapAmount`]. + /// - [`Error::InsufficientReserve`]: If the PSM holds less of `asset_id` than the + /// redemption requires. + /// - [`Error::DecimalsMismatch`]: If live decimals diverged from the snapshot taken at + /// registration. + /// - [`Error::AmountTooSmallAfterConversion`]: If the conversion to the counter-asset + /// rounds to zero; swap would transfer nothing. /// /// ## Events /// - /// - [`Event::Redeemed`]: Emitted on successful redemption + /// - [`Event::Redeemed`]: Emitted on successful redemption. #[pallet::call_index(1)] #[pallet::weight(T::WeightInfo::redeem())] pub fn redeem( origin: OriginFor, + internal_asset: T::AssetId, asset_id: T::AssetId, amount: BalanceOf, ) -> DispatchResult { let who = ensure_signed(origin)?; + let info = Psm::::get(&internal_asset).ok_or(Error::::PsmNotFound)?; - // Check asset is approved and redemption is enabled - let asset_status = - ExternalAssets::::get(&asset_id).ok_or(Error::::UnsupportedAsset)?; - ensure!(asset_status.allows_redemption(), Error::::AllSwapsStopped); + let external = ExternalAssets::::get(&internal_asset, &asset_id) + .ok_or(Error::::UnsupportedAsset)?; + ensure!(external.status.allows_redemption(), Error::::AllSwapsStopped); - // Guard against runtime drift in live decimals. - let (ext_decimals, internal_decimals) = Self::ensure_decimals_match(asset_id.clone())?; + let (ext_decimals, internal_decimals) = + Self::ensure_decimals_match(&info, &internal_asset, &asset_id, &external)?; ensure!(amount >= T::MinSwapAmount::get(), Error::::BelowMinimumSwap); - let fee = RedemptionFee::::get(&asset_id).mul_ceil(amount); + let fee = RedemptionFee::::get(&internal_asset, &asset_id).mul_ceil(amount); let internal_net = amount.saturating_sub(fee); - // Convert internal-net to external units (floor) and round-trip back. The round-tripped - // amount (`effective_internal_net`) is what is actually burned and what the tracked - // debt decreases by. Any truncation dust stays in the caller's internal balance — - // symmetric with `mint`, which only takes the round-tripped share of the external - // amount from the caller. let external_out = Self::internal_to_external(internal_net, ext_decimals, internal_decimals)?; - // Reject only when truncation wipes a non-zero net amount; a legitimately zero net - // (e.g., 100% fee) continues without an external transfer. ensure!( internal_net.is_zero() || !external_out.is_zero(), Error::::AmountTooSmallAfterConversion @@ -670,30 +770,28 @@ pub mod pallet { let effective_internal_net = Self::external_to_internal(external_out, ext_decimals, internal_decimals)?; - // Check debt first - redemptions are limited by tracked debt, not raw reserve. - // This prevents redemption of "donated" reserves that aren't backed by debt. - let current_debt = PsmDebt::::get(&asset_id); + let current_debt = PsmDebt::::get(&internal_asset, &asset_id); ensure!(current_debt >= effective_internal_net, Error::::InsufficientReserve); - let reserve = Self::get_reserve(asset_id.clone()); + let reserve = Self::get_reserve(&internal_asset, &asset_id); if reserve < external_out { defensive!("PSM reserve is less than expected output amount"); return Err(Error::::Unexpected.into()); } - // Transfer the nominal fee to the destination, then burn the redeemed portion. - // Round-trip dust is not charged. if !fee.is_zero() { - T::InternalAsset::transfer( + T::Fungibles::transfer( + internal_asset.clone(), &who, - &T::FeeDestination::get(), + &info.fee_destination, fee, Preservation::Expendable, )?; } if !effective_internal_net.is_zero() { - T::InternalAsset::burn_from( + T::Fungibles::burn_from( + internal_asset.clone(), &who, effective_internal_net, Preservation::Expendable, @@ -702,7 +800,7 @@ pub mod pallet { )?; } - let psm_account = Self::account_id(); + let psm_account = Self::psm_account(&internal_asset); if !external_out.is_zero() { T::Fungibles::transfer( asset_id.clone(), @@ -713,78 +811,102 @@ pub mod pallet { )?; } - PsmDebt::::mutate(&asset_id, |debt| { + PsmDebt::::mutate(&internal_asset, &asset_id, |debt| { *debt = debt.saturating_sub(effective_internal_net); }); Self::deposit_event(Event::Redeemed { + internal_asset, who, asset_id, paid: effective_internal_net.saturating_add(fee), external_received: external_out, fee, }); - Ok(()) } - /// Set the minting fee for a specific asset (external → internal). + /// Set the minting fee for an `(internal_asset, asset_id)` pair. /// /// ## Dispatch Origin /// - /// Must be [`Config::ManagerOrigin`]. + /// Must match the PSM instance's `full_admin` (the `Full` privilege level). /// /// ## Parameters /// - /// - `asset_id`: The external stablecoin to configure - /// - `fee`: The new minting fee as a Permill + /// - `internal_asset`: The PSM instance to configure. + /// - `asset_id`: The external stablecoin whose minting fee is being updated. + /// - `fee`: The new minting fee. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges. + /// - [`Error::AssetNotApproved`]: If `asset_id` is not approved on `internal_asset`. /// /// ## Events /// - /// - [`Event::MintingFeeUpdated`]: Emitted with old and new values + /// - [`Event::MintingFeeUpdated`]: Emitted with old and new values. #[pallet::call_index(2)] #[pallet::weight(T::WeightInfo::set_minting_fee())] pub fn set_minting_fee( origin: OriginFor, + internal_asset: T::AssetId, asset_id: T::AssetId, fee: Permill, ) -> DispatchResult { - let level = T::ManagerOrigin::ensure_origin(origin)?; - ensure!(level.can_set_fees(), Error::::InsufficientPrivilege); - ensure!(ExternalAssets::::contains_key(&asset_id), Error::::AssetNotApproved); - let old_value = MintingFee::::get(&asset_id); - MintingFee::::insert(&asset_id, fee); - Self::deposit_event(Event::MintingFeeUpdated { asset_id, old_value, new_value: fee }); + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_fees())?; + ensure!( + ExternalAssets::::contains_key(&internal_asset, &asset_id), + Error::::AssetNotApproved + ); + let old_value = MintingFee::::get(&internal_asset, &asset_id); + MintingFee::::insert(&internal_asset, &asset_id, fee); + Self::deposit_event(Event::MintingFeeUpdated { + internal_asset, + asset_id, + old_value, + new_value: fee, + }); Ok(()) } - /// Set the redemption fee for a specific asset (internal → external). + /// Set the redemption fee for an `(internal_asset, asset_id)` pair. /// /// ## Dispatch Origin /// - /// Must be [`Config::ManagerOrigin`]. + /// Must match the PSM instance's `full_admin` (the `Full` privilege level). /// /// ## Parameters /// - /// - `asset_id`: The external stablecoin to configure - /// - `fee`: The new redemption fee as a Permill + /// - `internal_asset`: The PSM instance to configure. + /// - `asset_id`: The external stablecoin whose redemption fee is being updated. + /// - `fee`: The new redemption fee. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges. + /// - [`Error::AssetNotApproved`]: If `asset_id` is not approved on `internal_asset`. /// /// ## Events /// - /// - [`Event::RedemptionFeeUpdated`]: Emitted with old and new values + /// - [`Event::RedemptionFeeUpdated`]: Emitted with old and new values. #[pallet::call_index(3)] #[pallet::weight(T::WeightInfo::set_redemption_fee())] pub fn set_redemption_fee( origin: OriginFor, + internal_asset: T::AssetId, asset_id: T::AssetId, fee: Permill, ) -> DispatchResult { - let level = T::ManagerOrigin::ensure_origin(origin)?; - ensure!(level.can_set_fees(), Error::::InsufficientPrivilege); - ensure!(ExternalAssets::::contains_key(&asset_id), Error::::AssetNotApproved); - let old_value = RedemptionFee::::get(&asset_id); - RedemptionFee::::insert(&asset_id, fee); + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_fees())?; + ensure!( + ExternalAssets::::contains_key(&internal_asset, &asset_id), + Error::::AssetNotApproved + ); + let old_value = RedemptionFee::::get(&internal_asset, &asset_id); + RedemptionFee::::insert(&internal_asset, &asset_id, fee); Self::deposit_event(Event::RedemptionFeeUpdated { + internal_asset, asset_id, old_value, new_value: fee, @@ -792,101 +914,142 @@ pub mod pallet { Ok(()) } - /// Set the maximum PSM debt as a percentage of total maximum issuance. + /// Set the absolute PSM debt ceiling of a specific PSM instance. /// /// ## Dispatch Origin /// - /// Must be [`Config::ManagerOrigin`]. + /// Must match the PSM instance's `full_admin` or `emergency_admin`; either the + /// `Full` or `Emergency` privilege level may use this call. + /// + /// ## Parameters + /// + /// - `internal_asset`: The PSM instance to configure. + /// - `value`: The new absolute debt ceiling, in internal-asset units. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If the origin level cannot set the debt ceiling. + /// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`. /// /// ## Events /// - /// - [`Event::MaxPsmDebtOfTotalUpdated`]: Emitted with old and new values + /// - [`Event::MaxDebtUpdated`]: Emitted with old and new values. #[pallet::call_index(4)] - #[pallet::weight(T::WeightInfo::set_max_psm_debt())] - pub fn set_max_psm_debt(origin: OriginFor, ratio: Permill) -> DispatchResult { - let level = T::ManagerOrigin::ensure_origin(origin)?; - ensure!(level.can_set_max_psm_debt(), Error::::InsufficientPrivilege); - let old_value = MaxPsmDebtOfTotal::::get(); - MaxPsmDebtOfTotal::::put(ratio); - Self::deposit_event(Event::MaxPsmDebtOfTotalUpdated { old_value, new_value: ratio }); - Ok(()) + #[pallet::weight(T::WeightInfo::set_max_debt())] + pub fn set_max_debt( + origin: OriginFor, + internal_asset: T::AssetId, + value: BalanceOf, + ) -> DispatchResult { + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_max_debt())?; + // `max_debt` is a hard cap: reject any value that would push an external's + // normalised ceiling below its outstanding debt. To halt minting / wind a + // PSM down, use the per-asset circuit breaker (`set_asset_status`) instead. + Self::ensure_ceilings_cover_debt(&internal_asset, value, None)?; + Psm::::try_mutate(&internal_asset, |maybe| -> DispatchResult { + let info = maybe.as_mut().ok_or(Error::::PsmNotFound)?; + let old_value = info.max_debt; + info.max_debt = value; + Self::deposit_event(Event::MaxDebtUpdated { + internal_asset: internal_asset.clone(), + old_value, + new_value: value, + }); + Ok(()) + }) } - /// Set the circuit breaker status for a specific external asset. + /// Set the per-external circuit breaker on a PSM instance. /// /// ## Dispatch Origin /// - /// Must be [`Config::ManagerOrigin`]. - /// - /// ## Details - /// - /// Controls which operations are allowed for this asset: - /// - [`CircuitBreakerLevel::AllEnabled`]: All swaps allowed - /// - [`CircuitBreakerLevel::MintingDisabled`]: Only redemptions allowed (useful for - /// draining debt) - /// - [`CircuitBreakerLevel::AllDisabled`]: No swaps allowed + /// Must match the PSM instance's `full_admin` or `emergency_admin`; either the + /// `Full` or `Emergency` privilege level may use this call. /// /// ## Parameters /// - /// - `asset_id`: The external stablecoin to configure - /// - `status`: The new circuit breaker level for this asset + /// - `internal_asset`: The PSM instance to configure. + /// - `asset_id`: The external stablecoin whose status is being updated. + /// - `status`: The new circuit breaker level for that external. /// /// ## Errors /// - /// - [`Error::AssetNotApproved`]: If the asset is not in the approved list + /// - [`Error::AssetNotApproved`]: If `asset_id` is not approved on `internal_asset`. /// /// ## Events /// - /// - [`Event::AssetStatusUpdated`]: Emitted with the asset ID and new status + /// - [`Event::AssetStatusUpdated`]: Emitted on a successful update. #[pallet::call_index(5)] #[pallet::weight(T::WeightInfo::set_asset_status())] pub fn set_asset_status( origin: OriginFor, + internal_asset: T::AssetId, asset_id: T::AssetId, status: CircuitBreakerLevel, ) -> DispatchResult { - T::ManagerOrigin::ensure_origin(origin)?; - ensure!(ExternalAssets::::contains_key(&asset_id), Error::::AssetNotApproved); - ExternalAssets::::insert(&asset_id, status); - Self::deposit_event(Event::AssetStatusUpdated { asset_id, status }); + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_circuit_breaker())?; + ExternalAssets::::try_mutate( + &internal_asset, + &asset_id, + |maybe| -> DispatchResult { + let info = maybe.as_mut().ok_or(Error::::AssetNotApproved)?; + info.status = status; + Ok(()) + }, + )?; + Self::deposit_event(Event::AssetStatusUpdated { internal_asset, asset_id, status }); Ok(()) } - /// Set the per-asset debt ceiling weight. + /// Set the per-external ceiling weight on a PSM instance. /// - /// ## Dispatch Origin + /// Weights are normalised against the sum of weights within the same instance: + /// `max_asset_debt = (weight / sum_of_weights) * info.max_debt`. /// - /// Must be [`Config::ManagerOrigin`]. + /// ## Dispatch Origin /// - /// ## Details + /// Must match the PSM instance's `full_admin` or `emergency_admin`; either the + /// `Full` or `Emergency` privilege level may use this call. /// - /// Ratios act as weights normalized against the sum of all asset weights: - /// `max_asset_debt = (ratio / sum_of_all_ratios) * MaxPsmDebtOfTotal * MaximumIssuance` + /// ## Parameters /// - /// With a single asset, the weight always normalizes to 100% of the PSM - /// ceiling. + /// - `internal_asset`: The PSM instance to configure. + /// - `asset_id`: The external stablecoin whose ceiling weight is being updated. + /// - `weight`: The new ceiling weight. Zero disables minting for this external. /// - /// ## Parameters + /// ## Errors /// - /// - `asset_id`: The external stablecoin to configure - /// - `ratio`: Weight for this asset's share of the total PSM ceiling + /// - [`Error::InsufficientPrivilege`]: If the origin level cannot set ceiling weights. + /// - [`Error::AssetNotApproved`]: If `asset_id` is not approved on `internal_asset`. /// /// ## Events /// - /// - [`Event::AssetCeilingWeightUpdated`]: Emitted with old and new values + /// - [`Event::AssetCeilingWeightUpdated`]: Emitted with old and new values. #[pallet::call_index(6)] #[pallet::weight(T::WeightInfo::set_asset_ceiling_weight())] pub fn set_asset_ceiling_weight( origin: OriginFor, + internal_asset: T::AssetId, asset_id: T::AssetId, weight: Permill, ) -> DispatchResult { - let level = T::ManagerOrigin::ensure_origin(origin)?; - ensure!(level.can_set_asset_ceiling(), Error::::InsufficientPrivilege); - ensure!(ExternalAssets::::contains_key(&asset_id), Error::::AssetNotApproved); - let old_value = AssetCeilingWeight::::get(&asset_id); - AssetCeilingWeight::::insert(&asset_id, weight); + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_set_asset_ceiling())?; + ensure!( + ExternalAssets::::contains_key(&internal_asset, &asset_id), + Error::::AssetNotApproved + ); + // Reweighting renormalises every external's ceiling, so reject the change if it + // would leave any approved external's debt above its new ceiling. + let info = Psm::::get(&internal_asset).ok_or(Error::::PsmNotFound)?; + Self::ensure_ceilings_cover_debt( + &internal_asset, + info.max_debt, + Some((&asset_id, weight)), + )?; + let old_value = AssetCeilingWeight::::get(&internal_asset, &asset_id); + AssetCeilingWeight::::insert(&internal_asset, &asset_id, weight); Self::deposit_event(Event::AssetCeilingWeightUpdated { + internal_asset, asset_id, old_value, new_value: weight, @@ -894,156 +1057,489 @@ pub mod pallet { Ok(()) } - /// Add an external stablecoin to the approved list. + /// Approve an external stablecoin on a PSM instance. + /// + /// Snapshots the external asset's live decimals at registration time and + /// increments [`PsmInfo::external_count`]. /// /// ## Dispatch Origin /// - /// Must be [`Config::ManagerOrigin`]. + /// Must match the PSM instance's `full_admin` (the `Full` privilege level). /// /// ## Parameters /// - /// - `asset_id`: The external stablecoin to add + /// - `internal_asset`: The PSM instance to approve the external on. + /// - `asset_id`: The external stablecoin to approve. /// /// ## Errors /// - /// - [`Error::AssetAlreadyApproved`]: If the asset is already in the approved list + /// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges. + /// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`. + /// - [`Error::TooManyAssets`]: If the PSM is already at + /// [`Config::MaxExternalAssetsPerPsm`]. + /// - [`Error::AssetAlreadyApproved`]: If `asset_id` is already approved on this PSM. + /// - [`Error::AssetDoesNotExist`]: If `asset_id` does not exist in the underlying fungibles + /// backend. + /// - [`Error::DecimalsMismatch`]: If the internal asset's live decimals diverged from the + /// snapshot in [`PsmInfo`]. + /// - [`Error::DecimalsRangeExceeded`]: If `|asset_decimals − internal_decimals|` exceeds + /// [`MAX_DECIMALS_DIFF`]. /// /// ## Events /// - /// - [`Event::ExternalAssetAdded`]: Emitted on successful addition + /// - [`Event::ExternalAssetAdded`]: Emitted on a successful approval. #[pallet::call_index(7)] #[pallet::weight(T::WeightInfo::add_external_asset())] - pub fn add_external_asset(origin: OriginFor, asset_id: T::AssetId) -> DispatchResult { - let level = T::ManagerOrigin::ensure_origin(origin)?; - ensure!(level.can_manage_assets(), Error::::InsufficientPrivilege); - ensure!( - !ExternalAssets::::contains_key(&asset_id), - Error::::AssetAlreadyApproved - ); - ensure!(T::Fungibles::asset_exists(asset_id.clone()), Error::::AssetDoesNotExist); - let count = ExternalAssets::::count(); - ensure!(count < T::MaxExternalAssets::get(), Error::::TooManyAssets); + pub fn add_external_asset( + origin: OriginFor, + internal_asset: T::AssetId, + asset_id: T::AssetId, + ) -> DispatchResult { + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_assets())?; + Psm::::try_mutate(&internal_asset, |maybe| -> DispatchResult { + let info = maybe.as_mut().ok_or(Error::::PsmNotFound)?; + ensure!( + !ExternalAssets::::contains_key(&internal_asset, &asset_id), + Error::::AssetAlreadyApproved + ); + ensure!( + info.external_count < T::MaxExternalAssetsPerPsm::get(), + Error::::TooManyAssets + ); + ensure!( + T::Fungibles::asset_exists(asset_id.clone()), + Error::::AssetDoesNotExist + ); - let asset_decimals = T::Fungibles::decimals(asset_id.clone()); - let internal_decimals = InternalDecimals::::get().ok_or(Error::::Unexpected)?; + let asset_decimals = T::Fungibles::decimals(asset_id.clone()); + ensure!( + T::Fungibles::decimals(internal_asset.clone()) == info.internal_decimals, + Error::::DecimalsMismatch + ); + ensure!( + (asset_decimals.abs_diff(info.internal_decimals) as u32) <= MAX_DECIMALS_DIFF, + Error::::DecimalsRangeExceeded + ); + + ExternalAssets::::insert( + &internal_asset, + &asset_id, + ExternalAssetInfo { + status: CircuitBreakerLevel::AllEnabled, + decimals: asset_decimals, + }, + ); + info.external_count = info.external_count.saturating_add(1); + Self::deposit_event(Event::ExternalAssetAdded { + internal_asset: internal_asset.clone(), + asset_id, + }); + Ok(()) + }) + } + + /// Remove an external stablecoin from a PSM instance. + /// + /// Wipes the external's per-instance state (status, decimals, fees, ceiling + /// weight, debt counter) and decrements [`PsmInfo::external_count`]. The + /// external must have zero outstanding debt on this instance. + /// + /// ## Dispatch Origin + /// + /// Must match the PSM instance's `full_admin` (the `Full` privilege level). + /// + /// ## Parameters + /// + /// - `internal_asset`: The PSM instance to remove the external from. + /// - `asset_id`: The external stablecoin to remove. + /// + /// ## Errors + /// + /// - [`Error::InsufficientPrivilege`]: If the origin only has `Emergency` privileges. + /// - [`Error::PsmNotFound`]: If no PSM is registered for `internal_asset`. + /// - [`Error::AssetNotApproved`]: If `asset_id` is not approved on this PSM. + /// - [`Error::AssetHasDebt`]: If the external still has non-zero outstanding debt. + /// + /// ## Events + /// + /// - [`Event::ExternalAssetRemoved`]: Emitted on a successful removal. + #[pallet::call_index(8)] + #[pallet::weight(T::WeightInfo::remove_external_asset())] + pub fn remove_external_asset( + origin: OriginFor, + internal_asset: T::AssetId, + asset_id: T::AssetId, + ) -> DispatchResult { + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_assets())?; + Psm::::try_mutate(&internal_asset, |maybe| -> DispatchResult { + let info = maybe.as_mut().ok_or(Error::::PsmNotFound)?; + ensure!( + ExternalAssets::::contains_key(&internal_asset, &asset_id), + Error::::AssetNotApproved + ); + ensure!( + PsmDebt::::get(&internal_asset, &asset_id).is_zero(), + Error::::AssetHasDebt + ); + ExternalAssets::::remove(&internal_asset, &asset_id); + MintingFee::::remove(&internal_asset, &asset_id); + RedemptionFee::::remove(&internal_asset, &asset_id); + AssetCeilingWeight::::remove(&internal_asset, &asset_id); + PsmDebt::::remove(&internal_asset, &asset_id); + info.external_count = info.external_count.saturating_sub(1); + Self::deposit_event(Event::ExternalAssetRemoved { + internal_asset: internal_asset.clone(), + asset_id, + }); + Ok(()) + }) + } + + /// Permissionlessly create a PSM. + /// + /// Reserves [`Config::CreationDeposit`] from the signer's native balance. Both + /// `full_admin` and `emergency_admin` are initialised to the signer's `Signed` origin + /// and may later be reassigned via [`Pallet::set_full_admin`] / + /// [`Pallet::set_emergency_admin`]. + /// + /// The caller must be the owner of `internal_asset`. The PSM mints/burns the internal + /// asset through the privileged `fungibles` trait path, so restricting creation to the + /// asset's owner is what prevents wrapping an asset you don't control and minting it + /// against worthless collateral. The owner keeps ownership and may run other minters + /// (e.g. a vault) over the same asset; its holders trust its owner regardless. + /// + /// ## Dispatch Origin + /// + /// Signed by the owner of `internal_asset`. + /// + /// ## Parameters + /// + /// - `internal_asset`: The internal stablecoin keying the new PSM. Must exist in the + /// fungibles backend and be owned by the caller; must not already have a PSM registered. + /// - `fee_destination`: Account that will receive mint/redeem fees. + /// - `max_debt`: Initial absolute internal-asset debt ceiling. + /// + /// ## Errors + /// + /// - [`Error::PsmAlreadyExists`]: A PSM is already registered for `internal_asset`. + /// - [`Error::AssetDoesNotExist`]: The internal asset does not exist. + /// - [`Error::NotAssetOwner`]: The caller is not the owner of `internal_asset`. + /// + /// ## Events + /// + /// - [`Event::PsmCreated`]. + #[pallet::call_index(9)] + #[pallet::weight(T::WeightInfo::create_psm())] + pub fn create_psm( + origin: OriginFor, + internal_asset: T::AssetId, + fee_destination: T::AccountId, + max_debt: BalanceOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(!Psm::::contains_key(&internal_asset), Error::::PsmAlreadyExists); ensure!( - T::InternalAsset::decimals() == internal_decimals, - Error::::DecimalsMismatch + T::Fungibles::asset_exists(internal_asset.clone()), + Error::::AssetDoesNotExist ); + // The PSM mints and burns the internal asset through the privileged `fungibles` + // trait path, which bypasses pallet-assets' issuer check. Require the caller to be + // the asset's owner so a PSM can only be created by the party that controls the + // asset. ensure!( - (asset_decimals.abs_diff(internal_decimals) as u32) <= MAX_DECIMALS_DIFF, - Error::::DecimalsRangeExceeded + T::Fungibles::owner(internal_asset.clone()) == Some(who.clone()), + Error::::NotAssetOwner ); - ExternalAssets::::insert(&asset_id, CircuitBreakerLevel::AllEnabled); - ExternalDecimals::::insert(&asset_id, asset_decimals); - Self::deposit_event(Event::ExternalAssetAdded { asset_id }); + let deposit = T::CreationDeposit::get(); + T::Currency::reserve(&who, deposit)?; + + let signer_origin: T::PalletsOrigin = + frame_system::RawOrigin::Signed(who.clone()).into(); + let internal_decimals = T::Fungibles::decimals(internal_asset.clone()); + Psm::::insert( + &internal_asset, + PsmInfo:: { + fee_destination: fee_destination.clone(), + max_debt, + internal_decimals, + external_count: 0, + }, + ); + PsmAdmin::::insert( + &internal_asset, + PsmAdminInfo:: { + full_admin: signer_origin.clone(), + emergency_admin: signer_origin.clone(), + depositor: who, + deposit, + }, + ); + // Acquire a provider reference on the reserve account and the fee destination for the + // lifetime of this PSM, so they can hold non-sufficient assets (external collateral / + // minted fees). Released in `remove_psm`. Unconditional (rather than + // `ensure_account_exists`) so the inc/dec is symmetric even when an account already + // exists or is shared across PSMs. + frame_system::Pallet::::inc_providers(&Self::psm_account(&internal_asset)); + frame_system::Pallet::::inc_providers(&fee_destination); + + Self::deposit_event(Event::PsmCreated { + internal_asset, + full_admin: signer_origin.clone(), + emergency_admin: signer_origin, + fee_destination, + max_debt, + }); Ok(()) } - /// Remove an external stablecoin from the approved list. + /// Remove a PSM. Callable by the current `full_admin`. All approved externals + /// must be removed first and aggregate PSM debt must be zero. + /// + /// The creation deposit is always returned to the account that originally paid it + /// (the depositor), regardless of any later admin reassignment. /// /// ## Dispatch Origin /// - /// Must be [`Config::ManagerOrigin`]. + /// Must match the PSM's `full_admin`. /// - /// ## Details + /// ## Parameters + /// + /// - `internal_asset`: The PSM instance to remove. + /// + /// ## Errors + /// + /// - [`Error::PsmNotFound`]: No PSM is registered for `internal_asset`. + /// - [`Error::PsmHasApprovedExternals`]: Approved externals still exist. + /// - [`Error::PsmHasDebt`]: Outstanding aggregate debt is non-zero. + /// + /// ## Events + /// + /// - [`Event::PsmRemoved`]. + #[pallet::call_index(10)] + #[pallet::weight(T::WeightInfo::remove_psm())] + pub fn remove_psm(origin: OriginFor, internal_asset: T::AssetId) -> DispatchResult { + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_assets())?; + let info = Psm::::get(&internal_asset).ok_or(Error::::PsmNotFound)?; + ensure!(info.external_count == 0, Error::::PsmHasApprovedExternals); + ensure!(Self::total_psm_debt(&internal_asset).is_zero(), Error::::PsmHasDebt); + + let admin = PsmAdmin::::get(&internal_asset).ok_or(Error::::PsmNotFound)?; + if !admin.deposit.is_zero() { + T::Currency::unreserve(&admin.depositor, admin.deposit); + } + + Psm::::remove(&internal_asset); + PsmAdmin::::remove(&internal_asset); + + // Release the provider references acquired in `create_psm`. Reaps each account when + // empty; a `ConsumerRemaining` error just means it still holds funds and must stay + // alive, so the result is intentionally discarded. + frame_system::Pallet::::dec_providers(&Self::psm_account(&internal_asset)).ok(); + frame_system::Pallet::::dec_providers(&info.fee_destination).ok(); + + Self::deposit_event(Event::PsmRemoved { internal_asset }); + Ok(()) + } + + /// Reassign the PSM's `full_admin`. Callable by the current `full_admin`. /// - /// The asset cannot be removed if it has non-zero PSM debt outstanding. - /// This prevents orphaned debt that cannot be redeemed. + /// ## Dispatch Origin /// - /// Upon removal, the associated configuration is also cleaned up: - /// - `MintingFee` for this asset - /// - `RedemptionFee` for this asset - /// - `AssetCeilingWeight` for this asset + /// Must match the PSM's current `full_admin`. /// /// ## Parameters /// - /// - `asset_id`: The external stablecoin to remove + /// - `internal_asset`: The PSM whose `full_admin` is being changed. + /// - `new_admin`: The new `full_admin` origin. /// /// ## Errors /// - /// - [`Error::AssetNotApproved`]: If the asset is not in the approved list - /// - [`Error::AssetHasDebt`]: If the asset has non-zero PSM debt + /// - [`Error::PsmNotFound`]: No PSM is registered for `internal_asset`. /// /// ## Events /// - /// - [`Event::ExternalAssetRemoved`]: Emitted on successful removal - #[pallet::call_index(8)] - #[pallet::weight(T::WeightInfo::remove_external_asset())] - pub fn remove_external_asset(origin: OriginFor, asset_id: T::AssetId) -> DispatchResult { - let level = T::ManagerOrigin::ensure_origin(origin)?; - ensure!(level.can_manage_assets(), Error::::InsufficientPrivilege); - ensure!(ExternalAssets::::contains_key(&asset_id), Error::::AssetNotApproved); - ensure!(PsmDebt::::get(&asset_id).is_zero(), Error::::AssetHasDebt); - ExternalAssets::::remove(&asset_id); - - // Clean up associated configuration - MintingFee::::remove(&asset_id); - RedemptionFee::::remove(&asset_id); - AssetCeilingWeight::::remove(&asset_id); - ExternalDecimals::::remove(&asset_id); - PsmDebt::::remove(&asset_id); - Self::deposit_event(Event::ExternalAssetRemoved { asset_id }); + /// - [`Event::FullAdminChanged`]. + #[pallet::call_index(11)] + #[pallet::weight(T::WeightInfo::set_full_admin())] + pub fn set_full_admin( + origin: OriginFor, + internal_asset: T::AssetId, + new_admin: Box, + ) -> DispatchResult { + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_assets())?; + let new_admin = *new_admin; + let old_admin = PsmAdmin::::try_mutate( + &internal_asset, + |maybe| -> Result { + let admin = maybe.as_mut().ok_or(Error::::PsmNotFound)?; + let old = core::mem::replace(&mut admin.full_admin, new_admin.clone()); + Ok(old) + }, + )?; + Self::deposit_event(Event::FullAdminChanged { internal_asset, old_admin, new_admin }); + Ok(()) + } + + /// Reassign the PSM's `emergency_admin`. Callable by the current `full_admin`. + /// + /// ## Dispatch Origin + /// + /// Must match the PSM's current `full_admin`. + /// + /// ## Parameters + /// + /// - `internal_asset`: The PSM whose `emergency_admin` is being changed. + /// - `new_admin`: The new `emergency_admin` origin. + /// + /// ## Errors + /// + /// - [`Error::PsmNotFound`]: No PSM is registered for `internal_asset`. + /// + /// ## Events + /// + /// - [`Event::EmergencyAdminChanged`]. + #[pallet::call_index(12)] + #[pallet::weight(T::WeightInfo::set_emergency_admin())] + pub fn set_emergency_admin( + origin: OriginFor, + internal_asset: T::AssetId, + new_admin: Box, + ) -> DispatchResult { + Self::ensure_psm_admin(origin, &internal_asset, |l| l.can_manage_assets())?; + let new_admin = *new_admin; + let old_admin = PsmAdmin::::try_mutate( + &internal_asset, + |maybe| -> Result { + let admin = maybe.as_mut().ok_or(Error::::PsmNotFound)?; + let old = core::mem::replace(&mut admin.emergency_admin, new_admin.clone()); + Ok(old) + }, + )?; + Self::deposit_event(Event::EmergencyAdminChanged { + internal_asset, + old_admin, + new_admin, + }); Ok(()) } } impl Pallet { - /// Get the PSM's derived account. - pub(crate) fn account_id() -> T::AccountId { - T::PalletId::get().into_account_truncating() + /// Derive the reserve account for a PSM instance. + /// + /// The encoded `internal_asset` is first hashed with `blake2_256` to produce a + /// fixed-size 32-byte seed. This avoids the collision hazard of feeding an + /// arbitrarily-long encoded asset id (e.g. an XCM `Location`) into + /// `into_sub_account_truncating`, which would otherwise discard the tail and + /// collapse different assets onto the same reserve account. + pub fn psm_account(internal_asset: &T::AssetId) -> T::AccountId { + let seed = sp_io::hashing::blake2_256(&internal_asset.encode()); + T::PalletId::get().into_sub_account_truncating(seed) } - /// Calculate max PSM debt based on system ceiling. - pub(crate) fn max_psm_debt() -> BalanceOf { - let max_issuance = T::MaximumIssuance::get(); - MaxPsmDebtOfTotal::::get().mul_floor(max_issuance) + /// PSM debt ceiling for an instance, read from the stored [`PsmInfo`]. Returns + /// zero if no PSM is installed for `internal_asset`. + #[cfg(test)] + pub(crate) fn max_psm_debt(internal_asset: &T::AssetId) -> BalanceOf { + Psm::::get(internal_asset).map(|p| p.max_debt).unwrap_or_default() } - /// Calculate max debt for a specific asset. - /// - /// Assumes the caller has verified the asset is approved and `AllEnabled`. - /// - /// Returns zero if the asset has no configured weight or the weight is zero. - /// - /// Weights are normalized against the sum of all asset weights to fill the - /// PSM ceiling. - pub(crate) fn max_asset_debt(asset_id: T::AssetId) -> BalanceOf { - let asset_weight = AssetCeilingWeight::::get(asset_id); - - if asset_weight.is_zero() { - return BalanceOf::::zero(); - } + /// Calculate max debt for a specific external on a PSM. + /// + /// Weights are normalised against the sum of weights within the same instance to + /// fill the instance's `max_debt` ceiling. Returns zero if the external has no + /// configured weight or weights sum to zero. + pub(crate) fn max_asset_debt( + internal_asset: &T::AssetId, + asset_id: &T::AssetId, + info: &PsmInfo, + ) -> BalanceOf { + let asset_weight = AssetCeilingWeight::::get(internal_asset, asset_id); + let total_weight = Self::total_ceiling_weight(internal_asset); + Self::normalised_ceiling(asset_weight, total_weight, info.max_debt) + } - let total_weight_sum: u32 = AssetCeilingWeight::::iter_values() - .map(|w| w.deconstruct()) - .fold(0u32, |acc, x| acc.saturating_add(x)); + /// Sum of the configured ceiling weights across a PSM's approved externals. + fn total_ceiling_weight(internal_asset: &T::AssetId) -> u32 { + AssetCeilingWeight::::iter_prefix(internal_asset) + .map(|(_, w)| w.deconstruct()) + .fold(0u32, |acc, x| acc.saturating_add(x)) + } - if total_weight_sum == 0 { + /// A single external's normalised debt ceiling: its share of the total weight applied + /// to `max_debt`. Zero if the external (or the PSM as a whole) carries no weight. + fn normalised_ceiling( + asset_weight: Permill, + total_weight: u32, + max_debt: BalanceOf, + ) -> BalanceOf { + let weight = asset_weight.deconstruct(); + if weight == 0 || total_weight == 0 { return BalanceOf::::zero(); } + Perbill::from_rational(weight, total_weight).mul_floor(max_debt) + } - let total_psm_ceiling = Self::max_psm_debt(); - Perbill::from_rational(asset_weight.deconstruct(), total_weight_sum) - .mul_floor(total_psm_ceiling) + /// Ensure that, at the given `max_debt` and with the optional per-asset + /// `weight_override` applied, every approved external's outstanding debt still fits + /// within its normalised ceiling. Used by `set_max_debt` and `set_asset_ceiling_weight` + /// to keep `debt <= ceiling` a true state invariant (asserted in `do_try_state`): + /// because ceilings are weight-normalised, changing `max_debt` or any single weight + /// renormalises *every* external's ceiling, so each such change must re-validate them + /// all. + fn ensure_ceilings_cover_debt( + internal_asset: &T::AssetId, + max_debt: BalanceOf, + weight_override: Option<(&T::AssetId, Permill)>, + ) -> DispatchResult { + let total_weight = match weight_override { + Some((asset_id, new_weight)) => { + let old_weight = + AssetCeilingWeight::::get(internal_asset, asset_id).deconstruct(); + Self::total_ceiling_weight(internal_asset) + .saturating_sub(old_weight) + .saturating_add(new_weight.deconstruct()) + }, + None => Self::total_ceiling_weight(internal_asset), + }; + for (asset_id, debt) in PsmDebt::::iter_prefix(internal_asset) { + if debt.is_zero() { + continue; + } + let weight = match weight_override { + Some((overridden, new_weight)) if *overridden == asset_id => new_weight, + _ => AssetCeilingWeight::::get(internal_asset, &asset_id), + }; + ensure!( + debt <= Self::normalised_ceiling(weight, total_weight, max_debt), + Error::::CeilingBelowOutstandingDebt + ); + } + Ok(()) } - /// Calculate total PSM debt across all approved assets. - pub(crate) fn total_psm_debt() -> BalanceOf { - PsmDebt::::iter_values() + /// Total internal-asset debt minted through a PSM instance. + pub(crate) fn total_psm_debt(internal_asset: &T::AssetId) -> BalanceOf { + PsmDebt::::iter_prefix_values(internal_asset) .fold(BalanceOf::::zero(), |acc, debt| acc.saturating_add(debt)) } - /// Check if an asset is approved for PSM swaps. + /// Whether an external is approved on a PSM instance. #[cfg(test)] - pub(crate) fn is_approved_asset(asset_id: &T::AssetId) -> bool { - ExternalAssets::::contains_key(asset_id) + pub(crate) fn is_approved_asset( + internal_asset: &T::AssetId, + asset_id: &T::AssetId, + ) -> bool { + ExternalAssets::::contains_key(internal_asset, asset_id) } - /// Get the reserve (balance) of an external asset held by PSM. - pub(crate) fn get_reserve(asset_id: T::AssetId) -> BalanceOf { - T::Fungibles::balance(asset_id, &Self::account_id()) + /// Balance of an external held by a PSM instance's reserve account. + pub(crate) fn get_reserve( + internal_asset: &T::AssetId, + asset_id: &T::AssetId, + ) -> BalanceOf { + T::Fungibles::balance(asset_id.clone(), &Self::psm_account(internal_asset)) } /// Convert an amount denominated in external-asset units into internal units. @@ -1104,101 +1600,148 @@ pub mod pallet { factor_u128.try_into().map_err(|_| Error::::ConversionOverflow) } - /// Verify the live decimals for an external asset still match the snapshot taken at - /// registration, and that the internal asset's live decimals still match the genesis - /// snapshot. + /// Verify the live decimals for an external still match the snapshot taken at + /// registration on this PSM, and that the internal asset's live decimals still + /// match the snapshot stored in [`PsmInfo`]. pub(crate) fn ensure_decimals_match( - asset_id: T::AssetId, + info: &PsmInfo, + internal_asset: &T::AssetId, + asset_id: &T::AssetId, + external: &ExternalAssetInfo, ) -> Result<(u8, u8), DispatchError> { - let ext_decimals = - ExternalDecimals::::get(&asset_id).ok_or(Error::::UnsupportedAsset)?; - ensure!(T::Fungibles::decimals(asset_id) == ext_decimals, Error::::DecimalsMismatch); - - let internal_decimals = InternalDecimals::::get().ok_or(Error::::Unexpected)?; ensure!( - T::InternalAsset::decimals() == internal_decimals, + T::Fungibles::decimals(asset_id.clone()) == external.decimals, Error::::DecimalsMismatch ); - - Ok((ext_decimals, internal_decimals)) + ensure!( + T::Fungibles::decimals(internal_asset.clone()) == info.internal_decimals, + Error::::DecimalsMismatch + ); + Ok((external.decimals, info.internal_decimals)) } - /// Ensure an account exists by incrementing its provider count if needed. - pub(crate) fn ensure_account_exists(account: &T::AccountId) { - if !frame_system::Pallet::::account_exists(account) { - frame_system::Pallet::::inc_providers(account); - } + /// Authorise an operation on the PSM keyed by `internal_asset`. + /// + /// Matches the incoming origin's caller against the PSM's stored + /// [`PsmAdminInfo::full_admin`] (yielding `Full`) or [`PsmAdminInfo::emergency_admin`] + /// (yielding `Emergency`). The resolved level is then checked against `required`. No + /// other authority can manage a PSM. + pub(crate) fn ensure_psm_admin( + origin: OriginFor, + internal_asset: &T::AssetId, + required: impl Fn(PsmManagerLevel) -> bool, + ) -> DispatchResult { + let admin = PsmAdmin::::get(internal_asset).ok_or(Error::::PsmNotFound)?; + let caller = ::RuntimeOrigin::from(origin).into_caller(); + let level = if caller == admin.full_admin { + PsmManagerLevel::Full + } else if caller == admin.emergency_admin { + PsmManagerLevel::Emergency + } else { + return Err(DispatchError::BadOrigin); + }; + ensure!(required(level), Error::::InsufficientPrivilege); + Ok(()) } #[cfg(any(feature = "try-runtime", test))] pub(crate) fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { use sp_runtime::traits::CheckedAdd; - // Check 1: Live decimals must still match the snapshots taken at registration/genesis — - // both for the internal asset and every approved external asset. - let internal_decimals_snapshot = - InternalDecimals::::get().ok_or("InternalDecimals not initialized")?; - ensure!( - T::InternalAsset::decimals() == internal_decimals_snapshot, - "Internal asset live decimals differ from the genesis snapshot" - ); - for (asset_id, _) in ExternalAssets::::iter() { - let snapshot = ExternalDecimals::::get(&asset_id) - .ok_or("Approved external asset missing decimals snapshot")?; + for (internal_asset, info) in Psm::::iter() { + // 0. Every PSM has its paired admin record. ensure!( - T::Fungibles::decimals(asset_id) == snapshot, - "External asset live decimals differ from the registration snapshot" + PsmAdmin::::contains_key(&internal_asset), + "PSM instance without a paired PsmAdmin record" ); - } - // Check 2: Per-asset reserve (in external units) must be >= the external equivalent of - // the tracked internal debt. Donated reserves may make it strictly greater. - for (asset_id, _) in ExternalAssets::::iter() { - let debt = PsmDebt::::get(&asset_id); - let reserve = Self::get_reserve(asset_id.clone()); - let ext_decimals = ExternalDecimals::::get(&asset_id) - .ok_or("Approved external asset missing decimals snapshot")?; - let debt_as_external = - Self::internal_to_external(debt, ext_decimals, internal_decimals_snapshot) - .map_err(|_| "Failed to convert tracked debt to external units")?; + // 1. Live internal decimals must match the snapshot. ensure!( - reserve >= debt_as_external, - "PSM reserve is less than tracked debt for an asset" + T::Fungibles::decimals(internal_asset.clone()) == info.internal_decimals, + "Internal asset live decimals diverged from the snapshot" ); - } - // Check 3: Computed total PSM debt must equal sum of per-asset debts. - let mut sum = BalanceOf::::zero(); - for (asset_id, _) in ExternalAssets::::iter() { - sum = sum - .checked_add(&PsmDebt::::get(&asset_id)) - .ok_or("PSM debt overflow when summing per-asset debts")?; - } - ensure!( - Self::total_psm_debt() == sum, - "total_psm_debt() does not match sum of per-asset debts" - ); + let mut counted = 0u32; + for (asset_id, external) in ExternalAssets::::iter_prefix(&internal_asset) { + ensure!( + T::Fungibles::decimals(asset_id.clone()) == external.decimals, + "External asset live decimals diverged from the snapshot" + ); + counted = counted.saturating_add(1); + + // 2. Per-external reserve covers tracked debt. + let debt = PsmDebt::::get(&internal_asset, &asset_id); + let reserve = Self::get_reserve(&internal_asset, &asset_id); + let debt_as_external = + Self::internal_to_external(debt, external.decimals, info.internal_decimals) + .map_err(|_| "Failed to convert tracked debt to external units")?; + ensure!( + reserve >= debt_as_external, + "PSM reserve is less than tracked debt for an asset" + ); + } - // Check 4: Per-asset debt should not exceed its ceiling. - // (May be transiently violated if governance lowers ceilings, but - // should hold under normal operation.) - for (asset_id, status) in ExternalAssets::::iter() { - if status.allows_minting() { - let debt = PsmDebt::::get(&asset_id); - let ceiling = Self::max_asset_debt(asset_id); + // 3. Cached `external_count` matches the iterated externals. + ensure!( + info.external_count == counted, + "PsmInfo.external_count does not match the approved externals" + ); + + // 4. Sum of per-asset debts equals the aggregate helper. + let mut sum = BalanceOf::::zero(); + for (_, debt) in PsmDebt::::iter_prefix(&internal_asset) { + sum = sum.checked_add(&debt).ok_or("PSM debt overflow when summing")?; + } + ensure!( + sum == Self::total_psm_debt(&internal_asset), + "sum of per-asset debts disagrees with total_psm_debt" + ); + + // 5. Aggregate debt within the configured ceiling. `set_max_debt` rejects a new + // ceiling below outstanding debt, so this holds as a true state invariant. + ensure!(sum <= info.max_debt, "Aggregate PSM debt exceeds the instance's max_debt"); + + // 6. Per-asset debt within its normalised ceiling for every approved external, + // `mint` enforces it on the way up, and `set_max_debt`/`set_asset_ceiling_weight` + // reject any change that would push any external's ceiling below its debt, so + // it holds universally. + for (asset_id, _) in ExternalAssets::::iter_prefix(&internal_asset) { + let debt = PsmDebt::::get(&internal_asset, &asset_id); + let ceiling = Self::max_asset_debt(&internal_asset, &asset_id, &info); ensure!(debt <= ceiling, "Per-asset PSM debt exceeds its ceiling"); } } + // 7. No orphaned per-asset state outside registered PSMs. + for (internal_asset, _, _) in ExternalAssets::::iter() { + ensure!( + Psm::::contains_key(&internal_asset), + "Orphaned ExternalAssets row without parent PSM" + ); + } + for (internal_asset, _, _) in PsmDebt::::iter() { + ensure!( + Psm::::contains_key(&internal_asset), + "Orphaned PsmDebt row without parent PSM" + ); + } + for (internal_asset, _) in PsmAdmin::::iter() { + ensure!( + Psm::::contains_key(&internal_asset), + "Orphaned PsmAdmin row without parent PSM" + ); + } + Ok(()) } } } impl PsmInterface for pallet::Pallet { + type AssetId = T::AssetId; type Balance = pallet::BalanceOf; - fn reserved_capacity() -> Self::Balance { - Self::max_psm_debt() + fn reserved_capacity(asset: Self::AssetId) -> Self::Balance { + pallet::Psm::::get(asset).map(|p| p.max_debt).unwrap_or_default() } } diff --git a/substrate/frame/psm/src/migrations/decimals.rs b/substrate/frame/psm/src/migrations/decimals.rs deleted file mode 100644 index 344a08fdc1f19..0000000000000 --- a/substrate/frame/psm/src/migrations/decimals.rs +++ /dev/null @@ -1,342 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Amforc AG. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! One-shot migration that populates decimal snapshots for a pre-existing PSM -//! deployment. -//! -//! Purpose: chains that approved external assets before the multi-decimal upgrade -//! have entries in `ExternalAssets` but no `ExternalDecimals` snapshots, and no -//! `InternalDecimals` either. Mint and redeem both require these snapshots and -//! will fail closed (`Error::DecimalsMismatch` / `Error::Unexpected`) until they -//! are populated. This migration reads live metadata and writes the snapshots. -//! -//! Out-of-range assets are handled gracefully: if an existing asset's decimals -//! differ from the internal asset's decimals by more than [`MAX_DECIMALS_DIFF`], -//! the migration still writes its decimals snapshot but flips its circuit -//! breaker to [`CircuitBreakerLevel::AllDisabled`]. The chain keeps upgrading; -//! governance can remove or re-enable the asset later once the off-chain -//! situation is resolved. The `try-runtime` post-upgrade hook verifies this -//! invariant — any out-of-range asset must end up disabled. -//! -//! Safe to run multiple times — already-populated snapshots are not overwritten. -//! -//! # Usage -//! -//! ```ignore -//! pub type Migrations = ( -//! pallet_psm::migrations::decimals::PopulateDecimals, -//! // ... other migrations -//! ); -//! ``` - -#[cfg(feature = "try-runtime")] -use alloc::vec::Vec; -use frame_support::{ - migrations::VersionedMigration, - pallet_prelude::Weight, - traits::{ - fungible::metadata::Inspect as FungibleMetadataInspect, - fungibles::metadata::Inspect as FungiblesMetadataInspect, Get, UncheckedOnRuntimeUpgrade, - }, -}; - -use crate::{ - pallet::{ - CircuitBreakerLevel, ExternalAssets, ExternalDecimals, InternalDecimals, MAX_DECIMALS_DIFF, - }, - Config, Pallet, -}; - -#[cfg(feature = "try-runtime")] -use frame_support::ensure; -#[cfg(feature = "try-runtime")] -use sp_runtime::TryRuntimeError; - -const LOG_TARGET: &str = "runtime::psm::migration::populate_decimals"; - -/// Version-gated v1 -> v2 migration that fills in decimal snapshots for all -/// pre-existing external assets and the internal asset, and bumps the pallet -/// on-chain storage version from 1 to 2. -pub type PopulateDecimals = VersionedMigration< - 1, - 2, - InnerPopulateDecimals, - Pallet, - ::DbWeight, ->; - -/// Version-unchecked migration logic. Exposed only for use by [`PopulateDecimals`]. -/// -/// Should never be placed directly into a runtime's migrations tuple — use the -/// versioned alias [`PopulateDecimals`] so the on-chain storage version is -/// checked and bumped. -pub struct InnerPopulateDecimals(core::marker::PhantomData); - -impl UncheckedOnRuntimeUpgrade for InnerPopulateDecimals { - fn on_runtime_upgrade() -> Weight { - log::info!( - target: LOG_TARGET, - "Running PopulateDecimals: backfilling decimal snapshots" - ); - - let mut reads = 0u64; - let mut writes = 0u64; - - // Internal asset snapshot — only write if missing. - reads += 2; - let internal_decimals = T::InternalAsset::decimals(); - if !InternalDecimals::::exists() { - InternalDecimals::::put(internal_decimals); - writes += 1; - } - - // Per-asset snapshots. Walk every approved external asset. - for (asset_id, status) in ExternalAssets::::iter() { - reads += 3; // ExternalAssets iter item + ExternalDecimals + Fungibles::decimals reads below - if ExternalDecimals::::contains_key(&asset_id) { - log::info!( - target: LOG_TARGET, - "Asset {:?} already has a decimals snapshot, skipping", - asset_id, - ); - continue; - } - - let asset_decimals = T::Fungibles::decimals(asset_id.clone()); - ExternalDecimals::::insert(&asset_id, asset_decimals); - writes += 1; - - let diff = asset_decimals.abs_diff(internal_decimals) as u32; - if diff > MAX_DECIMALS_DIFF { - // Do not fail the migration. Disable swaps for this asset so - // mint/redeem cannot operate on an unsupported decimal gap. The - // snapshot is still written — it preserves the observed state - // and lets the runtime guard surface the divergence clearly. - if status != CircuitBreakerLevel::AllDisabled { - ExternalAssets::::insert(&asset_id, CircuitBreakerLevel::AllDisabled); - writes += 1; - } - log::warn!( - target: LOG_TARGET, - "Asset {:?} decimals diff ({}) exceeds MAX_DECIMALS_DIFF ({}); disabling", - asset_id, - diff, - MAX_DECIMALS_DIFF, - ); - } else { - log::info!( - target: LOG_TARGET, - "Populated decimals snapshot for asset {:?} (decimals={})", - asset_id, - asset_decimals, - ); - } - } - - log::info!( - target: LOG_TARGET, - "PopulateDecimals complete" - ); - - T::DbWeight::get().reads_writes(reads, writes) - } - - #[cfg(feature = "try-runtime")] - fn pre_upgrade() -> Result, TryRuntimeError> { - Ok(Vec::new()) - } - - #[cfg(feature = "try-runtime")] - fn post_upgrade(_state: Vec) -> Result<(), TryRuntimeError> { - // Internal asset snapshot present and consistent with live metadata. - ensure!( - InternalDecimals::::get() == Some(T::InternalAsset::decimals()), - "InternalDecimals snapshot missing or stale after migration" - ); - - let internal_decimals = T::InternalAsset::decimals(); - for (asset_id, status) in ExternalAssets::::iter() { - let snapshot = ExternalDecimals::::get(&asset_id) - .ok_or("Approved external asset missing decimals snapshot after migration")?; - ensure!( - snapshot == T::Fungibles::decimals(asset_id), - "ExternalDecimals snapshot differs from live metadata after migration" - ); - let diff = snapshot.abs_diff(internal_decimals) as u32; - if diff > MAX_DECIMALS_DIFF { - ensure!( - status == CircuitBreakerLevel::AllDisabled, - "Out-of-range external asset was not disabled by migration" - ); - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - mock::{ - new_test_ext, RuntimeOrigin, Test, ALICE, DAI_MOCK_ASSET_ID, USDC_ASSET_ID, - USDT_ASSET_ID, - }, - Pallet, - }; - use frame_support::{ - assert_ok, - traits::{GetStorageVersion, OnRuntimeUpgrade, StorageVersion}, - }; - - /// The wrapper only runs when on-chain version is 1. Genesis sets it to 2, - /// so tests must roll it back to simulate a v1 chain. - fn prepare_v1() { - StorageVersion::new(1).put::>(); - } - - #[test] - fn populate_decimals_backfills_existing_assets() { - new_test_ext().execute_with(|| { - // Simulate a pre-migration state: existing assets have ExternalAssets - // entries but no decimals snapshots (and no InternalDecimals). - prepare_v1(); - InternalDecimals::::kill(); - ExternalDecimals::::remove(USDC_ASSET_ID); - ExternalDecimals::::remove(USDT_ASSET_ID); - - PopulateDecimals::::on_runtime_upgrade(); - - assert_eq!(InternalDecimals::::get(), Some(6)); - assert_eq!(ExternalDecimals::::get(USDC_ASSET_ID), Some(6)); - assert_eq!(ExternalDecimals::::get(USDT_ASSET_ID), Some(6)); - // Normal status preserved since decimals are in range. - assert_eq!( - ExternalAssets::::get(USDC_ASSET_ID), - Some(CircuitBreakerLevel::AllEnabled) - ); - assert_eq!( - ExternalAssets::::get(USDT_ASSET_ID), - Some(CircuitBreakerLevel::AllEnabled) - ); - }); - } - - #[test] - fn populate_decimals_does_not_overwrite_existing_snapshots() { - new_test_ext().execute_with(|| { - prepare_v1(); - // Genesis already wrote snapshots. Plant a sentinel to verify the - // migration does not overwrite it. - ExternalDecimals::::insert(USDC_ASSET_ID, 42u8); - - PopulateDecimals::::on_runtime_upgrade(); - - assert_eq!(ExternalDecimals::::get(USDC_ASSET_ID), Some(42)); - }); - } - - #[test] - fn populate_decimals_disables_out_of_range_assets() { - new_test_ext().execute_with(|| { - // Simulate: DAI_MOCK (18 decimals) was approved under a prior internal - // configuration; then internal metadata was changed to something exotic - // that makes the diff exceed MAX_DECIMALS_DIFF. We fake this by - // approving DAI and then shifting the internal asset's live decimals - // through metadata update. - use crate::mock::{Assets, INTERNAL_ASSET_ID}; - use frame_support::traits::fungibles::metadata::Mutate as MetadataMutate; - - assert_ok!(Pallet::::add_external_asset( - RuntimeOrigin::root(), - DAI_MOCK_ASSET_ID - )); - // DAI has 18 decimals; internal currently 6; diff = 12 (in range). - // Shift internal to 40 decimals so the diff becomes 22 — still in range - // (MAX_DECIMALS_DIFF is 24). Push further to make diff too large: - // setting internal to the extreme (say, 45) would push diff = 27, > 24. - assert_ok!(>::set( - INTERNAL_ASSET_ID, - &ALICE, - b"Internal Asset".to_vec(), - b"INTERNAL".to_vec(), - 45, - )); - - // Wipe DAI's snapshot and InternalDecimals to force repopulation, then - // roll back to v1 so the versioned wrapper actually runs. - ExternalDecimals::::remove(DAI_MOCK_ASSET_ID); - InternalDecimals::::kill(); - prepare_v1(); - - PopulateDecimals::::on_runtime_upgrade(); - - // Snapshot was written regardless. - assert_eq!(InternalDecimals::::get(), Some(45)); - assert_eq!(ExternalDecimals::::get(DAI_MOCK_ASSET_ID), Some(18)); - // DAI is disabled because 45 - 18 = 27 > MAX_DECIMALS_DIFF (24). - assert_eq!( - ExternalAssets::::get(DAI_MOCK_ASSET_ID), - Some(CircuitBreakerLevel::AllDisabled) - ); - // In-range assets stay enabled. - assert_eq!( - ExternalAssets::::get(USDC_ASSET_ID), - Some(CircuitBreakerLevel::AllEnabled) - ); - }); - } - - #[test] - fn populate_decimals_runs_once_then_skips() { - new_test_ext().execute_with(|| { - prepare_v1(); - InternalDecimals::::kill(); - ExternalDecimals::::remove(USDC_ASSET_ID); - - // First run: on-chain version is 1, migration executes and bumps to 2. - PopulateDecimals::::on_runtime_upgrade(); - assert_eq!(Pallet::::on_chain_storage_version(), StorageVersion::new(2)); - let stable1 = InternalDecimals::::get(); - let usdc1 = ExternalDecimals::::get(USDC_ASSET_ID); - - // Second run: on-chain version is 2, versioned wrapper skips — state - // is unchanged. - PopulateDecimals::::on_runtime_upgrade(); - assert_eq!(InternalDecimals::::get(), stable1); - assert_eq!(ExternalDecimals::::get(USDC_ASSET_ID), usdc1); - assert_eq!(Pallet::::on_chain_storage_version(), StorageVersion::new(2)); - }); - } - - #[test] - fn populate_decimals_skips_when_not_on_version_one() { - new_test_ext().execute_with(|| { - // Simulate an already-upgraded chain at v2. Wrapper must skip. - StorageVersion::new(2).put::>(); - - ExternalDecimals::::remove(USDC_ASSET_ID); - PopulateDecimals::::on_runtime_upgrade(); - - // Snapshot not repopulated — migration was skipped. - assert_eq!(ExternalDecimals::::get(USDC_ASSET_ID), None); - // Version unchanged. - assert_eq!(Pallet::::on_chain_storage_version(), StorageVersion::new(2)); - }); - } -} diff --git a/substrate/frame/psm/src/migrations/init.rs b/substrate/frame/psm/src/migrations/init.rs deleted file mode 100644 index 42dc63ba56f12..0000000000000 --- a/substrate/frame/psm/src/migrations/init.rs +++ /dev/null @@ -1,458 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Amforc AG. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Idempotent migration to initialize PSM parameters for post-genesis deployment. -//! -//! This migration sets initial values for all configurable PSM parameters when -//! adding the pallet to an existing chain. Already-configured assets are skipped, -//! making it safe to run multiple times. -//! -//! # Usage -//! -//! Include in your runtime migrations: -//! -//! ```ignore -//! pub type Migrations = ( -//! pallet_psm::migrations::init::InitializePsm, -//! // ... other migrations -//! ); -//! ``` -//! -//! Where `PsmInitialConfig` implements [`InitialPsmConfig`]. - -use alloc::collections::btree_map::BTreeMap; -#[cfg(feature = "try-runtime")] -use alloc::vec::Vec; -use frame_support::{ - pallet_prelude::{Get, Weight}, - traits::{ - fungible::metadata::Inspect as FungibleMetadataInspect, - fungibles::metadata::Inspect as FungiblesMetadataInspect, - }, -}; -use sp_runtime::Permill; - -use crate::{ - pallet::{ - AssetCeilingWeight, CircuitBreakerLevel, ExternalAssets, ExternalDecimals, - InternalDecimals, MaxPsmDebtOfTotal, MintingFee, RedemptionFee, MAX_DECIMALS_DIFF, - }, - Config, Pallet, -}; - -#[cfg(feature = "try-runtime")] -use frame_support::ensure; -#[cfg(feature = "try-runtime")] -use sp_runtime::TryRuntimeError; - -const LOG_TARGET: &str = "runtime::psm::migration"; - -/// Configuration trait for initial PSM parameters. -/// -/// Implement this trait in your runtime to provide the initial values used by -/// [`InitializePsm`]. -pub trait InitialPsmConfig { - /// Max PSM debt as a fraction of MaximumIssuance. - fn max_psm_debt_of_total() -> Permill; - - /// Per-asset configuration: - /// - minting fee - /// - redemption fee - /// - asset ceiling weight - /// - /// Keys also define the set of approved external assets. - fn asset_configs() -> BTreeMap; -} - -/// Idempotent migration to initialize PSM pallet parameters. -/// -/// This migration: -/// 1. Sets `MaxPsmDebtOfTotal` -/// 2. For each configured external asset, checks if it already exists. If not, adds it with -/// `AllEnabled` status and the configured fees and ceiling weight. -/// 3. Ensures the PSM and fee destination accounts exist -/// -/// Safe to run multiple times — existing assets are not overwritten. -pub struct InitializePsm(core::marker::PhantomData<(T, I)>); - -impl> frame_support::traits::OnRuntimeUpgrade - for InitializePsm -{ - fn on_runtime_upgrade() -> Weight { - log::info!( - target: LOG_TARGET, - "Running InitializePsm: initializing PSM pallet parameters" - ); - - let asset_configs = I::asset_configs(); - let mut reads = 0u64; - let mut writes = 0u64; - - reads += 1; - if !MaxPsmDebtOfTotal::::exists() { - MaxPsmDebtOfTotal::::put(I::max_psm_debt_of_total()); - writes += 1; - } - - // Internal decimals snapshot: populate from live metadata if not yet set. - // Per-asset snapshots for pre-existing approved assets are owned by - // `super::decimals::PopulateDecimals` — this migration only touches `ExternalDecimals` for - // assets it adds as new below. - let internal_decimals = T::InternalAsset::decimals(); - reads += 1; - if !InternalDecimals::::exists() { - InternalDecimals::::put(internal_decimals); - writes += 1; - } - for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in &asset_configs { - reads += 1; - // Skip assets that are already configured. - if ExternalAssets::::contains_key(asset_id) { - log::info!( - target: LOG_TARGET, - "Asset {:?} already configured, skipping", - asset_id, - ); - continue; - } - - let asset_decimals = T::Fungibles::decimals(asset_id.clone()); - let diff = asset_decimals.abs_diff(internal_decimals) as u32; - if diff > MAX_DECIMALS_DIFF { - log::error!( - target: LOG_TARGET, - "Asset {:?} decimals diff ({}) exceeds MAX_DECIMALS_DIFF ({}), skipping", - asset_id, - diff, - MAX_DECIMALS_DIFF, - ); - continue; - } - - ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); - ExternalDecimals::::insert(asset_id, asset_decimals); - MintingFee::::insert(asset_id, minting_fee); - RedemptionFee::::insert(asset_id, redemption_fee); - AssetCeilingWeight::::insert(asset_id, ceiling_weight); - writes += 5; - - log::info!( - target: LOG_TARGET, - "Configured external asset {:?} (decimals={})", - asset_id, - asset_decimals, - ); - } - - Pallet::::ensure_account_exists(&Pallet::::account_id()); - Pallet::::ensure_account_exists(&T::FeeDestination::get()); - writes += 2; - - log::info!( - target: LOG_TARGET, - "InitializePsm complete" - ); - - T::DbWeight::get().reads_writes(reads, writes) - } - - #[cfg(feature = "try-runtime")] - fn pre_upgrade() -> Result, TryRuntimeError> { - Ok(Vec::new()) - } - - #[cfg(feature = "try-runtime")] - fn post_upgrade(_state: Vec) -> Result<(), TryRuntimeError> { - ensure!( - MaxPsmDebtOfTotal::::get() == I::max_psm_debt_of_total(), - "MaxPsmDebtOfTotal mismatch after migration" - ); - - for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in I::asset_configs() { - ensure!( - ExternalAssets::::get(&asset_id) == Some(CircuitBreakerLevel::AllEnabled), - "External asset missing or not AllEnabled after migration" - ); - ensure!( - MintingFee::::get(&asset_id) == minting_fee, - "MintingFee mismatch after migration" - ); - ensure!( - RedemptionFee::::get(&asset_id) == redemption_fee, - "RedemptionFee mismatch after migration" - ); - ensure!( - AssetCeilingWeight::::get(&asset_id) == ceiling_weight, - "AssetCeilingWeight mismatch after migration" - ); - } - - let psm_account = Pallet::::account_id(); - ensure!( - frame_system::Pallet::::account_exists(&psm_account), - "PSM account does not exist after migration" - ); - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::mock::{new_test_ext, Assets, Test, ALICE, USDC_ASSET_ID, USDT_ASSET_ID}; - use frame_support::assert_ok; - - struct TestPsmConfig; - - impl InitialPsmConfig for TestPsmConfig { - fn max_psm_debt_of_total() -> Permill { - Permill::from_percent(25) - } - - fn asset_configs() -> BTreeMap { - [ - ( - USDC_ASSET_ID, - ( - Permill::from_parts(5_000), - Permill::from_parts(5_000), - Permill::from_percent(50), - ), - ), - ( - USDT_ASSET_ID, - ( - Permill::from_parts(3_000), - Permill::from_parts(7_000), - Permill::from_percent(50), - ), - ), - ] - .into_iter() - .collect() - } - } - - fn clear_all_psm_state() { - MaxPsmDebtOfTotal::::kill(); - InternalDecimals::::kill(); - ExternalAssets::::remove(USDC_ASSET_ID); - ExternalAssets::::remove(USDT_ASSET_ID); - MintingFee::::remove(USDC_ASSET_ID); - MintingFee::::remove(USDT_ASSET_ID); - RedemptionFee::::remove(USDC_ASSET_ID); - RedemptionFee::::remove(USDT_ASSET_ID); - AssetCeilingWeight::::remove(USDC_ASSET_ID); - AssetCeilingWeight::::remove(USDT_ASSET_ID); - ExternalDecimals::::remove(USDC_ASSET_ID); - ExternalDecimals::::remove(USDT_ASSET_ID); - } - - #[test] - fn initialize_psm_configures_new_assets() { - use frame_support::traits::{ - fungible::metadata::Inspect as _, fungibles::metadata::Inspect as _, OnRuntimeUpgrade, - }; - - new_test_ext().execute_with(|| { - clear_all_psm_state(); - - InitializePsm::::on_runtime_upgrade(); - - assert_eq!(MaxPsmDebtOfTotal::::get(), TestPsmConfig::max_psm_debt_of_total()); - assert_eq!( - InternalDecimals::::get(), - Some(::InternalAsset::decimals()) - ); - - for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in - TestPsmConfig::asset_configs() - { - assert_eq!( - ExternalAssets::::get(asset_id), - Some(CircuitBreakerLevel::AllEnabled) - ); - // New assets get their decimals snapshot. - assert_eq!( - ExternalDecimals::::get(asset_id), - Some(::Fungibles::decimals(asset_id)) - ); - assert_eq!(MintingFee::::get(asset_id), minting_fee); - assert_eq!(RedemptionFee::::get(asset_id), redemption_fee); - assert_eq!(AssetCeilingWeight::::get(asset_id), ceiling_weight); - } - }); - } - - #[test] - fn initialize_psm_populates_internal_decimals_when_missing() { - use frame_support::traits::{fungible::metadata::Inspect as _, OnRuntimeUpgrade}; - - new_test_ext().execute_with(|| { - // InternalDecimals was populated by genesis; clear it to simulate a - // pre-decimal-snapshot deployment where the migration must seed it. - InternalDecimals::::kill(); - - InitializePsm::::on_runtime_upgrade(); - - assert_eq!( - InternalDecimals::::get(), - Some(::InternalAsset::decimals()) - ); - }); - } - - #[test] - fn initialize_psm_preserves_existing_internal_decimals() { - use frame_support::traits::OnRuntimeUpgrade; - - new_test_ext().execute_with(|| { - // Plant a sentinel (non-live) value. The migration must not overwrite. - InternalDecimals::::put(42u8); - - InitializePsm::::on_runtime_upgrade(); - - assert_eq!(InternalDecimals::::get(), Some(42)); - }); - } - - #[test] - fn initialize_psm_skips_existing_assets() { - use frame_support::traits::OnRuntimeUpgrade; - - new_test_ext().execute_with(|| { - // Pre-configure USDC with custom values; drop its decimals snapshot to simulate a - // pre-migration partial state. This migration must not touch USDC's snapshot (that is - // `PopulateDecimals`'s job). - ExternalAssets::::insert(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); - MintingFee::::insert(USDC_ASSET_ID, Permill::from_percent(10)); - ExternalDecimals::::remove(USDC_ASSET_ID); - - // Remove USDT so it gets configured. - ExternalAssets::::remove(USDT_ASSET_ID); - MintingFee::::remove(USDT_ASSET_ID); - RedemptionFee::::remove(USDT_ASSET_ID); - AssetCeilingWeight::::remove(USDT_ASSET_ID); - ExternalDecimals::::remove(USDT_ASSET_ID); - - InitializePsm::::on_runtime_upgrade(); - - // USDC was not overwritten — including its missing decimals snapshot. - assert_eq!( - ExternalAssets::::get(USDC_ASSET_ID), - Some(CircuitBreakerLevel::MintingDisabled) - ); - assert_eq!(MintingFee::::get(USDC_ASSET_ID), Permill::from_percent(10)); - assert_eq!(ExternalDecimals::::get(USDC_ASSET_ID), None); - - // USDT was newly configured; its decimals snapshot is populated. - let (_, (minting_fee, redemption_fee, ceiling_weight)) = TestPsmConfig::asset_configs() - .into_iter() - .find(|(id, _)| *id == USDT_ASSET_ID) - .unwrap(); - assert_eq!( - ExternalAssets::::get(USDT_ASSET_ID), - Some(CircuitBreakerLevel::AllEnabled) - ); - assert!(ExternalDecimals::::get(USDT_ASSET_ID).is_some()); - assert_eq!(MintingFee::::get(USDT_ASSET_ID), minting_fee); - assert_eq!(RedemptionFee::::get(USDT_ASSET_ID), redemption_fee); - assert_eq!(AssetCeilingWeight::::get(USDT_ASSET_ID), ceiling_weight); - }); - } - - #[test] - fn initialize_psm_skips_assets_with_wrong_decimals() { - use frame_support::traits::{ - fungibles::{metadata::Mutate as MetadataMutate, Create as FungiblesCreate}, - OnRuntimeUpgrade, - }; - - const WRONG_DECIMALS_ID: u32 = 99; - - new_test_ext().execute_with(|| { - // Create an asset with 8 decimals (internal asset has 6). - assert_ok!(>::create(WRONG_DECIMALS_ID, ALICE, true, 1)); - assert_ok!(>::set( - WRONG_DECIMALS_ID, - &ALICE, - b"Wrong".to_vec(), - b"WRG".to_vec(), - (MAX_DECIMALS_DIFF + 6 + 1).try_into().unwrap(), // exceeds MAX_DECIMALS_DIFF - )); - - struct MixedDecimalsConfig; - impl InitialPsmConfig for MixedDecimalsConfig { - fn max_psm_debt_of_total() -> Permill { - Permill::from_percent(50) - } - fn asset_configs() -> BTreeMap { - [ - ( - WRONG_DECIMALS_ID, - (Permill::zero(), Permill::zero(), Permill::from_percent(50)), - ), - ( - USDC_ASSET_ID, // 6 decimals — matches internal asset - (Permill::zero(), Permill::zero(), Permill::from_percent(50)), - ), - ] - .into_iter() - .collect() - } - } - - ExternalAssets::::remove(WRONG_DECIMALS_ID); - ExternalAssets::::remove(USDC_ASSET_ID); - - InitializePsm::::on_runtime_upgrade(); - - // Wrong decimals asset was skipped. - assert_eq!(ExternalAssets::::get(WRONG_DECIMALS_ID), None); - - // Matching decimals asset was configured. - assert_eq!( - ExternalAssets::::get(USDC_ASSET_ID), - Some(CircuitBreakerLevel::AllEnabled) - ); - }); - } - - #[test] - fn initialize_psm_is_idempotent() { - use frame_support::traits::OnRuntimeUpgrade; - - new_test_ext().execute_with(|| { - clear_all_psm_state(); - - // Run twice. - InitializePsm::::on_runtime_upgrade(); - InitializePsm::::on_runtime_upgrade(); - - // Same result as running once. - assert_eq!(MaxPsmDebtOfTotal::::get(), TestPsmConfig::max_psm_debt_of_total()); - for (asset_id, _) in TestPsmConfig::asset_configs() { - assert_eq!( - ExternalAssets::::get(asset_id), - Some(CircuitBreakerLevel::AllEnabled) - ); - assert!(ExternalDecimals::::get(asset_id).is_some()); - } - }); - } -} diff --git a/substrate/frame/psm/src/migrations/mod.rs b/substrate/frame/psm/src/migrations/mod.rs deleted file mode 100644 index babb2ca187216..0000000000000 --- a/substrate/frame/psm/src/migrations/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Amforc AG. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Migrations for the PSM pallet. - -pub mod decimals; -pub mod init; - -pub use decimals::PopulateDecimals; -pub use init::InitializePsm; diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs index 6dd589db2bb26..8982ba5a04ef4 100644 --- a/substrate/frame/psm/src/mock.rs +++ b/substrate/frame/psm/src/mock.rs @@ -17,7 +17,7 @@ use frame_support::{ derive_impl, parameter_types, - traits::{AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, EnsureOrigin}, + traits::{AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64}, weights::constants::RocksDbWeight, PalletId, }; @@ -30,6 +30,8 @@ pub const ALICE: u64 = 1; pub const BOB: u64 = 2; pub const CHARLIE: u64 = 3; pub const INSURANCE_FUND: u64 = 100; +/// Account whose signed origin acts as the emergency admin on the test PSM. +pub const EMERGENCY_ACCOUNT: u64 = 999; // Asset IDs pub const INTERNAL_ASSET_ID: u32 = 1; @@ -48,14 +50,8 @@ pub const DAI_UNIT: u128 = 1_000_000_000_000_000_000; // Initial balances for testing pub const INITIAL_BALANCE: u128 = 1_000_000 * INTERNAL_UNIT; // 1M units - -parameter_types! { - pub static MockMaximumIssuance: u128 = 10_000_000 * INTERNAL_UNIT; -} - -pub fn set_mock_maximum_issuance(value: u128) { - MockMaximumIssuance::set(value); -} +/// Default per-instance debt ceiling at genesis: 50% of the legacy 20M issuance cap. +pub const DEFAULT_MAX_DEBT: u128 = 10_000_000 * INTERNAL_UNIT; #[frame_support::runtime] mod test_runtime { @@ -112,35 +108,9 @@ impl pallet_assets::Config for Test { } parameter_types! { - pub const InternalAssetId: u32 = INTERNAL_ASSET_ID; - pub const InsuranceFundAccount: u64 = INSURANCE_FUND; pub const MinSwapAmount: u128 = 100 * INTERNAL_UNIT; pub const PsmPalletId: PalletId = PalletId(*b"py/psm!!"); -} - -/// Account used as emergency origin (non-root). -pub const EMERGENCY_ACCOUNT: u64 = 999; - -/// Maps Root to Full level, EMERGENCY_ACCOUNT to Emergency level. -pub struct MockManagerOrigin; -impl EnsureOrigin for MockManagerOrigin { - type Success = crate::PsmManagerLevel; - - fn try_origin(o: RuntimeOrigin) -> Result { - use frame_system::RawOrigin; - match o.clone().into() { - Ok(RawOrigin::Root) => Ok(crate::PsmManagerLevel::Full), - Ok(RawOrigin::Signed(who)) if who == EMERGENCY_ACCOUNT => { - Ok(crate::PsmManagerLevel::Emergency) - }, - _ => Err(o), - } - } - - #[cfg(feature = "runtime-benchmarks")] - fn try_successful_origin() -> Result { - Ok(RuntimeOrigin::root()) - } + pub const PsmCreationDeposit: u128 = 1_000_000; } #[cfg(feature = "runtime-benchmarks")] @@ -169,15 +139,15 @@ impl crate::BenchmarkHelper for PsmBenchmarkHelper { impl crate::Config for Test { type Fungibles = Assets; + type Currency = Balances; + type RuntimeOrigin = RuntimeOrigin; + type PalletsOrigin = OriginCaller; type AssetId = u32; - type MaximumIssuance = MockMaximumIssuance; - type ManagerOrigin = MockManagerOrigin; type WeightInfo = (); - type InternalAsset = frame_support::traits::fungible::ItemOf; - type FeeDestination = InsuranceFundAccount; type PalletId = PsmPalletId; type MinSwapAmount = MinSwapAmount; - type MaxExternalAssets = ConstU32<10>; + type MaxExternalAssetsPerPsm = ConstU32<10>; + type CreationDeposit = PsmCreationDeposit; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = PsmBenchmarkHelper; } @@ -227,35 +197,61 @@ pub fn new_test_ext() -> TestState { .assimilate_storage(&mut storage) .unwrap(); - crate::GenesisConfig:: { - max_psm_debt_of_total: Permill::from_percent(50), - asset_configs: [ - ( - USDC_ASSET_ID, - (Permill::from_percent(1), Permill::from_percent(1), Permill::from_percent(60)), - ), - ( - USDT_ASSET_ID, - (Permill::from_percent(1), Permill::from_percent(1), Permill::from_percent(40)), - ), - ] - .into_iter() - .collect(), - _marker: Default::default(), - } - .assimilate_storage(&mut storage) - .unwrap(); - let mut ext: TestState = storage.into(); ext.execute_with(|| { System::set_block_number(1); - set_mock_maximum_issuance(20_000_000 * INTERNAL_UNIT); + install_test_psm(); }); ext } +/// Direct storage install of the test PSM with `Root` as full_admin and +/// `Signed(EMERGENCY_ACCOUNT)` as emergency_admin. We bypass `create_psm` here so tests don't +/// depend on balance funding plumbing. +fn install_test_psm() { + let internal_decimals = + >::decimals( + INTERNAL_ASSET_ID, + ); + let full_admin: OriginCaller = frame_system::RawOrigin::::Root.into(); + let emergency_admin: OriginCaller = + frame_system::RawOrigin::::Signed(EMERGENCY_ACCOUNT).into(); + crate::Psm::::insert( + INTERNAL_ASSET_ID, + crate::PsmInfo:: { + fee_destination: INSURANCE_FUND, + max_debt: DEFAULT_MAX_DEBT, + internal_decimals, + external_count: 2, + }, + ); + crate::PsmAdmin::::insert( + INTERNAL_ASSET_ID, + crate::PsmAdminInfo:: { full_admin, emergency_admin, depositor: ALICE, deposit: 0 }, + ); + // Acquire provider refs like `create_psm` does, so the test PSM mirrors a real one. + frame_system::Pallet::::inc_providers(&crate::Pallet::::psm_account( + &INTERNAL_ASSET_ID, + )); + frame_system::Pallet::::inc_providers(&INSURANCE_FUND); + + for (asset, weight, decimals) in [ + (USDC_ASSET_ID, Permill::from_percent(60), 6u8), + (USDT_ASSET_ID, Permill::from_percent(40), 6u8), + ] { + crate::ExternalAssets::::insert( + INTERNAL_ASSET_ID, + asset, + crate::ExternalAssetInfo { status: crate::CircuitBreakerLevel::AllEnabled, decimals }, + ); + crate::MintingFee::::insert(INTERNAL_ASSET_ID, asset, Permill::from_percent(1)); + crate::RedemptionFee::::insert(INTERNAL_ASSET_ID, asset, Permill::from_percent(1)); + crate::AssetCeilingWeight::::insert(INTERNAL_ASSET_ID, asset, weight); + } +} + pub struct ExtBuilder { mint_ops: Vec<(u64, u32, u128)>, } @@ -283,6 +279,7 @@ impl ExtBuilder { for (who, asset_id, amount) in self.mint_ops { frame_support::assert_ok!(crate::Pallet::::mint( RuntimeOrigin::signed(who), + INTERNAL_ASSET_ID, asset_id, amount, )); @@ -294,32 +291,45 @@ impl ExtBuilder { } pub fn set_minting_fee(asset_id: u32, fee: Permill) { - crate::MintingFee::::insert(asset_id, fee); + crate::MintingFee::::insert(INTERNAL_ASSET_ID, asset_id, fee); } pub fn set_redemption_fee(asset_id: u32, fee: Permill) { - crate::RedemptionFee::::insert(asset_id, fee); + crate::RedemptionFee::::insert(INTERNAL_ASSET_ID, asset_id, fee); } -pub fn set_max_psm_debt_ratio(ratio: Permill) { - crate::MaxPsmDebtOfTotal::::put(ratio); +pub fn set_max_debt(value: u128) { + crate::Psm::::mutate(INTERNAL_ASSET_ID, |maybe| { + if let Some(info) = maybe.as_mut() { + info.max_debt = value; + } + }); } pub fn set_asset_ceiling_weight(asset_id: u32, weight: Permill) { - crate::AssetCeilingWeight::::insert(asset_id, weight); + crate::AssetCeilingWeight::::insert(INTERNAL_ASSET_ID, asset_id, weight); } pub fn set_asset_status(asset_id: u32, status: crate::CircuitBreakerLevel) { - crate::ExternalAssets::::insert(asset_id, status); + crate::ExternalAssets::::mutate(INTERNAL_ASSET_ID, asset_id, |maybe| { + if let Some(info) = maybe.as_mut() { + info.status = status; + } + }); } /// Register an external asset via the extrinsic (records snapshot decimals) and /// assign it a per-asset ceiling weight. pub fn register_external_asset_with_weight(asset_id: u32, weight: Permill) { use frame_support::assert_ok; - assert_ok!(crate::Pallet::::add_external_asset(RuntimeOrigin::root(), asset_id)); + assert_ok!(crate::Pallet::::add_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + asset_id, + )); assert_ok!(crate::Pallet::::set_asset_ceiling_weight( RuntimeOrigin::root(), + INTERNAL_ASSET_ID, asset_id, weight, )); @@ -352,5 +362,5 @@ pub fn get_asset_balance(asset_id: u32, account: u64) -> u128 { } pub fn psm_account() -> u64 { - crate::Pallet::::account_id() + crate::Pallet::::psm_account(&INTERNAL_ASSET_ID) } diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index 4f020da2e958f..b132c5ba038e2 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -17,12 +17,23 @@ use super::mock::*; use crate::{ - AssetCeilingWeight, CircuitBreakerLevel, Error, Event, ExternalAssets, MaxPsmDebtOfTotal, - MintingFee, PsmDebt, RedemptionFee, + AssetCeilingWeight, CircuitBreakerLevel, Error, Event, ExternalAssets, MintingFee, PsmDebt, + RedemptionFee, }; use frame_support::{assert_noop, assert_ok, hypothetically}; use sp_runtime::{DispatchError, Permill, TokenError}; +fn psm_max_debt() -> u128 { + crate::Psm::::get(INTERNAL_ASSET_ID) + .map(|p| p.max_debt) + .unwrap_or_default() +} + +fn psm_max_asset_debt(asset_id: u32) -> u128 { + let info = crate::Psm::::get(INTERNAL_ASSET_ID).expect("PSM exists at genesis"); + crate::Pallet::::max_asset_debt(&INTERNAL_ASSET_ID, &asset_id, &info) +} + mod mint { use super::*; @@ -32,7 +43,12 @@ mod mint { let mint_amount = 1000 * INTERNAL_UNIT; let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + mint_amount + )); let fee = Permill::from_percent(1).mul_ceil(mint_amount); let internal_to_user = mint_amount - fee; @@ -41,10 +57,11 @@ mod mint { assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), mint_amount); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, ALICE), internal_to_user); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND), fee); - assert_eq!(PsmDebt::::get(USDC_ASSET_ID), mint_amount); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), mint_amount); System::assert_has_event( Event::::Minted { + internal_asset: INTERNAL_ASSET_ID, who: ALICE, asset_id: USDC_ASSET_ID, external_amount: mint_amount, @@ -63,7 +80,12 @@ mod mint { let mint_amount = 1000 * INTERNAL_UNIT; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + mint_amount + )); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, ALICE), mint_amount); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND), 0); @@ -79,7 +101,12 @@ mod mint { let fee = Permill::from_percent(5).mul_ceil(mint_amount); let internal_to_user = mint_amount - fee; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + mint_amount + )); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, ALICE), internal_to_user); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND), fee); @@ -93,7 +120,12 @@ mod mint { let mint_amount = 1000 * INTERNAL_UNIT; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + mint_amount + )); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, ALICE), 0); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND), mint_amount); @@ -104,7 +136,12 @@ mod mint { fn fails_unsupported_asset() { new_test_ext().execute_with(|| { assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), UNSUPPORTED_ASSET_ID, 1000 * INTERNAL_UNIT), + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + UNSUPPORTED_ASSET_ID, + 1000 * INTERNAL_UNIT + ), Error::::UnsupportedAsset ); }); @@ -116,13 +153,19 @@ mod mint { set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * INTERNAL_UNIT), + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 1000 * INTERNAL_UNIT + ), Error::::MintingStopped ); // Other assets should still work assert_ok!(Psm::mint( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDT_ASSET_ID, 1000 * INTERNAL_UNIT )); @@ -135,13 +178,19 @@ mod mint { set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::AllDisabled); assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * INTERNAL_UNIT), + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 1000 * INTERNAL_UNIT + ), Error::::MintingStopped ); // Other assets should still work assert_ok!(Psm::mint( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDT_ASSET_ID, 1000 * INTERNAL_UNIT )); @@ -154,7 +203,12 @@ mod mint { let below_min = MinSwapAmount::get() - 1; assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, below_min), + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + below_min + ), Error::::BelowMinimumSwap ); }); @@ -164,16 +218,16 @@ mod mint { fn fails_exceeds_max_debt() { new_test_ext().execute_with(|| { // Set global ceiling to 1% and asset ratio to 100% - set_max_psm_debt_ratio(Permill::from_percent(1)); + set_max_debt(200_000 * INTERNAL_UNIT); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(100)); - let max_debt = crate::Pallet::::max_asset_debt(USDC_ASSET_ID); + let max_debt = psm_max_asset_debt(USDC_ASSET_ID); let too_much = max_debt + 1; fund_external_asset(USDC_ASSET_ID, ALICE, too_much); assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much), + Psm::mint(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, USDC_ASSET_ID, too_much), Error::::ExceedsMaxPsmDebt ); }); @@ -185,13 +239,18 @@ mod mint { // When PSM debt is set to an extreme value, the aggregate ceiling check // will catch it before reaching the per-asset arithmetic overflow check. // This is correct behavior - ceiling checks provide safety. - set_max_psm_debt_ratio(Permill::from_percent(100)); + set_max_debt(20_000_000 * INTERNAL_UNIT); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(100)); - PsmDebt::::insert(USDC_ASSET_ID, u128::MAX - 100); + PsmDebt::::insert(INTERNAL_ASSET_ID, USDC_ASSET_ID, u128::MAX - 100); assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * INTERNAL_UNIT), + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 1000 * INTERNAL_UNIT + ), Error::::ExceedsMaxPsmDebt ); }); @@ -201,17 +260,22 @@ mod mint { fn boundary_new_debt_equals_max() { new_test_ext().execute_with(|| { // Set USDC to 100% and USDT to 0% so USDC gets full ceiling - set_max_psm_debt_ratio(Permill::from_percent(1)); + set_max_debt(200_000 * INTERNAL_UNIT); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(100)); set_asset_ceiling_weight(USDT_ASSET_ID, Permill::from_percent(0)); - let max_debt = crate::Pallet::::max_asset_debt(USDC_ASSET_ID); + let max_debt = psm_max_asset_debt(USDC_ASSET_ID); fund_external_asset(USDC_ASSET_ID, ALICE, max_debt); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, max_debt)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + max_debt + )); - assert_eq!(PsmDebt::::get(USDC_ASSET_ID), max_debt); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), max_debt); }); } @@ -224,38 +288,18 @@ mod mint { let too_much = alice_usdc_before + 1000 * INTERNAL_UNIT; assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much), + Psm::mint(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, USDC_ASSET_ID, too_much), TokenError::FundsUnavailable ); // Verify no state mutation occurred - assert_eq!(PsmDebt::::get(USDC_ASSET_ID), 0); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), 0); assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, ALICE), alice_internal_before); assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), psm_usdc_before); }); } - #[test] - fn fails_mint_exceeds_system_wide_issuance() { - new_test_ext().execute_with(|| { - let maximum_issuance = MockMaximumIssuance::get(); - - // Simulate Vaults having minted most of the cap (leave only 100 internal room) - let vault_minted = maximum_issuance - 100 * INTERNAL_UNIT; - fund_internal(BOB, vault_minted); - - // PSM per-asset ceiling would allow this, but system cap won't - let mint_amount = 1000 * INTERNAL_UNIT; - fund_external_asset(USDC_ASSET_ID, ALICE, mint_amount); - - assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount), - Error::::ExceedsMaxIssuance - ); - }); - } - #[test] fn fails_mint_exceeds_aggregate_psm_ceiling() { new_test_ext().execute_with(|| { @@ -264,19 +308,29 @@ mod mint { set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(50)); set_asset_ceiling_weight(USDT_ASSET_ID, Permill::from_percent(50)); - let max_psm_debt = crate::Pallet::::max_psm_debt(); + let max_psm_debt = crate::Pallet::::max_psm_debt(&INTERNAL_ASSET_ID); // Mint 50% of PSM ceiling via USDC (succeeds) let usdc_amount = Permill::from_percent(50).mul_floor(max_psm_debt); fund_external_asset(USDC_ASSET_ID, ALICE, usdc_amount); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, usdc_amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + usdc_amount + )); // Try to mint 50% + 1 via USDT (total would exceed PSM ceiling) let usdt_amount = Permill::from_percent(50).mul_floor(max_psm_debt) + 1; fund_external_asset(USDT_ASSET_ID, BOB, usdt_amount); assert_noop!( - Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, usdt_amount), + Psm::mint( + RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, + USDT_ASSET_ID, + usdt_amount + ), Error::::ExceedsMaxPsmDebt ); }); @@ -293,9 +347,14 @@ mod redeem { let alice_internal_before = get_asset_balance(INTERNAL_ASSET_ID, ALICE); let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); let psm_usdc_before = get_asset_balance(USDC_ASSET_ID, psm_account()); - let debt_before = PsmDebt::::get(USDC_ASSET_ID); + let debt_before = PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, redeem_amount)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + redeem_amount + )); let fee = Permill::from_percent(1).mul_ceil(redeem_amount); let external_to_user = redeem_amount - fee; @@ -312,10 +371,14 @@ mod redeem { get_asset_balance(USDC_ASSET_ID, psm_account()), psm_usdc_before - external_to_user ); - assert_eq!(PsmDebt::::get(USDC_ASSET_ID), debt_before - external_to_user); + assert_eq!( + PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), + debt_before - external_to_user + ); System::assert_has_event( Event::::Redeemed { + internal_asset: INTERNAL_ASSET_ID, who: ALICE, asset_id: USDC_ASSET_ID, paid: redeem_amount, @@ -335,7 +398,12 @@ mod redeem { let redeem_amount = 1000 * INTERNAL_UNIT; let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, redeem_amount)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + redeem_amount + )); assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before + redeem_amount); }); @@ -351,7 +419,12 @@ mod redeem { let external_to_user = redeem_amount - fee; let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, redeem_amount)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + redeem_amount + )); assert_eq!( get_asset_balance(USDC_ASSET_ID, ALICE), @@ -369,7 +442,12 @@ mod redeem { let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); let insurance_internal_before = get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, redeem_amount)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + redeem_amount + )); assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before); assert_eq!( @@ -385,6 +463,7 @@ mod redeem { assert_noop!( Psm::redeem( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, UNSUPPORTED_ASSET_ID, 1000 * INTERNAL_UNIT ), @@ -399,7 +478,12 @@ mod redeem { set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::AllDisabled); assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * INTERNAL_UNIT), + Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 1000 * INTERNAL_UNIT + ), Error::::AllSwapsStopped ); }); @@ -413,6 +497,7 @@ mod redeem { // Redemption should still work when only minting is disabled assert_ok!(Psm::redeem( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDC_ASSET_ID, 1000 * INTERNAL_UNIT )); @@ -425,7 +510,12 @@ mod redeem { let below_min = 50 * INTERNAL_UNIT; assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, below_min), + Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + below_min + ), Error::::BelowMinimumSwap ); }); @@ -440,7 +530,12 @@ mod redeem { assert_eq!(reserve, 0); assert_noop!( - Psm::redeem(RuntimeOrigin::signed(BOB), USDC_ASSET_ID, 1000 * INTERNAL_UNIT), + Psm::redeem( + RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 1000 * INTERNAL_UNIT + ), Error::::InsufficientReserve ); }); @@ -456,7 +551,12 @@ mod redeem { let too_much = alice_internal + 1000 * INTERNAL_UNIT; assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much), + Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + too_much + ), TokenError::FundsUnavailable ); }); @@ -469,8 +569,18 @@ mod redeem { set_redemption_fee(USDC_ASSET_ID, Permill::zero()); let amount = 5000 * INTERNAL_UNIT; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + amount + )); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + amount + )); assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), 0); }); @@ -481,7 +591,7 @@ mod redeem { ExtBuilder::default().mints(ALICE, 5000 * INTERNAL_UNIT).build_and_execute(|| { set_redemption_fee(USDC_ASSET_ID, Permill::zero()); - let debt = PsmDebt::::get(USDC_ASSET_ID); + let debt = PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); let donation = 5000 * INTERNAL_UNIT; // Defensive path: simulate donated reserves by funding psm_account() @@ -497,18 +607,33 @@ mod redeem { // Should fail because redemption is limited by debt, not reserve assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, redeem_amount), + Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + redeem_amount + ), Error::::InsufficientReserve ); // Verify boundary: exactly debt works, but debt+1 does not hypothetically!({ - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, debt)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + debt + )); assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), donation); }); assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, debt + 1), + Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + debt + 1 + ), Error::::InsufficientReserve ); }); @@ -521,15 +646,21 @@ mod governance { #[test] fn set_minting_fee_works() { new_test_ext().execute_with(|| { - let old_fee = MintingFee::::get(USDC_ASSET_ID); + let old_fee = MintingFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); let new_fee = Permill::from_percent(5); - assert_ok!(Psm::set_minting_fee(RuntimeOrigin::root(), USDC_ASSET_ID, new_fee)); + assert_ok!(Psm::set_minting_fee( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + new_fee + )); - assert_eq!(MintingFee::::get(USDC_ASSET_ID), new_fee); + assert_eq!(MintingFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), new_fee); System::assert_has_event( Event::::MintingFeeUpdated { + internal_asset: INTERNAL_ASSET_ID, asset_id: USDC_ASSET_ID, old_value: old_fee, new_value: new_fee, @@ -542,33 +673,40 @@ mod governance { #[test] fn set_minting_fee_unauthorized() { new_test_ext().execute_with(|| { - let old_fee = MintingFee::::get(USDC_ASSET_ID); + let old_fee = MintingFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); assert_noop!( Psm::set_minting_fee( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDC_ASSET_ID, Permill::from_percent(5) ), DispatchError::BadOrigin ); - assert_eq!(MintingFee::::get(USDC_ASSET_ID), old_fee); + assert_eq!(MintingFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), old_fee); }); } #[test] fn set_redemption_fee_works() { new_test_ext().execute_with(|| { - let old_fee = RedemptionFee::::get(USDC_ASSET_ID); + let old_fee = RedemptionFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); let new_fee = Permill::from_percent(5); - assert_ok!(Psm::set_redemption_fee(RuntimeOrigin::root(), USDC_ASSET_ID, new_fee)); + assert_ok!(Psm::set_redemption_fee( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + new_fee + )); - assert_eq!(RedemptionFee::::get(USDC_ASSET_ID), new_fee); + assert_eq!(RedemptionFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), new_fee); System::assert_has_event( Event::::RedemptionFeeUpdated { + internal_asset: INTERNAL_ASSET_ID, asset_id: USDC_ASSET_ID, old_value: old_fee, new_value: new_fee, @@ -581,35 +719,37 @@ mod governance { #[test] fn set_redemption_fee_unauthorized() { new_test_ext().execute_with(|| { - let old_fee = RedemptionFee::::get(USDC_ASSET_ID); + let old_fee = RedemptionFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); assert_noop!( Psm::set_redemption_fee( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDC_ASSET_ID, Permill::from_percent(5) ), DispatchError::BadOrigin ); - assert_eq!(RedemptionFee::::get(USDC_ASSET_ID), old_fee); + assert_eq!(RedemptionFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), old_fee); }); } #[test] - fn set_max_psm_debt_works() { + fn set_max_debt_works() { new_test_ext().execute_with(|| { - let old_ratio = MaxPsmDebtOfTotal::::get(); - let new_ratio = Permill::from_percent(20); + let old_value = psm_max_debt(); + let new_value = 5_000_000 * INTERNAL_UNIT; - assert_ok!(Psm::set_max_psm_debt(RuntimeOrigin::root(), new_ratio)); + assert_ok!(Psm::set_max_debt(RuntimeOrigin::root(), INTERNAL_ASSET_ID, new_value)); - assert_eq!(MaxPsmDebtOfTotal::::get(), new_ratio); + assert_eq!(psm_max_debt(), new_value); System::assert_has_event( - Event::::MaxPsmDebtOfTotalUpdated { - old_value: old_ratio, - new_value: new_ratio, + Event::::MaxDebtUpdated { + internal_asset: INTERNAL_ASSET_ID, + old_value, + new_value, } .into(), ); @@ -617,16 +757,87 @@ mod governance { } #[test] - fn set_max_psm_debt_unauthorized() { + fn set_max_debt_unauthorized() { new_test_ext().execute_with(|| { - let old_ratio = MaxPsmDebtOfTotal::::get(); + let old_value = psm_max_debt(); assert_noop!( - Psm::set_max_psm_debt(RuntimeOrigin::signed(ALICE), Permill::from_percent(20)), + Psm::set_max_debt( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + 5_000_000 * INTERNAL_UNIT + ), DispatchError::BadOrigin ); - assert_eq!(MaxPsmDebtOfTotal::::get(), old_ratio); + assert_eq!(psm_max_debt(), old_value); + }); + } + + #[test] + fn set_max_debt_rejects_value_undershooting_an_asset_ceiling() { + new_test_ext().execute_with(|| { + // Test PSM splits ceilings USDC 60% / USDT 40%. Pin max_debt and fill USDC to its + // 60% ceiling (600 of 1000). + set_max_debt(1_000 * INTERNAL_UNIT); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 600 * INTERNAL_UNIT + )); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), 600 * INTERNAL_UNIT); + + // Lowering max_debt to 700 keeps the AGGREGATE within bounds (600 <= 700) but drops + // USDC's 60% ceiling to 420 < 600. The per-asset guard rejects it — a plain aggregate + // floor would not have. + assert_noop!( + Psm::set_max_debt(RuntimeOrigin::root(), INTERNAL_ASSET_ID, 700 * INTERNAL_UNIT), + Error::::CeilingBelowOutstandingDebt + ); + + // A value where USDC's ceiling still covers its debt is accepted, and the per-asset + // invariant holds. + assert_ok!(Psm::set_max_debt( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + 1_000 * INTERNAL_UNIT + )); + assert_ok!(Psm::do_try_state()); + }); + } + + #[test] + fn set_asset_ceiling_weight_rejects_weight_undershooting_debt() { + new_test_ext().execute_with(|| { + set_max_debt(1_000 * INTERNAL_UNIT); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 600 * INTERNAL_UNIT + )); + + // USDC holds 600 at its 60% (=600) ceiling. Cutting its weight to 30% would drop the + // ceiling to (30/70)*1000 = 428 < 600 — rejected. + assert_noop!( + Psm::set_asset_ceiling_weight( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + Permill::from_percent(30) + ), + Error::::CeilingBelowOutstandingDebt + ); + + // Raising its weight only grows the ceiling, so it is accepted. + assert_ok!(Psm::set_asset_ceiling_weight( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + Permill::from_percent(80) + )); + assert_ok!(Psm::do_try_state()); }); } @@ -635,13 +846,25 @@ mod governance { new_test_ext().execute_with(|| { let new_status = CircuitBreakerLevel::MintingDisabled; - assert_ok!(Psm::set_asset_status(RuntimeOrigin::root(), USDC_ASSET_ID, new_status)); + assert_ok!(Psm::set_asset_status( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + new_status + )); - assert_eq!(ExternalAssets::::get(USDC_ASSET_ID), Some(new_status)); + assert_eq!( + ExternalAssets::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID).map(|e| e.status), + Some(new_status), + ); System::assert_has_event( - Event::::AssetStatusUpdated { asset_id: USDC_ASSET_ID, status: new_status } - .into(), + Event::::AssetStatusUpdated { + internal_asset: INTERNAL_ASSET_ID, + asset_id: USDC_ASSET_ID, + status: new_status, + } + .into(), ); }); } @@ -649,18 +872,19 @@ mod governance { #[test] fn set_asset_status_unauthorized() { new_test_ext().execute_with(|| { - let old_status = ExternalAssets::::get(USDC_ASSET_ID); + let old_status = ExternalAssets::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); assert_noop!( Psm::set_asset_status( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled ), DispatchError::BadOrigin ); - assert_eq!(ExternalAssets::::get(USDC_ASSET_ID), old_status); + assert_eq!(ExternalAssets::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), old_status); }); } @@ -670,6 +894,7 @@ mod governance { assert_noop!( Psm::set_asset_status( RuntimeOrigin::root(), + INTERNAL_ASSET_ID, UNSUPPORTED_ASSET_ID, CircuitBreakerLevel::MintingDisabled ), @@ -681,19 +906,24 @@ mod governance { #[test] fn set_asset_ceiling_weight_works() { new_test_ext().execute_with(|| { - let old_ratio = AssetCeilingWeight::::get(USDC_ASSET_ID); + let old_ratio = AssetCeilingWeight::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); let new_ratio = Permill::from_percent(80); assert_ok!(Psm::set_asset_ceiling_weight( RuntimeOrigin::root(), + INTERNAL_ASSET_ID, USDC_ASSET_ID, new_ratio )); - assert_eq!(AssetCeilingWeight::::get(USDC_ASSET_ID), new_ratio); + assert_eq!( + AssetCeilingWeight::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), + new_ratio + ); System::assert_has_event( Event::::AssetCeilingWeightUpdated { + internal_asset: INTERNAL_ASSET_ID, asset_id: USDC_ASSET_ID, old_value: old_ratio, new_value: new_ratio, @@ -706,18 +936,22 @@ mod governance { #[test] fn set_asset_ceiling_weight_unauthorized() { new_test_ext().execute_with(|| { - let old_ratio = AssetCeilingWeight::::get(USDC_ASSET_ID); + let old_ratio = AssetCeilingWeight::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); assert_noop!( Psm::set_asset_ceiling_weight( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDC_ASSET_ID, Permill::from_percent(80) ), DispatchError::BadOrigin ); - assert_eq!(AssetCeilingWeight::::get(USDC_ASSET_ID), old_ratio); + assert_eq!( + AssetCeilingWeight::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), + old_ratio + ); }); } @@ -726,14 +960,22 @@ mod governance { new_test_ext().execute_with(|| { let new_asset = 99u32; create_asset_with_metadata(new_asset); - assert!(!Psm::is_approved_asset(&new_asset)); + assert!(!Psm::is_approved_asset(&INTERNAL_ASSET_ID, &new_asset)); - assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), new_asset)); + assert_ok!(Psm::add_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + new_asset + )); - assert!(Psm::is_approved_asset(&new_asset)); + assert!(Psm::is_approved_asset(&INTERNAL_ASSET_ID, &new_asset)); System::assert_has_event( - Event::::ExternalAssetAdded { asset_id: new_asset }.into(), + Event::::ExternalAssetAdded { + internal_asset: INTERNAL_ASSET_ID, + asset_id: new_asset, + } + .into(), ); }); } @@ -752,8 +994,14 @@ mod governance { 8 )); - assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), new_asset)); - assert_eq!(crate::ExternalDecimals::::get(new_asset), Some(8)); + assert_ok!(Psm::add_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + new_asset + )); + let stored = crate::ExternalAssets::::get(INTERNAL_ASSET_ID, new_asset) + .expect("external present"); + assert_eq!(stored.decimals, 8); }); } @@ -772,7 +1020,7 @@ mod governance { )); assert_noop!( - Psm::add_external_asset(RuntimeOrigin::root(), new_asset), + Psm::add_external_asset(RuntimeOrigin::root(), INTERNAL_ASSET_ID, new_asset), Error::::DecimalsRangeExceeded ); }); @@ -782,7 +1030,7 @@ mod governance { fn add_external_asset_unauthorized() { new_test_ext().execute_with(|| { assert_noop!( - Psm::add_external_asset(RuntimeOrigin::signed(ALICE), 99u32), + Psm::add_external_asset(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, 99u32), DispatchError::BadOrigin ); }); @@ -792,7 +1040,7 @@ mod governance { fn add_external_asset_fails_already_approved() { new_test_ext().execute_with(|| { assert_noop!( - Psm::add_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID), + Psm::add_external_asset(RuntimeOrigin::root(), INTERNAL_ASSET_ID, USDC_ASSET_ID), Error::::AssetAlreadyApproved ); }); @@ -806,10 +1054,10 @@ mod governance { assert!(!>::asset_exists( ghost )); - assert!(!crate::Pallet::::is_approved_asset(&ghost)); + assert!(!crate::Pallet::::is_approved_asset(&INTERNAL_ASSET_ID, &ghost)); assert_noop!( - Psm::add_external_asset(RuntimeOrigin::root(), ghost), + Psm::add_external_asset(RuntimeOrigin::root(), INTERNAL_ASSET_ID, ghost), Error::::AssetDoesNotExist ); }); @@ -819,18 +1067,23 @@ mod governance { fn add_external_asset_fails_too_many() { new_test_ext().execute_with(|| { use frame_support::traits::Get; - let max: u32 = ::MaxExternalAssets::get(); - let existing = crate::ExternalAssets::::count(); + let max: u32 = ::MaxExternalAssetsPerPsm::get(); + let existing = + crate::ExternalAssets::::iter_prefix(INTERNAL_ASSET_ID).count() as u32; // Fill up to the limit. for i in 0..(max - existing) { let asset_id = 1000 + i; create_asset_with_metadata(asset_id); - assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), asset_id)); + assert_ok!(Psm::add_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + asset_id + )); } // One more should fail. create_asset_with_metadata(9999); assert_noop!( - Psm::add_external_asset(RuntimeOrigin::root(), 9999), + Psm::add_external_asset(RuntimeOrigin::root(), INTERNAL_ASSET_ID, 9999), Error::::TooManyAssets ); }); @@ -839,14 +1092,22 @@ mod governance { #[test] fn remove_external_asset_works() { new_test_ext().execute_with(|| { - assert!(Psm::is_approved_asset(&USDC_ASSET_ID)); + assert!(Psm::is_approved_asset(&INTERNAL_ASSET_ID, &USDC_ASSET_ID)); - assert_ok!(Psm::remove_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID)); + assert_ok!(Psm::remove_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID + )); - assert!(!Psm::is_approved_asset(&USDC_ASSET_ID)); + assert!(!Psm::is_approved_asset(&INTERNAL_ASSET_ID, &USDC_ASSET_ID)); System::assert_has_event( - Event::::ExternalAssetRemoved { asset_id: USDC_ASSET_ID }.into(), + Event::::ExternalAssetRemoved { + internal_asset: INTERNAL_ASSET_ID, + asset_id: USDC_ASSET_ID, + } + .into(), ); }); } @@ -855,16 +1116,20 @@ mod governance { fn remove_external_asset_cleans_up_configuration() { new_test_ext().execute_with(|| { // Verify configuration exists before removal (explicitly set in genesis) - assert!(MintingFee::::contains_key(USDC_ASSET_ID)); - assert!(RedemptionFee::::contains_key(USDC_ASSET_ID)); - assert!(AssetCeilingWeight::::contains_key(USDC_ASSET_ID)); + assert!(MintingFee::::contains_key(INTERNAL_ASSET_ID, USDC_ASSET_ID)); + assert!(RedemptionFee::::contains_key(INTERNAL_ASSET_ID, USDC_ASSET_ID)); + assert!(AssetCeilingWeight::::contains_key(INTERNAL_ASSET_ID, USDC_ASSET_ID)); - assert_ok!(Psm::remove_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID)); + assert_ok!(Psm::remove_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID + )); // Verify storage entries are removed (not just set to default) - assert!(!MintingFee::::contains_key(USDC_ASSET_ID)); - assert!(!RedemptionFee::::contains_key(USDC_ASSET_ID)); - assert!(!AssetCeilingWeight::::contains_key(USDC_ASSET_ID)); + assert!(!MintingFee::::contains_key(INTERNAL_ASSET_ID, USDC_ASSET_ID)); + assert!(!RedemptionFee::::contains_key(INTERNAL_ASSET_ID, USDC_ASSET_ID)); + assert!(!AssetCeilingWeight::::contains_key(INTERNAL_ASSET_ID, USDC_ASSET_ID)); }); } @@ -872,7 +1137,11 @@ mod governance { fn remove_external_asset_unauthorized() { new_test_ext().execute_with(|| { assert_noop!( - Psm::remove_external_asset(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID), + Psm::remove_external_asset( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID + ), DispatchError::BadOrigin ); }); @@ -882,7 +1151,7 @@ mod governance { fn remove_external_asset_fails_not_approved() { new_test_ext().execute_with(|| { assert_noop!( - Psm::remove_external_asset(RuntimeOrigin::root(), 99u32), + Psm::remove_external_asset(RuntimeOrigin::root(), INTERNAL_ASSET_ID, 99u32), Error::::AssetNotApproved ); }); @@ -892,7 +1161,7 @@ mod governance { fn remove_external_asset_fails_has_debt() { ExtBuilder::default().mints(ALICE, 1000 * INTERNAL_UNIT).build_and_execute(|| { assert_noop!( - Psm::remove_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID), + Psm::remove_external_asset(RuntimeOrigin::root(), INTERNAL_ASSET_ID, USDC_ASSET_ID), Error::::AssetHasDebt ); }); @@ -908,23 +1177,29 @@ mod governance { // With non-zero debt, removal is blocked. assert_ok!(Psm::mint( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDC_ASSET_ID, 1000 * INTERNAL_UNIT )); assert_noop!( - Psm::remove_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID), + Psm::remove_external_asset(RuntimeOrigin::root(), INTERNAL_ASSET_ID, USDC_ASSET_ID), Error::::AssetHasDebt ); // Drain debt to zero — removal now succeeds. assert_ok!(Psm::redeem( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDC_ASSET_ID, 1000 * INTERNAL_UNIT )); - assert_eq!(PsmDebt::::get(USDC_ASSET_ID), 0); - assert_ok!(Psm::remove_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID)); - assert!(!ExternalAssets::::contains_key(USDC_ASSET_ID)); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), 0); + assert_ok!(Psm::remove_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID + )); + assert!(!ExternalAssets::::contains_key(INTERNAL_ASSET_ID, USDC_ASSET_ID)); }); } @@ -935,58 +1210,68 @@ mod governance { assert_ok!(Psm::set_asset_status( RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, USDC_ASSET_ID, new_status )); - assert_eq!(ExternalAssets::::get(USDC_ASSET_ID), Some(new_status)); + assert_eq!( + ExternalAssets::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID).map(|e| e.status), + Some(new_status), + ); }); } #[test] fn emergency_origin_cannot_set_minting_fee() { new_test_ext().execute_with(|| { - let old_fee = MintingFee::::get(USDC_ASSET_ID); + let old_fee = MintingFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); assert_noop!( Psm::set_minting_fee( RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, USDC_ASSET_ID, Permill::from_percent(5) ), Error::::InsufficientPrivilege ); - assert_eq!(MintingFee::::get(USDC_ASSET_ID), old_fee); + assert_eq!(MintingFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), old_fee); }); } #[test] fn emergency_origin_cannot_set_redemption_fee() { new_test_ext().execute_with(|| { - let old_fee = RedemptionFee::::get(USDC_ASSET_ID); + let old_fee = RedemptionFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); assert_noop!( Psm::set_redemption_fee( RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, USDC_ASSET_ID, Permill::from_percent(5) ), Error::::InsufficientPrivilege ); - assert_eq!(RedemptionFee::::get(USDC_ASSET_ID), old_fee); + assert_eq!(RedemptionFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), old_fee); }); } #[test] - fn emergency_origin_can_set_max_psm_debt() { + fn emergency_origin_can_set_max_debt() { new_test_ext().execute_with(|| { - let new_ratio = Permill::from_percent(20); + let new_value = 5_000_000 * INTERNAL_UNIT; - assert_ok!(Psm::set_max_psm_debt(RuntimeOrigin::signed(EMERGENCY_ACCOUNT), new_ratio)); + assert_ok!(Psm::set_max_debt( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, + new_value, + )); - assert_eq!(MaxPsmDebtOfTotal::::get(), new_ratio); + assert_eq!(psm_max_debt(), new_value); }); } @@ -997,11 +1282,15 @@ mod governance { assert_ok!(Psm::set_asset_ceiling_weight( RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, USDC_ASSET_ID, new_ratio )); - assert_eq!(AssetCeilingWeight::::get(USDC_ASSET_ID), new_ratio); + assert_eq!( + AssetCeilingWeight::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), + new_ratio + ); }); } @@ -1011,11 +1300,15 @@ mod governance { let new_asset = 99u32; assert_noop!( - Psm::add_external_asset(RuntimeOrigin::signed(EMERGENCY_ACCOUNT), new_asset), + Psm::add_external_asset( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, + new_asset + ), Error::::InsufficientPrivilege ); - assert!(!Psm::is_approved_asset(&new_asset)); + assert!(!Psm::is_approved_asset(&INTERNAL_ASSET_ID, &new_asset)); }); } @@ -1023,11 +1316,15 @@ mod governance { fn emergency_origin_cannot_remove_external_asset() { new_test_ext().execute_with(|| { assert_noop!( - Psm::remove_external_asset(RuntimeOrigin::signed(EMERGENCY_ACCOUNT), USDC_ASSET_ID), + Psm::remove_external_asset( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, + USDC_ASSET_ID + ), Error::::InsufficientPrivilege ); - assert!(Psm::is_approved_asset(&USDC_ASSET_ID)); + assert!(Psm::is_approved_asset(&INTERNAL_ASSET_ID, &USDC_ASSET_ID)); }); } @@ -1037,6 +1334,7 @@ mod governance { assert_noop!( Psm::set_minting_fee( RuntimeOrigin::root(), + INTERNAL_ASSET_ID, UNSUPPORTED_ASSET_ID, Permill::from_percent(5) ), @@ -1051,6 +1349,7 @@ mod governance { assert_noop!( Psm::set_redemption_fee( RuntimeOrigin::root(), + INTERNAL_ASSET_ID, UNSUPPORTED_ASSET_ID, Permill::from_percent(5) ), @@ -1065,6 +1364,7 @@ mod governance { assert_noop!( Psm::set_asset_ceiling_weight( RuntimeOrigin::root(), + INTERNAL_ASSET_ID, UNSUPPORTED_ASSET_ID, Permill::from_percent(50) ), @@ -1078,29 +1378,29 @@ mod helpers { use super::*; #[test] - fn max_psm_debt_calculation() { + fn max_psm_debt_reads_psm_info() { new_test_ext().execute_with(|| { - set_mock_maximum_issuance(10_000_000 * INTERNAL_UNIT); - set_max_psm_debt_ratio(Permill::from_percent(10)); - - let max_debt = crate::Pallet::::max_psm_debt(); - let expected = Permill::from_percent(10).mul_floor(10_000_000 * INTERNAL_UNIT); + set_max_debt(1_000_000 * INTERNAL_UNIT); - assert_eq!(max_debt, expected); + assert_eq!( + crate::Pallet::::max_psm_debt(&INTERNAL_ASSET_ID), + 1_000_000 * INTERNAL_UNIT + ); }); } #[test] fn max_asset_debt_calculation() { new_test_ext().execute_with(|| { - set_mock_maximum_issuance(10_000_000 * INTERNAL_UNIT); - set_max_psm_debt_ratio(Permill::from_percent(10)); + set_max_debt(1_000_000 * INTERNAL_UNIT); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(60)); - let max_asset_debt = crate::Pallet::::max_asset_debt(USDC_ASSET_ID); - // 10M * 10% * 60% = 600K - let expected = Permill::from_percent(60) - .mul_floor(Permill::from_percent(10).mul_floor(10_000_000 * INTERNAL_UNIT)); + let info = crate::Psm::::get(INTERNAL_ASSET_ID).unwrap(); + let max_asset_debt = + crate::Pallet::::max_asset_debt(&INTERNAL_ASSET_ID, &USDC_ASSET_ID, &info); + // 1M * 60% / (60% + 40%) = 600K + let expected = sp_runtime::Perbill::from_rational(60u32, 100u32) + .mul_floor(1_000_000 * INTERNAL_UNIT); assert_eq!(max_asset_debt, expected); }); @@ -1109,16 +1409,22 @@ mod helpers { #[test] fn is_approved_asset_true() { new_test_ext().execute_with(|| { - assert!(crate::Pallet::::is_approved_asset(&USDC_ASSET_ID)); - assert!(crate::Pallet::::is_approved_asset(&USDT_ASSET_ID)); + assert!(crate::Pallet::::is_approved_asset(&INTERNAL_ASSET_ID, &USDC_ASSET_ID)); + assert!(crate::Pallet::::is_approved_asset(&INTERNAL_ASSET_ID, &USDT_ASSET_ID)); }); } #[test] fn is_approved_asset_false() { new_test_ext().execute_with(|| { - assert!(!crate::Pallet::::is_approved_asset(&UNSUPPORTED_ASSET_ID)); - assert!(!crate::Pallet::::is_approved_asset(&INTERNAL_ASSET_ID)); + assert!(!crate::Pallet::::is_approved_asset( + &INTERNAL_ASSET_ID, + &UNSUPPORTED_ASSET_ID + )); + assert!(!crate::Pallet::::is_approved_asset( + &INTERNAL_ASSET_ID, + &INTERNAL_ASSET_ID + )); }); } @@ -1126,30 +1432,42 @@ mod helpers { fn is_approved_asset_false_after_removal() { new_test_ext().execute_with(|| { // USDC is approved at genesis. - assert!(crate::Pallet::::is_approved_asset(&USDC_ASSET_ID)); + assert!(crate::Pallet::::is_approved_asset(&INTERNAL_ASSET_ID, &USDC_ASSET_ID)); // Removal flips the predicate. - assert_ok!(Psm::remove_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID)); - assert!(!crate::Pallet::::is_approved_asset(&USDC_ASSET_ID)); + assert_ok!(Psm::remove_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID + )); + assert!(!crate::Pallet::::is_approved_asset(&INTERNAL_ASSET_ID, &USDC_ASSET_ID)); }); } #[test] fn get_reserve_returns_balance() { new_test_ext().execute_with(|| { - assert_eq!(crate::Pallet::::get_reserve(USDC_ASSET_ID), 0); + assert_eq!(crate::Pallet::::get_reserve(&INTERNAL_ASSET_ID, &USDC_ASSET_ID), 0); let mint_amount = 1000 * INTERNAL_UNIT; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + mint_amount + )); - assert_eq!(crate::Pallet::::get_reserve(USDC_ASSET_ID), mint_amount); + assert_eq!( + crate::Pallet::::get_reserve(&INTERNAL_ASSET_ID, &USDC_ASSET_ID), + mint_amount + ); }); } #[test] fn account_id_is_derived() { new_test_ext().execute_with(|| { - let account = crate::Pallet::::account_id(); + let account = crate::Pallet::::psm_account(&INTERNAL_ASSET_ID); assert_ne!(account, ALICE); assert_ne!(account, BOB); assert_ne!(account, INSURANCE_FUND); @@ -1173,50 +1491,58 @@ mod circuit_breaker { // Seed debt upfront so every redeem below has something to drain // against — the circuit breaker check is what we want to exercise, // not the debt floor. - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), asset, 500 * INTERNAL_UNIT)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + asset, + 500 * INTERNAL_UNIT + )); // Baseline: AllEnabled — both swaps work. - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), asset, amount)); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), asset, amount)); + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, asset, amount)); + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, asset, amount)); // Transition: AllEnabled -> MintingDisabled. Mint blocked, redeem // still works (useful for draining debt during a partial outage). assert_ok!(Psm::set_asset_status( RuntimeOrigin::root(), + INTERNAL_ASSET_ID, asset, CircuitBreakerLevel::MintingDisabled, )); assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), asset, amount), + Psm::mint(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, asset, amount), Error::::MintingStopped ); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), asset, amount)); + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, asset, amount)); // Transition: MintingDisabled -> AllDisabled. Both blocked. Debt is // still > 0 here, so a redeem rejection is a real circuit-breaker // rejection (AllSwapsStopped), not an InsufficientReserve one. assert_ok!(Psm::set_asset_status( RuntimeOrigin::root(), + INTERNAL_ASSET_ID, asset, CircuitBreakerLevel::AllDisabled, )); assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), asset, amount), + Psm::mint(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, asset, amount), Error::::MintingStopped ); assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), asset, amount), + Psm::redeem(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, asset, amount), Error::::AllSwapsStopped ); // Transition: AllDisabled -> AllEnabled. Both resume normally. assert_ok!(Psm::set_asset_status( RuntimeOrigin::root(), + INTERNAL_ASSET_ID, asset, CircuitBreakerLevel::AllEnabled, )); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), asset, amount)); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), asset, amount)); + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, asset, amount)); + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, asset, amount)); }); } } @@ -1231,15 +1557,15 @@ mod ceiling_redistribution { // PSM ceiling = 50% of 20M = 10M // USDC ceiling = 60% of 10M = 6M // USDT ceiling = 40% of 10M = 4M - set_max_psm_debt_ratio(Permill::from_percent(50)); + set_max_debt(10_000_000 * INTERNAL_UNIT); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(60)); set_asset_ceiling_weight(USDT_ASSET_ID, Permill::from_percent(40)); - let max_psm = crate::Pallet::::max_psm_debt(); + let max_psm = crate::Pallet::::max_psm_debt(&INTERNAL_ASSET_ID); assert_eq!(max_psm, 10_000_000 * INTERNAL_UNIT); // Normal ceiling for USDT = 4M - let usdt_normal_ceiling = crate::Pallet::::max_asset_debt(USDT_ASSET_ID); + let usdt_normal_ceiling = psm_max_asset_debt(USDT_ASSET_ID); assert_eq!(usdt_normal_ceiling, 4_000_000 * INTERNAL_UNIT); // Disable USDC minting and set weight to 0% (governance workflow) @@ -1255,6 +1581,7 @@ mod ceiling_redistribution { // Mint up to the old ceiling (4M) - should work assert_ok!(Psm::mint( RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, USDT_ASSET_ID, 4_000_000 * INTERNAL_UNIT )); @@ -1262,16 +1589,25 @@ mod ceiling_redistribution { // Mint another 5M - this would fail with old logic but should work now assert_ok!(Psm::mint( RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, USDT_ASSET_ID, 5_000_000 * INTERNAL_UNIT )); // Total USDT debt should be 9M - assert_eq!(PsmDebt::::get(USDT_ASSET_ID), 9_000_000 * INTERNAL_UNIT); + assert_eq!( + PsmDebt::::get(INTERNAL_ASSET_ID, USDT_ASSET_ID), + 9_000_000 * INTERNAL_UNIT + ); // Can't mint more than PSM ceiling (already at 9M, only 1M left) assert_noop!( - Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, 2_000_000 * INTERNAL_UNIT), + Psm::mint( + RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, + USDT_ASSET_ID, + 2_000_000 * INTERNAL_UNIT + ), Error::::ExceedsMaxPsmDebt ); }); @@ -1283,10 +1619,14 @@ mod ceiling_redistribution { // Add a third asset let bridged_usdc_asset_id = 4u32; create_asset_with_metadata(bridged_usdc_asset_id); - assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), bridged_usdc_asset_id)); + assert_ok!(Psm::add_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + bridged_usdc_asset_id + )); // Setup: USDC 50%, USDT 25%, ETH:USDC 25% - set_max_psm_debt_ratio(Permill::from_percent(50)); + set_max_debt(10_000_000 * INTERNAL_UNIT); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(50)); set_asset_ceiling_weight(USDT_ASSET_ID, Permill::from_percent(25)); set_asset_ceiling_weight(bridged_usdc_asset_id, Permill::from_percent(25)); @@ -1296,10 +1636,14 @@ mod ceiling_redistribution { fund_external_asset(USDC_ASSET_ID, ALICE, 4_000_000 * INTERNAL_UNIT); assert_ok!(Psm::mint( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDC_ASSET_ID, 4_000_000 * INTERNAL_UNIT )); - assert_eq!(PsmDebt::::get(USDC_ASSET_ID), 4_000_000 * INTERNAL_UNIT); + assert_eq!( + PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID), + 4_000_000 * INTERNAL_UNIT + ); // Now disable USDC and set weight to 0% set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); @@ -1315,13 +1659,19 @@ mod ceiling_redistribution { // USDT can mint up to 5M (redistributed ceiling) assert_ok!(Psm::mint( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDT_ASSET_ID, 5_000_000 * INTERNAL_UNIT )); // USDT can't mint more than its redistributed ceiling assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDT_ASSET_ID, 1_000_000 * INTERNAL_UNIT), + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDT_ASSET_ID, + 1_000_000 * INTERNAL_UNIT + ), Error::::ExceedsMaxPsmDebt ); }); @@ -1331,7 +1681,7 @@ mod ceiling_redistribution { fn normal_weights_use_proportional_ceilings() { new_test_ext().execute_with(|| { // Setup: USDC 60%, USDT 40% - set_max_psm_debt_ratio(Permill::from_percent(50)); + set_max_debt(10_000_000 * INTERNAL_UNIT); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(60)); set_asset_ceiling_weight(USDT_ASSET_ID, Permill::from_percent(40)); @@ -1343,13 +1693,19 @@ mod ceiling_redistribution { // Can mint up to 4M assert_ok!(Psm::mint( RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, USDT_ASSET_ID, 4_000_000 * INTERNAL_UNIT )); // Can't mint more - exceeds per-asset ceiling assert_noop!( - Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, 1_000_000 * INTERNAL_UNIT), + Psm::mint( + RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, + USDT_ASSET_ID, + 1_000_000 * INTERNAL_UNIT + ), Error::::ExceedsMaxPsmDebt ); }); @@ -1359,29 +1715,43 @@ mod ceiling_redistribution { fn single_asset_weight_always_normalizes_to_full_ceiling() { new_test_ext().execute_with(|| { // Remove USDT so only USDC remains - assert_ok!(Psm::remove_external_asset(RuntimeOrigin::root(), USDT_ASSET_ID)); + assert_ok!(Psm::remove_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDT_ASSET_ID + )); - set_max_psm_debt_ratio(Permill::from_percent(50)); + set_max_debt(10_000_000 * INTERNAL_UNIT); // PSM ceiling = 50% of 20M = 10M let mint_amount = 1000 * INTERNAL_UNIT; // Set USDC weight to 30% — with a single asset this normalizes to 100% set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(30)); - let ceiling_at_30 = crate::Pallet::::max_asset_debt(USDC_ASSET_ID); + let ceiling_at_30 = psm_max_asset_debt(USDC_ASSET_ID); assert_eq!(ceiling_at_30, 10_000_000 * INTERNAL_UNIT); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + mint_amount + )); // Change weight to 80% — still normalizes to 100% set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(80)); - let ceiling_at_80 = crate::Pallet::::max_asset_debt(USDC_ASSET_ID); + let ceiling_at_80 = psm_max_asset_debt(USDC_ASSET_ID); assert_eq!(ceiling_at_80, 10_000_000 * INTERNAL_UNIT); // Setting weight to 0% disables minting set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(0)); - let ceiling_at_0 = crate::Pallet::::max_asset_debt(USDC_ASSET_ID); + let ceiling_at_0 = psm_max_asset_debt(USDC_ASSET_ID); assert_eq!(ceiling_at_0, 0); assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount), + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + mint_amount + ), Error::::ExceedsMaxPsmDebt ); }); @@ -1391,7 +1761,7 @@ mod ceiling_redistribution { fn restoring_weight_restores_normal_ceilings() { new_test_ext().execute_with(|| { // Setup: USDC 60%, USDT 40% - set_max_psm_debt_ratio(Permill::from_percent(50)); + set_max_debt(10_000_000 * INTERNAL_UNIT); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(60)); set_asset_ceiling_weight(USDT_ASSET_ID, Permill::from_percent(40)); @@ -1402,6 +1772,7 @@ mod ceiling_redistribution { set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(0)); assert_ok!(Psm::mint( RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, USDT_ASSET_ID, 5_000_000 * INTERNAL_UNIT )); @@ -1413,7 +1784,12 @@ mod ceiling_redistribution { // Now USDT ceiling is back to 4M, but we already have 5M debt // Can't mint more assert_noop!( - Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, 1_000_000 * INTERNAL_UNIT), + Psm::mint( + RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, + USDT_ASSET_ID, + 1_000_000 * INTERNAL_UNIT + ), Error::::ExceedsMaxPsmDebt ); }); @@ -1453,14 +1829,22 @@ mod cycles { println!("User USDC: {:.2}", user_external_before as f64 / unit); println!("IF internal: {:.2}", if_internal_before as f64 / unit); println!("PSM USDC: {:.2}", psm_external_before as f64 / unit); - println!("PSM Debt: {:.2}", PsmDebt::::get(USDC_ASSET_ID) as f64 / unit); + println!( + "PSM Debt: {:.2}", + PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID) as f64 / unit + ); let mut total_mint_fees = 0u128; let mut total_redeem_fees = 0u128; for i in 0..cycles { // Mint - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + amount + )); let (mint_fee, internal_received) = match last_event() { Event::Minted { fee, received: internal_received, .. } => { @@ -1486,14 +1870,22 @@ mod cycles { "PSM USDC: {:.2}", get_asset_balance(USDC_ASSET_ID, psm_account()) as f64 / unit ); - println!("PSM Debt: {:.2}", PsmDebt::::get(USDC_ASSET_ID) as f64 / unit); + println!( + "PSM Debt: {:.2}", + PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID) as f64 / unit + ); println!( "IF internal: {:.2}", get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND) as f64 / unit ); // Redeem all internal received - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + amount + )); let (redeem_fee, external_received) = match last_event() { Event::Redeemed { fee, external_received, .. } => (fee, external_received), @@ -1517,7 +1909,10 @@ mod cycles { "PSM USDC: {:.2}", get_asset_balance(USDC_ASSET_ID, psm_account()) as f64 / unit ); - println!("PSM Debt: {:.2}", PsmDebt::::get(USDC_ASSET_ID) as f64 / unit); + println!( + "PSM Debt: {:.2}", + PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID) as f64 / unit + ); println!( "IF internal: {:.2}", get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND) as f64 / unit @@ -1530,7 +1925,7 @@ mod cycles { let if_internal_after = get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND); let psm_external_after = get_asset_balance(USDC_ASSET_ID, psm_account()); - let psm_debt_after = PsmDebt::::get(USDC_ASSET_ID); + let psm_debt_after = PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); println!("\n=== Final State ==="); println!("User USDC: {:.2}", user_external_after as f64 / unit); @@ -1581,11 +1976,13 @@ mod cycles { // Set ceiling for ~1000 cycles // Each cycle: mint 100000, redeem 100000 → debt grows by ~1000 per cycle // For 1000 cycles: need ceiling > 110 + 1000 * 2.19 ≈ 2300 - // 10M * 0.025% = 2500 units ceiling - set_max_psm_debt_ratio(Permill::from_percent(10)); + // 2M * 50% (only USDC weighted) = 1M units ceiling + set_max_debt(2_000_000 * INTERNAL_UNIT); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(50)); - let max_debt = crate::Pallet::::max_asset_debt(USDC_ASSET_ID); + let info = crate::Psm::::get(INTERNAL_ASSET_ID).unwrap(); + let max_debt = + crate::Pallet::::max_asset_debt(&INTERNAL_ASSET_ID, &USDC_ASSET_ID, &info); println!("MAX DEBT: {}", max_debt); @@ -1607,14 +2004,17 @@ mod cycles { println!("User internal: {:.2}", user_internal_before as f64 / unit); println!("IF internal: {:.2}", if_internal_before as f64 / unit); println!("PSM USDC: {:.2}", psm_external_before as f64 / unit); - println!("PSM Debt: {:.2}", PsmDebt::::get(USDC_ASSET_ID) as f64 / unit); + println!( + "PSM Debt: {:.2}", + PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID) as f64 / unit + ); let mut total_mint_fees = 0u128; let mut total_redeem_fees = 0u128; let mut cycle = 0u128; loop { - let current_debt = PsmDebt::::get(USDC_ASSET_ID); + let current_debt = PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); // Check if we can mint another `amount` if current_debt + amount > max_debt { @@ -1628,7 +2028,12 @@ mod cycles { cycle += 1; // Mint - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + amount + )); let (mint_fee, internal_received) = match last_event() { Event::Minted { fee, received: internal_received, .. } => { @@ -1654,14 +2059,22 @@ mod cycles { "PSM USDC: {:.2}", get_asset_balance(USDC_ASSET_ID, psm_account()) as f64 / unit ); - println!("PSM Debt: {:.2}", PsmDebt::::get(USDC_ASSET_ID) as f64 / unit); + println!( + "PSM Debt: {:.2}", + PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID) as f64 / unit + ); println!( "IF internal: {:.2}", get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND) as f64 / unit ); // Redeem all internal received - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + amount + )); let (redeem_fee, external_received) = match last_event() { Event::Redeemed { fee, external_received, .. } => (fee, external_received), @@ -1685,7 +2098,10 @@ mod cycles { "PSM USDC: {:.2}", get_asset_balance(USDC_ASSET_ID, psm_account()) as f64 / unit ); - println!("PSM Debt: {:.2}", PsmDebt::::get(USDC_ASSET_ID) as f64 / unit); + println!( + "PSM Debt: {:.2}", + PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID) as f64 / unit + ); println!( "IF internal: {:.2}", get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND) as f64 / unit @@ -1698,7 +2114,7 @@ mod cycles { let if_internal_after = get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND); let psm_external_after = get_asset_balance(USDC_ASSET_ID, psm_account()); - let psm_debt_after = PsmDebt::::get(USDC_ASSET_ID); + let psm_debt_after = PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); println!("\n=== Final State ==="); println!("Total cycles: {}", cycle); @@ -1731,14 +2147,19 @@ mod cycles { // Redeem to fully drain PSM debt to 0 // When redeeming: external_received = internal_paid - fee = internal_paid * (1 - // fee_rate) So: internal_paid = external_received / (1 - fee_rate) - let fee_rate = RedemptionFee::::get(USDC_ASSET_ID); + let fee_rate = RedemptionFee::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); println!("Fee Rate: {:#?}", fee_rate); let complement_parts = 1_000_000u128 - fee_rate.deconstruct() as u128; println!("Complemenet Part: {:#?}", complement_parts); let internal_needed = (psm_debt_after * 1_000_000).div_ceil(complement_parts); println!("internal Needed: {:#?}", internal_needed); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, internal_needed)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + internal_needed + )); let (redeem_fee, _external_received) = match last_event() { Event::Redeemed { fee, external_received, .. } => (fee, external_received), @@ -1751,7 +2172,7 @@ mod cycles { let user_internal_after = get_asset_balance(INTERNAL_ASSET_ID, ALICE); let if_internal_after = get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND); let psm_external_after = get_asset_balance(USDC_ASSET_ID, psm_account()); - let psm_debt_after = PsmDebt::::get(USDC_ASSET_ID); + let psm_debt_after = PsmDebt::::get(INTERNAL_ASSET_ID, USDC_ASSET_ID); let total_fees = total_mint_fees + total_redeem_fees; let if_increase = if_internal_after - if_internal_before; @@ -1796,7 +2217,7 @@ mod cycles { /// registered with PSM via `register_external_asset_with_weight` inside each test. mod decimal_scaling { use super::*; - use crate::{ExternalDecimals, MAX_DECIMALS_DIFF}; + use crate::MAX_DECIMALS_DIFF; fn set_zero_fees(asset_id: u32) { set_minting_fee(asset_id, Permill::zero()); @@ -1871,13 +2292,18 @@ mod decimal_scaling { let expected_internal = 10_000 * INTERNAL_UNIT; // 10_000 internal let alice_usdx_before = get_asset_balance(USDX_ASSET_ID, ALICE); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, usdx_raw)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + usdx_raw + )); // User spent exactly usdx_raw (no dust path on scale-up). assert_eq!(get_asset_balance(USDX_ASSET_ID, ALICE), alice_usdx_before - usdx_raw); assert_eq!(get_asset_balance(USDX_ASSET_ID, psm_account()), usdx_raw); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, ALICE), expected_internal); - assert_eq!(PsmDebt::::get(USDX_ASSET_ID), expected_internal); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, USDX_ASSET_ID), expected_internal); }); } @@ -1895,13 +2321,21 @@ mod decimal_scaling { let expected_internal = 100 * INTERNAL_UNIT; let alice_before = get_asset_balance(DAI_MOCK_ASSET_ID, ALICE); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), DAI_MOCK_ASSET_ID, dai_raw)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + DAI_MOCK_ASSET_ID, + dai_raw + )); // Only effective amount left the user; dust (123 wei) stays. assert_eq!(get_asset_balance(DAI_MOCK_ASSET_ID, ALICE), alice_before - effective_dai); assert_eq!(get_asset_balance(DAI_MOCK_ASSET_ID, psm_account()), effective_dai); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, ALICE), expected_internal); - assert_eq!(PsmDebt::::get(DAI_MOCK_ASSET_ID), expected_internal); + assert_eq!( + PsmDebt::::get(INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID), + expected_internal + ); }); } @@ -1932,9 +2366,14 @@ mod decimal_scaling { let alice_dai_before = get_asset_balance(DAI_MOCK_ASSET_ID, ALICE); let alice_internal_before = get_asset_balance(INTERNAL_ASSET_ID, ALICE); let if_before = get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND); - let debt_before = PsmDebt::::get(DAI_MOCK_ASSET_ID); + let debt_before = PsmDebt::::get(INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), DAI_MOCK_ASSET_ID, deposit)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + DAI_MOCK_ASSET_ID, + deposit + )); // ALICE keeps exactly `dust` of the submitted DAI; only // `effective_external` left her wallet into the PSM reserve. @@ -1951,12 +2390,16 @@ mod decimal_scaling { ); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND), if_before + fee); // Debt grows by the internal-equivalent of the backed deposit. - assert_eq!(PsmDebt::::get(DAI_MOCK_ASSET_ID), debt_before + internal_equivalent); + assert_eq!( + PsmDebt::::get(INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID), + debt_before + internal_equivalent + ); // Minted event carries the effective external amount (what actually // entered the reserve), not the raw submission. System::assert_has_event( Event::::Minted { + internal_asset: INTERNAL_ASSET_ID, who: ALICE, asset_id: DAI_MOCK_ASSET_ID, external_amount: effective_external, @@ -1975,7 +2418,7 @@ mod decimal_scaling { // 999 wei DAI -> internal = 999 / 10^12 = 0. assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), DAI_MOCK_ASSET_ID, 999), + Psm::mint(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID, 999), Error::::AmountTooSmallAfterConversion ); }); @@ -1989,7 +2432,12 @@ mod decimal_scaling { // 50 DAI = 50 internal equivalent, below MinSwapAmount (100 internal). let below = 50 * DAI_UNIT; assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), DAI_MOCK_ASSET_ID, below), + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + DAI_MOCK_ASSET_ID, + below + ), Error::::BelowMinimumSwap ); }); @@ -2006,22 +2454,32 @@ mod decimal_scaling { // First mint so PSM has reserve and debt. let internal_amount = 1000 * INTERNAL_UNIT; let dai_raw = 1000 * DAI_UNIT; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), DAI_MOCK_ASSET_ID, dai_raw)); - assert_eq!(PsmDebt::::get(DAI_MOCK_ASSET_ID), internal_amount); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + DAI_MOCK_ASSET_ID, + dai_raw + )); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID), internal_amount); // Redeem 500 internal -> expect exactly 500 DAI back. let redeem = 500 * INTERNAL_UNIT; let alice_dai_before = get_asset_balance(DAI_MOCK_ASSET_ID, ALICE); let alice_internal_before = get_asset_balance(INTERNAL_ASSET_ID, ALICE); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), DAI_MOCK_ASSET_ID, redeem)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + DAI_MOCK_ASSET_ID, + redeem + )); assert_eq!( get_asset_balance(DAI_MOCK_ASSET_ID, ALICE), alice_dai_before + 500 * DAI_UNIT ); assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, ALICE), alice_internal_before - redeem); - assert_eq!(PsmDebt::::get(DAI_MOCK_ASSET_ID), redeem); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID), redeem); }); } @@ -2034,7 +2492,12 @@ mod decimal_scaling { set_minting_fee(USDX_ASSET_ID, Permill::zero()); // Seed reserve and ALICE's internal balance with a prior 0-fee mint. - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, 10_000 * USDX_UNIT)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + 10_000 * USDX_UNIT + )); set_redemption_fee(USDX_ASSET_ID, Permill::from_percent(1)); @@ -2057,9 +2520,14 @@ mod decimal_scaling { let alice_usdx_before = get_asset_balance(USDX_ASSET_ID, ALICE); let alice_internal_before = get_asset_balance(INTERNAL_ASSET_ID, ALICE); let if_before = get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND); - let debt_before = PsmDebt::::get(USDX_ASSET_ID); + let debt_before = PsmDebt::::get(INTERNAL_ASSET_ID, USDX_ASSET_ID); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, redeem)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + redeem + )); // User receives exactly `external_out` USDX. assert_eq!(get_asset_balance(USDX_ASSET_ID, ALICE), alice_usdx_before + external_out); @@ -2073,7 +2541,10 @@ mod decimal_scaling { // FeeDestination receives only the nominal fee, not fee + dust. assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND), if_before + fee); // Debt reduces by exactly the round-tripped internal amount. - assert_eq!(PsmDebt::::get(USDX_ASSET_ID), debt_before - eff_internal_net); + assert_eq!( + PsmDebt::::get(INTERNAL_ASSET_ID, USDX_ASSET_ID), + debt_before - eff_internal_net + ); // Redeemed event matches the actual movements: `internal_paid` reflects // the internal actually charged (burn + fee) with round-trip dust excluded, @@ -2081,6 +2552,7 @@ mod decimal_scaling { // is the nominal configured fee. System::assert_has_event( Event::::Redeemed { + internal_asset: INTERNAL_ASSET_ID, who: ALICE, asset_id: USDX_ASSET_ID, paid: eff_internal_net + fee, @@ -2100,7 +2572,12 @@ mod decimal_scaling { // Mint first so PSM has reserve. 10_000 USDX -> 10_000 internal debt. let usdx_raw = 10_000 * USDX_UNIT; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, usdx_raw)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + usdx_raw + )); // Redeem 100 internal + 1 unit of dust. USDX has 2 decimals, so internal -> USDX // divides by 10^4. 100_000_001 internal -> 10_000 USDX (= 100_000_000 internal @@ -2111,9 +2588,14 @@ mod decimal_scaling { let alice_usdx_before = get_asset_balance(USDX_ASSET_ID, ALICE); let alice_internal_before = get_asset_balance(INTERNAL_ASSET_ID, ALICE); let if_before = get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND); - let debt_before = PsmDebt::::get(USDX_ASSET_ID); + let debt_before = PsmDebt::::get(INTERNAL_ASSET_ID, USDX_ASSET_ID); - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, redeem)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + redeem + )); assert_eq!( get_asset_balance(USDX_ASSET_ID, ALICE), @@ -2128,7 +2610,10 @@ mod decimal_scaling { // Fee destination receives nothing (fee rate is zero). assert_eq!(get_asset_balance(INTERNAL_ASSET_ID, INSURANCE_FUND), if_before); // Debt reduced by what actually left the reserve in internal terms. - assert_eq!(PsmDebt::::get(USDX_ASSET_ID), debt_before - 100 * INTERNAL_UNIT); + assert_eq!( + PsmDebt::::get(INTERNAL_ASSET_ID, USDX_ASSET_ID), + debt_before - 100 * INTERNAL_UNIT + ); }); } @@ -2141,7 +2626,12 @@ mod decimal_scaling { // Seed the PSM reserve and ALICE's internal balance with a prior mint so // the redeem below has something to operate on. let usdx_raw = 10_000 * USDX_UNIT; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, usdx_raw)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + usdx_raw + )); set_redemption_fee(USDX_ASSET_ID, Permill::from_percent(100)); let alice_usdx_before = get_asset_balance(USDX_ASSET_ID, ALICE); @@ -2152,7 +2642,12 @@ mod decimal_scaling { // is burned, no external asset is transferred, the entire redeem // amount moves to the fee destination. let redeem = 100 * INTERNAL_UNIT; - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, redeem)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + redeem + )); // User receives zero USDX (100% fee). assert_eq!(get_asset_balance(USDX_ASSET_ID, ALICE), alice_usdx_before); @@ -2169,7 +2664,12 @@ mod decimal_scaling { // Seed the PSM reserve and ALICE's internal balance with a prior mint. let usdx_raw = 10_000 * USDX_UNIT; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, usdx_raw)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + usdx_raw + )); // Configure an extreme redemption fee so `internal_net > 0` but falls // below one USDX raw unit (factor 10^4). With MinSwapAmount = 10^8 @@ -2181,7 +2681,7 @@ mod decimal_scaling { let redeem = 100 * INTERNAL_UNIT; assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, redeem), + Psm::redeem(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID, USDX_ASSET_ID, redeem), Error::::AmountTooSmallAfterConversion ); }); @@ -2204,7 +2704,12 @@ mod decimal_scaling { )); assert_noop!( - Psm::mint(RuntimeOrigin::signed(BOB), USDX_ASSET_ID, 10_000 * USDX_UNIT), + Psm::mint( + RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + 10_000 * USDX_UNIT + ), Error::::DecimalsMismatch ); }); @@ -2217,7 +2722,12 @@ mod decimal_scaling { set_zero_fees(USDX_ASSET_ID); // Mint first, then change decimals. - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, 10_000 * USDX_UNIT)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + 10_000 * USDX_UNIT + )); assert_ok!(Assets::set_metadata( RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, @@ -2227,7 +2737,12 @@ mod decimal_scaling { )); assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, 100 * INTERNAL_UNIT), + Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + 100 * INTERNAL_UNIT + ), Error::::DecimalsMismatch ); }); @@ -2247,7 +2762,12 @@ mod decimal_scaling { )); assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * INTERNAL_UNIT), + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 1000 * INTERNAL_UNIT + ), Error::::DecimalsMismatch ); }); @@ -2260,6 +2780,7 @@ mod decimal_scaling { // drift the internal asset's decimals. assert_ok!(Psm::mint( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, USDC_ASSET_ID, 1000 * INTERNAL_UNIT )); @@ -2272,60 +2793,48 @@ mod decimal_scaling { )); assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 100 * INTERNAL_UNIT), + Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 100 * INTERNAL_UNIT + ), Error::::DecimalsMismatch ); }); } #[test] - fn mint_fails_when_asset_decimals_snapshot_missing() { - new_test_ext().execute_with(|| { - // USDC is approved in genesis but we clear its decimals snapshot to - // simulate a partially-migrated state. - crate::ExternalDecimals::::remove(USDC_ASSET_ID); - - assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * INTERNAL_UNIT), - Error::::UnsupportedAsset - ); - }); - } - - #[test] - fn redeem_fails_when_asset_decimals_snapshot_missing() { + fn mint_fails_when_psm_not_installed() { new_test_ext().execute_with(|| { - fund_internal(ALICE, 1000 * INTERNAL_UNIT); - crate::ExternalDecimals::::remove(USDC_ASSET_ID); + crate::Psm::::remove(INTERNAL_ASSET_ID); assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 100 * INTERNAL_UNIT), - Error::::UnsupportedAsset - ); - }); - } - - #[test] - fn mint_fails_when_internal_decimals_snapshot_missing() { - new_test_ext().execute_with(|| { - crate::InternalDecimals::::kill(); - - assert_noop!( - Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * INTERNAL_UNIT), - Error::::Unexpected + Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 1000 * INTERNAL_UNIT + ), + Error::::PsmNotFound ); }); } #[test] - fn redeem_fails_when_internal_decimals_snapshot_missing() { + fn redeem_fails_when_psm_not_installed() { new_test_ext().execute_with(|| { fund_internal(ALICE, 1000 * INTERNAL_UNIT); - crate::InternalDecimals::::kill(); + crate::Psm::::remove(INTERNAL_ASSET_ID); assert_noop!( - Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 100 * INTERNAL_UNIT), - Error::::Unexpected + Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + 100 * INTERNAL_UNIT + ), + Error::::PsmNotFound ); }); } @@ -2336,10 +2845,16 @@ mod decimal_scaling { fn asset_decimals_snapshot_recorded_on_add_and_cleaned_on_remove() { new_test_ext().execute_with(|| { register_external_asset_with_weight(USDX_ASSET_ID, Permill::from_percent(100)); - assert_eq!(ExternalDecimals::::get(USDX_ASSET_ID), Some(2)); + let stored = ExternalAssets::::get(INTERNAL_ASSET_ID, USDX_ASSET_ID) + .expect("external present after add"); + assert_eq!(stored.decimals, 2); - assert_ok!(Psm::remove_external_asset(RuntimeOrigin::root(), USDX_ASSET_ID)); - assert_eq!(ExternalDecimals::::get(USDX_ASSET_ID), None); + assert_ok!(Psm::remove_external_asset( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDX_ASSET_ID + )); + assert_eq!(ExternalAssets::::get(INTERNAL_ASSET_ID, USDX_ASSET_ID), None); }); } @@ -2363,12 +2878,25 @@ mod decimal_scaling { set_zero_fees(DAI_MOCK_ASSET_ID); // Mint 500 internal-equivalent via USDX, 1500 internal-equivalent via DAI. - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDX_ASSET_ID, 500 * USDX_UNIT)); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), DAI_MOCK_ASSET_ID, 1500 * DAI_UNIT)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDX_ASSET_ID, + 500 * USDX_UNIT + )); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + DAI_MOCK_ASSET_ID, + 1500 * DAI_UNIT + )); - assert_eq!(PsmDebt::::get(USDX_ASSET_ID), 500 * INTERNAL_UNIT); - assert_eq!(PsmDebt::::get(DAI_MOCK_ASSET_ID), 1500 * INTERNAL_UNIT); - assert_eq!(Psm::total_psm_debt(), 2000 * INTERNAL_UNIT); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, USDX_ASSET_ID), 500 * INTERNAL_UNIT); + assert_eq!( + PsmDebt::::get(INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID), + 1500 * INTERNAL_UNIT + ); + assert_eq!(Psm::total_psm_debt(&INTERNAL_ASSET_ID), 2000 * INTERNAL_UNIT); // do_try_state asserts invariants; invoke manually. assert_ok!(Psm::do_try_state()); @@ -2399,18 +2927,28 @@ mod decimal_scaling { for &(asset_id, is_mint, amount) in steps { if is_mint { - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), asset_id, amount)); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + asset_id, + amount + )); } else { - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), asset_id, amount)); + assert_ok!(Psm::redeem( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + asset_id, + amount + )); } // try_state must hold after every step. assert_ok!(Psm::do_try_state()); } // After draining both, per-asset debt and aggregate are zero. - assert_eq!(PsmDebt::::get(USDX_ASSET_ID), 0); - assert_eq!(PsmDebt::::get(DAI_MOCK_ASSET_ID), 0); - assert_eq!(Psm::total_psm_debt(), 0); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, USDX_ASSET_ID), 0); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID), 0); + assert_eq!(Psm::total_psm_debt(&INTERNAL_ASSET_ID), 0); // Reserves are also empty (zero fees, so no dust was charged). assert_eq!(get_asset_balance(USDX_ASSET_ID, psm_account()), 0); @@ -2425,8 +2963,16 @@ mod decimal_scaling { set_zero_fees(DAI_MOCK_ASSET_ID); // Mint so the PSM has tracked debt + matching DAI reserve. - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), DAI_MOCK_ASSET_ID, 1000 * DAI_UNIT)); - assert_eq!(PsmDebt::::get(DAI_MOCK_ASSET_ID), 1000 * INTERNAL_UNIT); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + DAI_MOCK_ASSET_ID, + 1000 * DAI_UNIT + )); + assert_eq!( + PsmDebt::::get(INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID), + 1000 * INTERNAL_UNIT + ); // Donate extra DAI straight to the PSM account. Reserve now exceeds // internal_to_external(debt). try_state check 2 uses the external-side @@ -2443,12 +2989,410 @@ mod decimal_scaling { // continues to hold. assert_ok!(Psm::redeem( RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID, 1000 * INTERNAL_UNIT )); - assert_eq!(PsmDebt::::get(DAI_MOCK_ASSET_ID), 0); + assert_eq!(PsmDebt::::get(INTERNAL_ASSET_ID, DAI_MOCK_ASSET_ID), 0); assert_eq!(get_asset_balance(DAI_MOCK_ASSET_ID, psm), 7 * DAI_UNIT); assert_ok!(Psm::do_try_state()); }); } } + +/// Lifecycle (`create_psm`/`remove_psm`) and per-instance admin reassignment +/// (`set_full_admin`/`set_emergency_admin`). +mod admin { + use super::*; + + /// A fresh internal asset id with no PSM installed by the mock. + const NEW_INTERNAL: u32 = 50; + + fn root_origin() -> OriginCaller { + frame_system::RawOrigin::::Root.into() + } + + fn signed_origin(who: u64) -> OriginCaller { + frame_system::RawOrigin::::Signed(who).into() + } + + #[test] + fn create_psm_works() { + new_test_ext().execute_with(|| { + assert_ok!(Assets::create(RuntimeOrigin::signed(ALICE), NEW_INTERNAL, ALICE, 1)); + let reserved_before = Balances::reserved_balance(&ALICE); + + assert_ok!(Psm::create_psm( + RuntimeOrigin::signed(ALICE), + NEW_INTERNAL, + INSURANCE_FUND, + DEFAULT_MAX_DEBT, + )); + + // Hot record. + let info = crate::Psm::::get(NEW_INTERNAL).expect("PSM created"); + assert_eq!(info.fee_destination, INSURANCE_FUND); + assert_eq!(info.max_debt, DEFAULT_MAX_DEBT); + assert_eq!(info.external_count, 0); + + // Admin record: the signer is both admins and the depositor; the deposit is + // reserved from the signer. + let admin = crate::PsmAdmin::::get(NEW_INTERNAL).expect("admin record"); + assert_eq!(admin.full_admin, signed_origin(ALICE)); + assert_eq!(admin.emergency_admin, signed_origin(ALICE)); + assert_eq!(admin.depositor, ALICE); + assert!(admin.deposit > 0); + assert_eq!(Balances::reserved_balance(&ALICE), reserved_before + admin.deposit); + + System::assert_has_event( + Event::::PsmCreated { + internal_asset: NEW_INTERNAL, + full_admin: signed_origin(ALICE), + emergency_admin: signed_origin(ALICE), + fee_destination: INSURANCE_FUND, + max_debt: DEFAULT_MAX_DEBT, + } + .into(), + ); + + // The signer is now the full admin and can manage the instance. + assert_ok!(Psm::set_max_debt(RuntimeOrigin::signed(ALICE), NEW_INTERNAL, 1)); + }); + } + + #[test] + fn create_psm_fails_if_already_exists() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::create_psm( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + INSURANCE_FUND, + DEFAULT_MAX_DEBT, + ), + Error::::PsmAlreadyExists + ); + }); + } + + #[test] + fn create_psm_fails_if_asset_missing() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::create_psm( + RuntimeOrigin::signed(ALICE), + 4242u32, + INSURANCE_FUND, + DEFAULT_MAX_DEBT, + ), + Error::::AssetDoesNotExist + ); + }); + } + + #[test] + fn create_psm_fails_if_caller_not_asset_owner() { + new_test_ext().execute_with(|| { + // The asset is owned by ALICE; BOB does not control it and must not be able to + // wrap it in a PSM — otherwise anyone could create a PSM over an asset they don't + // own and mint it against worthless collateral. + assert_ok!(Assets::create(RuntimeOrigin::signed(ALICE), NEW_INTERNAL, ALICE, 1)); + assert_noop!( + Psm::create_psm( + RuntimeOrigin::signed(BOB), + NEW_INTERNAL, + INSURANCE_FUND, + DEFAULT_MAX_DEBT, + ), + Error::::NotAssetOwner + ); + }); + } + + #[test] + fn create_psm_fails_if_deposit_cannot_be_reserved() { + new_test_ext().execute_with(|| { + // An account that owns the internal asset but has no native balance to cover the + // creation deposit. The deposit gate must reject it. + const POOR: u64 = 7; + assert_ok!(Assets::force_create(RuntimeOrigin::root(), NEW_INTERNAL, POOR, true, 1)); + assert_eq!(Balances::free_balance(POOR), 0); + + assert_noop!( + Psm::create_psm( + RuntimeOrigin::signed(POOR), + NEW_INTERNAL, + INSURANCE_FUND, + DEFAULT_MAX_DEBT, + ), + pallet_balances::Error::::InsufficientBalance + ); + // Nothing was created. + assert!(!crate::Psm::::contains_key(NEW_INTERNAL)); + }); + } + + #[test] + fn remove_psm_works_and_refunds_creator() { + new_test_ext().execute_with(|| { + assert_ok!(Assets::create(RuntimeOrigin::signed(ALICE), NEW_INTERNAL, ALICE, 1)); + let reserved_before = Balances::reserved_balance(&ALICE); + assert_ok!(Psm::create_psm( + RuntimeOrigin::signed(ALICE), + NEW_INTERNAL, + INSURANCE_FUND, + DEFAULT_MAX_DEBT, + )); + assert!(Balances::reserved_balance(&ALICE) > reserved_before); + + assert_ok!(Psm::remove_psm(RuntimeOrigin::signed(ALICE), NEW_INTERNAL)); + + assert!(!crate::Psm::::contains_key(NEW_INTERNAL)); + assert!(!crate::PsmAdmin::::contains_key(NEW_INTERNAL)); + // Deposit returned to the creator. + assert_eq!(Balances::reserved_balance(&ALICE), reserved_before); + System::assert_has_event( + Event::::PsmRemoved { internal_asset: NEW_INTERNAL }.into(), + ); + }); + } + + #[test] + fn create_psm_then_remove_releases_provider_refs() { + new_test_ext().execute_with(|| { + assert_ok!(Assets::create(RuntimeOrigin::signed(ALICE), NEW_INTERNAL, ALICE, 1)); + let psm = crate::Pallet::::psm_account(&NEW_INTERNAL); + let psm_before = frame_system::Account::::get(&psm).providers; + let fee_before = frame_system::Account::::get(&INSURANCE_FUND).providers; + + assert_ok!(Psm::create_psm( + RuntimeOrigin::signed(ALICE), + NEW_INTERNAL, + INSURANCE_FUND, + DEFAULT_MAX_DEBT, + )); + // create_psm acquired one provider reference on each of the reserve account and the + // fee destination. + assert_eq!(frame_system::Account::::get(&psm).providers, psm_before + 1); + assert_eq!( + frame_system::Account::::get(&INSURANCE_FUND).providers, + fee_before + 1 + ); + + assert_ok!(Psm::remove_psm(RuntimeOrigin::signed(ALICE), NEW_INTERNAL)); + // remove_psm released both — net zero, no provider leak across the lifecycle. + assert_eq!(frame_system::Account::::get(&psm).providers, psm_before); + assert_eq!(frame_system::Account::::get(&INSURANCE_FUND).providers, fee_before); + }); + } + + #[test] + fn remove_psm_refunds_original_depositor_after_reassign() { + new_test_ext().execute_with(|| { + assert_ok!(Assets::create(RuntimeOrigin::signed(ALICE), NEW_INTERNAL, ALICE, 1)); + let reserved_before = Balances::reserved_balance(&ALICE); + assert_ok!(Psm::create_psm( + RuntimeOrigin::signed(ALICE), + NEW_INTERNAL, + INSURANCE_FUND, + DEFAULT_MAX_DEBT, + )); + + // Hand off full control to Root, then let Root remove the PSM. + assert_ok!(Psm::set_full_admin( + RuntimeOrigin::signed(ALICE), + NEW_INTERNAL, + Box::new(root_origin()), + )); + assert_ok!(Psm::remove_psm(RuntimeOrigin::root(), NEW_INTERNAL)); + + // The deposit still returns to ALICE, the original depositor. + assert_eq!(Balances::reserved_balance(&ALICE), reserved_before); + }); + } + + #[test] + fn remove_psm_fails_with_approved_externals() { + new_test_ext().execute_with(|| { + // The PSM pre-installed by the test harness has USDC & USDT approved. + assert_noop!( + Psm::remove_psm(RuntimeOrigin::root(), INTERNAL_ASSET_ID), + Error::::PsmHasApprovedExternals + ); + }); + } + + #[test] + fn remove_psm_fails_with_debt() { + new_test_ext().execute_with(|| { + assert_ok!(Assets::create(RuntimeOrigin::signed(ALICE), NEW_INTERNAL, ALICE, 1)); + assert_ok!(Psm::create_psm( + RuntimeOrigin::signed(ALICE), + NEW_INTERNAL, + INSURANCE_FUND, + DEFAULT_MAX_DEBT, + )); + // Inject debt while there are no approved externals so the debt check is reached. + PsmDebt::::insert(NEW_INTERNAL, USDC_ASSET_ID, 1u128); + assert_noop!( + Psm::remove_psm(RuntimeOrigin::signed(ALICE), NEW_INTERNAL), + Error::::PsmHasDebt + ); + }); + } + + #[test] + fn remove_psm_unauthorized() { + new_test_ext().execute_with(|| { + // Stranger: the pre-installed test PSM's full_admin is Root. + assert_noop!( + Psm::remove_psm(RuntimeOrigin::signed(ALICE), INTERNAL_ASSET_ID), + DispatchError::BadOrigin + ); + // Emergency admin lacks the Full level. + assert_noop!( + Psm::remove_psm(RuntimeOrigin::signed(EMERGENCY_ACCOUNT), INTERNAL_ASSET_ID), + Error::::InsufficientPrivilege + ); + }); + } + + #[test] + fn set_full_admin_works() { + new_test_ext().execute_with(|| { + // The pre-installed test PSM's full_admin is Root; hand off to ALICE. + assert_ok!(Psm::set_full_admin( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + Box::new(signed_origin(ALICE)), + )); + + assert_eq!( + crate::PsmAdmin::::get(INTERNAL_ASSET_ID).unwrap().full_admin, + signed_origin(ALICE) + ); + System::assert_has_event( + Event::::FullAdminChanged { + internal_asset: INTERNAL_ASSET_ID, + old_admin: root_origin(), + new_admin: signed_origin(ALICE), + } + .into(), + ); + + // The new full admin can manage; the old one no longer can. + assert_ok!(Psm::set_minting_fee( + RuntimeOrigin::signed(ALICE), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + Permill::from_percent(2), + )); + assert_noop!( + Psm::set_minting_fee( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + Permill::from_percent(3), + ), + DispatchError::BadOrigin + ); + }); + } + + #[test] + fn set_full_admin_requires_full_admin() { + new_test_ext().execute_with(|| { + // Emergency admin cannot reassign. + assert_noop!( + Psm::set_full_admin( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, + Box::new(signed_origin(BOB)), + ), + Error::::InsufficientPrivilege + ); + // Stranger cannot reassign. + assert_noop!( + Psm::set_full_admin( + RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, + Box::new(signed_origin(BOB)), + ), + DispatchError::BadOrigin + ); + assert_eq!( + crate::PsmAdmin::::get(INTERNAL_ASSET_ID).unwrap().full_admin, + root_origin() + ); + }); + } + + #[test] + fn set_emergency_admin_works() { + new_test_ext().execute_with(|| { + assert_ok!(Psm::set_emergency_admin( + RuntimeOrigin::root(), + INTERNAL_ASSET_ID, + Box::new(signed_origin(BOB)), + )); + + assert_eq!( + crate::PsmAdmin::::get(INTERNAL_ASSET_ID).unwrap().emergency_admin, + signed_origin(BOB) + ); + System::assert_has_event( + Event::::EmergencyAdminChanged { + internal_asset: INTERNAL_ASSET_ID, + old_admin: signed_origin(EMERGENCY_ACCOUNT), + new_admin: signed_origin(BOB), + } + .into(), + ); + + // The new emergency admin can act; the old one no longer can. + assert_ok!(Psm::set_asset_status( + RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + CircuitBreakerLevel::MintingDisabled, + )); + assert_noop!( + Psm::set_asset_status( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, + USDC_ASSET_ID, + CircuitBreakerLevel::AllEnabled, + ), + DispatchError::BadOrigin + ); + }); + } + + #[test] + fn set_emergency_admin_requires_full_admin() { + new_test_ext().execute_with(|| { + // The emergency admin cannot reassign itself. + assert_noop!( + Psm::set_emergency_admin( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + INTERNAL_ASSET_ID, + Box::new(signed_origin(BOB)), + ), + Error::::InsufficientPrivilege + ); + // Stranger cannot reassign. + assert_noop!( + Psm::set_emergency_admin( + RuntimeOrigin::signed(BOB), + INTERNAL_ASSET_ID, + Box::new(signed_origin(BOB)), + ), + DispatchError::BadOrigin + ); + assert_eq!( + crate::PsmAdmin::::get(INTERNAL_ASSET_ID).unwrap().emergency_admin, + signed_origin(EMERGENCY_ACCOUNT) + ); + }); + } +} diff --git a/substrate/frame/psm/src/weights.rs b/substrate/frame/psm/src/weights.rs index 54b11c97c316f..73ab582af843e 100644 --- a/substrate/frame/psm/src/weights.rs +++ b/substrate/frame/psm/src/weights.rs @@ -63,11 +63,15 @@ pub trait WeightInfo { fn redeem() -> Weight; fn set_minting_fee() -> Weight; fn set_redemption_fee() -> Weight; - fn set_max_psm_debt() -> Weight; + fn set_max_debt() -> Weight; fn set_asset_status() -> Weight; fn set_asset_ceiling_weight() -> Weight; fn add_external_asset() -> Weight; fn remove_external_asset() -> Weight; + fn create_psm() -> Weight; + fn remove_psm() -> Weight; + fn set_full_admin() -> Weight; + fn set_emergency_admin() -> Weight; } /// Weights for `pallet_psm` using the Substrate node and recommended hardware. @@ -146,7 +150,7 @@ impl WeightInfo for SubstrateWeight { } /// Storage: `Psm::MaxPsmDebtOfTotal` (r:1 w:1) /// Proof: `Psm::MaxPsmDebtOfTotal` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - fn set_max_psm_debt() -> Weight { + fn set_max_debt() -> Weight { // Proof Size summary in bytes: // Measured: `306` // Estimated: `1489` @@ -215,6 +219,27 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(6_u64)) } + // Placeholder until benchmarks run; estimated like `add_external_asset`. + fn create_psm() -> Weight { + Weight::from_parts(30_000_000, 3501) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + fn remove_psm() -> Weight { + Weight::from_parts(25_000_000, 3501) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + fn set_full_admin() -> Weight { + Weight::from_parts(20_000_000, 3501) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + fn set_emergency_admin() -> Weight { + Weight::from_parts(20_000_000, 3501) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } } // For backwards compatibility and tests. @@ -292,7 +317,7 @@ impl WeightInfo for () { } /// Storage: `Psm::MaxPsmDebtOfTotal` (r:1 w:1) /// Proof: `Psm::MaxPsmDebtOfTotal` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) - fn set_max_psm_debt() -> Weight { + fn set_max_debt() -> Weight { // Proof Size summary in bytes: // Measured: `306` // Estimated: `1489` @@ -361,4 +386,25 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(6_u64)) } + // Placeholder until benchmarks run; estimated like `add_external_asset`. + fn create_psm() -> Weight { + Weight::from_parts(30_000_000, 3501) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + fn remove_psm() -> Weight { + Weight::from_parts(25_000_000, 3501) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + fn set_full_admin() -> Weight { + Weight::from_parts(20_000_000, 3501) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + fn set_emergency_admin() -> Weight { + Weight::from_parts(20_000_000, 3501) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } diff --git a/substrate/frame/support/src/traits/tokens/fungibles/union_of.rs b/substrate/frame/support/src/traits/tokens/fungibles/union_of.rs index b962511ee6a3c..18a97c110dee4 100644 --- a/substrate/frame/support/src/traits/tokens/fungibles/union_of.rs +++ b/substrate/frame/support/src/traits/tokens/fungibles/union_of.rs @@ -169,6 +169,41 @@ impl< } } +impl< + Left: fungibles::Inspect + fungibles::roles::Inspect, + Right: fungibles::Inspect + + fungibles::roles::Inspect, + Criterion: Convert>, + AssetKind: AssetId, + AccountId, + > fungibles::roles::Inspect for UnionOf +{ + fn owner(asset: Self::AssetId) -> Option { + match Criterion::convert(asset) { + Left(a) => >::owner(a), + Right(a) => >::owner(a), + } + } + fn issuer(asset: Self::AssetId) -> Option { + match Criterion::convert(asset) { + Left(a) => >::issuer(a), + Right(a) => >::issuer(a), + } + } + fn admin(asset: Self::AssetId) -> Option { + match Criterion::convert(asset) { + Left(a) => >::admin(a), + Right(a) => >::admin(a), + } + } + fn freezer(asset: Self::AssetId) -> Option { + match Criterion::convert(asset) { + Left(a) => >::freezer(a), + Right(a) => >::freezer(a), + } + } +} + impl< Left: fungibles::Inspect + fungibles::metadata::Inspect diff --git a/substrate/frame/support/src/traits/tokens/stable.rs b/substrate/frame/support/src/traits/tokens/stable.rs index 08835a7f23cb0..490bad598ef61 100644 --- a/substrate/frame/support/src/traits/tokens/stable.rs +++ b/substrate/frame/support/src/traits/tokens/stable.rs @@ -17,22 +17,27 @@ //! Traits for stablecoin inter-pallet communication. -/// Trait exposing the PSM pallet's reserved capacity to other pallets. +/// Trait exposing PSM-reserved issuance capacity to other pallets, scoped to a specific +/// internal asset. /// -/// Implemented by the PSM pallet, used by the Vaults pallet to account for -/// PSM-reserved debt ceiling when calculating available vault capacity. +/// Implemented by the PSM pallet. Consumers (e.g. the Vaults pallet) query the issuance +/// reserved by the PSM for a given stablecoin so they can size their own available +/// capacity accordingly. pub trait PsmInterface { + /// Asset identifier type used by the underlying fungibles backend. + type AssetId; /// The balance type. type Balance; - /// Get the amount of internal/minting stablecoin issuance capacity reserved by the PSM. - fn reserved_capacity() -> Self::Balance; + /// Issuance reserved by the PSM for `asset`. Zero if no PSM is registered for it. + fn reserved_capacity(asset: Self::AssetId) -> Self::Balance; } impl PsmInterface for () { + type AssetId = (); type Balance = u128; - fn reserved_capacity() -> Self::Balance { + fn reserved_capacity(_asset: Self::AssetId) -> Self::Balance { 0 } }