From 7374bad09f393f8a23875562a5bbd220f4a707ba Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Fri, 20 Mar 2026 11:39:54 +0000 Subject: [PATCH 01/32] feat: rebase onto master directly --- Cargo.lock | 17 + Cargo.toml | 2 + substrate/bin/node/runtime/src/lib.rs | 65 + substrate/frame/psm/Cargo.toml | 58 + substrate/frame/psm/README.md | 165 ++ substrate/frame/psm/src/benchmarking.rs | 220 +++ substrate/frame/psm/src/lib.rs | 876 ++++++++++ substrate/frame/psm/src/migrations/mod.rs | 22 + substrate/frame/psm/src/migrations/v1.rs | 274 +++ substrate/frame/psm/src/mock.rs | 272 +++ substrate/frame/psm/src/tests.rs | 1519 +++++++++++++++++ substrate/frame/psm/src/weights.rs | 362 ++++ substrate/frame/support/src/traits.rs | 5 +- substrate/frame/support/src/traits/tokens.rs | 1 + .../frame/support/src/traits/tokens/stable.rs | 50 + umbrella/Cargo.toml | 9 + umbrella/src/lib.rs | 4 + 17 files changed, 3919 insertions(+), 2 deletions(-) create mode 100644 substrate/frame/psm/Cargo.toml create mode 100644 substrate/frame/psm/README.md create mode 100644 substrate/frame/psm/src/benchmarking.rs create mode 100644 substrate/frame/psm/src/lib.rs create mode 100644 substrate/frame/psm/src/migrations/mod.rs create mode 100644 substrate/frame/psm/src/migrations/v1.rs create mode 100644 substrate/frame/psm/src/mock.rs create mode 100644 substrate/frame/psm/src/tests.rs create mode 100644 substrate/frame/psm/src/weights.rs create mode 100644 substrate/frame/support/src/traits/tokens/stable.rs diff --git a/Cargo.lock b/Cargo.lock index 32dd6526bf055..faf2c359b0181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13110,6 +13110,22 @@ dependencies = [ "scale-info", ] +[[package]] +name = "pallet-psm" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "pallet-assets", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "sp-io", + "sp-runtime", +] + [[package]] name = "pallet-ranked-collective" version = "28.0.0" @@ -16465,6 +16481,7 @@ dependencies = [ "pallet-people", "pallet-preimage", "pallet-proxy", + "pallet-psm", "pallet-ranked-collective", "pallet-recovery", "pallet-referenda", diff --git a/Cargo.toml b/Cargo.toml index b0373557d1c9f..ff6dd60ae16f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -413,6 +413,7 @@ members = [ "substrate/frame/people", "substrate/frame/preimage", "substrate/frame/proxy", + "substrate/frame/psm", "substrate/frame/ranked-collective", "substrate/frame/recovery", "substrate/frame/referenda", @@ -1031,6 +1032,7 @@ pallet-parameters = { path = "substrate/frame/parameters", default-features = fa pallet-people = { path = "substrate/frame/people", default-features = false } pallet-preimage = { path = "substrate/frame/preimage", default-features = false } pallet-proxy = { path = "substrate/frame/proxy", default-features = false } +pallet-psm = { path = "substrate/frame/psm", default-features = false } pallet-ranked-collective = { path = "substrate/frame/ranked-collective", default-features = false } pallet-recovery = { path = "substrate/frame/recovery", default-features = false } pallet-referenda = { path = "substrate/frame/referenda", default-features = false } diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index 94c1c2f959912..fc0a535c7dec9 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -2865,6 +2865,9 @@ mod runtime { #[runtime::pallet_index(85)] pub type Oracle = pallet_oracle::Pallet; + #[runtime::pallet_index(86)] + pub type Psm = pallet_psm::Pallet; + #[runtime::pallet_index(89)] pub type MetaTx = pallet_meta_tx::Pallet; @@ -3040,6 +3043,67 @@ impl pallet_oracle::Config for Runtime { type BenchmarkHelper = OracleBenchmarkingHelper; } +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 PsmStableAsset = ItemOf; + +/// Stub VaultsInterface that imposes no debt ceiling. +pub struct NoVaultsCeiling; +impl frame_support::traits::VaultsInterface for NoVaultsCeiling { + type Balance = Balance; + fn get_maximum_issuance() -> 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()) + } +} + +/// Configure the PSM (Peg Stability Module) pallet. +impl pallet_psm::Config for Runtime { + type Fungibles = Assets; + type AssetId = u32; + type VaultsInterface = NoVaultsCeiling; + type ManagerOrigin = EnsurePsmManager; + type WeightInfo = pallet_psm::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type StableAssetId = PsmStablecoinAssetId; + type StableAsset = PsmStableAsset; + type FeeHandler = ResolveTo; + type PalletId = PsmPalletId; + type MinSwapAmount = PsmMinSwapAmount; + type MaxExternalAssets = ConstU32<10>; +} + /// MMR helper types. mod mmr { use super::*; @@ -3184,6 +3248,7 @@ mod benches { [pallet_asset_conversion_ops, AssetConversionMigration] [pallet_verify_signature, VerifySignature] [pallet_meta_tx, MetaTx] + [pallet_psm, Psm] ); } diff --git a/substrate/frame/psm/Cargo.toml b/substrate/frame/psm/Cargo.toml new file mode 100644 index 0000000000000..484dea4002254 --- /dev/null +++ b/substrate/frame/psm/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "pallet-psm" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license = "Apache-2.0" +homepage.workspace = true +repository.workspace = true +description = "FRAME pallet for the Peg Stability Module." +readme = "README.md" +include = ["README.md", "src/**/*"] + +[lints] +workspace = true + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { features = ["derive"], workspace = true } +frame-benchmarking = { workspace = true, optional = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +log = { workspace = true } +scale-info = { features = ["derive"], 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"] +std = [ + "codec/std", + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "log/std", + "scale-info/std", + "sp-runtime/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-assets/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "pallet-assets/try-runtime", + "pallet-balances/try-runtime", + "sp-runtime/try-runtime", +] diff --git a/substrate/frame/psm/README.md b/substrate/frame/psm/README.md new file mode 100644 index 0000000000000..8e6f60a652894 --- /dev/null +++ b/substrate/frame/psm/README.md @@ -0,0 +1,165 @@ +# PSM Pallet + +A Peg Stability Module enabling 1:1 swaps between pUSD and pre-approved external stablecoins on Substrate-based blockchains. + +## Overview + +The PSM pallet allows users to swap external stablecoins (e.g., USDC, USDT) for pUSD 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`) +- **pUSD is minted/burned**: Users receive pUSD when depositing external stablecoins, and burn pUSD when redeeming +- **Circuit breaker provides emergency control**: Per-asset circuit breaker can disable minting or all swaps + +## Swap Lifecycle + +### 1. Mint (External -> pUSD) +```rust +mint(origin, asset_id, external_amount) +``` +- Deposits external stablecoin into the PSM account +- Mints pUSD to the user (minus minting fee) +- Fee is minted as pUSD to the Insurance Fund +- Enforces three-tier debt ceiling: system-wide, aggregate PSM, and per-asset +- Requires `external_amount >= MinSwapAmount` + +### 2. Redeem (pUSD -> External) +```rust +redeem(origin, asset_id, pusd_amount) +``` +- Burns pUSD from the user (minus redemption fee) +- Transfers external stablecoin from PSM account to user +- Fee is transferred as pUSD from user to Insurance Fund +- Limited by tracked PSM debt (not raw reserve balance) +- Requires `pusd_amount >= MinSwapAmount` + +## Debt Ceiling Architecture + +Before minting, the PSM checks three ceilings in order: + +1. **System-wide**: `total_issuance(pUSD) + amount <= MaximumIssuance` +2. **Aggregate PSM**: `total_psm_debt + amount <= MaxPsmDebtOfTotal * MaximumIssuance` +3. **Per-asset**: `asset_debt + amount <= normalized_asset_share_of_psm_ceiling` + +### PSM Reserved Capacity + +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. + +### Per-Asset Ceiling + +Per-asset ceilings use a weight-based system: + +``` +max_asset_debt = (AssetCeilingWeight[asset_id] / sum_of_all_weights) * max_psm_debt +``` + +Setting an asset's weight to 0% disables minting and redistributes its capacity to other assets. + +## Fee Structure + +Fees are calculated using `Permill::mul_ceil` (rounds up): + +- **Minting Fee**: `fee = MintingFee[asset_id].mul_ceil(external_amount)` -- deducted from pUSD output, minted to Insurance Fund +- **Redemption Fee**: `fee = RedemptionFee[asset_id].mul_ceil(pusd_amount)` -- transferred as pUSD from user to Insurance Fund + +With 0.5% fees on both sides, arbitrage opportunities exist when pUSD trades outside $0.995-$1.005. + +## Circuit Breaker + +Each approved asset 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 | + +The `set_asset_status` extrinsic can be called by both `GeneralAdmin` and `EmergencyAction` origins. + +## 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 (defaults to AllEnabled) | +| `remove_external_asset(asset_id)` | Full | Remove approved stablecoin (requires zero debt) | + +### Privilege Levels + +The `ManagerOrigin` returns a privilege level: +- **Full** (via GeneralAdmin): Can modify all parameters +- **Emergency** (via EmergencyAction): Can only modify circuit breaker status + +### 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)` + +## Configuration + +```rust +impl pallet_psm::Config for Runtime { + type Asset = Assets; // Fungibles impl for pUSD and external stablecoins + type AssetId = u32; // Asset identifier type + type VaultsInterface = Vaults; // Interface to query MaximumIssuance from Vaults + type ManagerOrigin = EnsurePsmManager; // Governance origin (returns privilege level) + type WeightInfo = weights::SubstrateWeight; + type StablecoinAssetId = StablecoinAssetId; // Constant: pUSD asset ID + type InsuranceFund = InsuranceFundAccount; // Account receiving fee revenue + type PalletId = PsmPalletId; // For deriving PSM account address + type MinSwapAmount = MinSwapAmount; // Minimum swap amount (prevents dust) +} +``` + +### Parameters (Set via Governance) + +| Parameter | Description | Suggested Value | +| -------------------- | ------------------------------------ | --------------------- | +| `MaxPsmDebtOfTotal` | PSM ceiling as % of MaximumIssuance | 10% | +| `MintingFee` | Fee for external -> pUSD (per asset) | 0.5% | +| `RedemptionFee` | Fee for pUSD -> external (per asset) | 0.5% | +| `AssetCeilingWeight` | Per-asset share of PSM ceiling | 50% each (USDC, USDT) | + +### Required Constants + +- `StablecoinAssetId`: The asset ID for pUSD +- `InsuranceFund`: Account that receives fee revenue (shared with pallet-vaults) +- `PalletId`: Unique identifier for deriving the PSM account +- `MinSwapAmount`: Minimum amount for any swap (default: 100 pUSD) + +## Events + +- `Minted { who, asset_id, external_amount, pusd_received, fee }`: User swapped external stablecoin for pUSD +- `Redeemed { who, asset_id, pusd_paid, external_received, fee }`: User swapped pUSD 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 + +## 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 pUSD 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 + +## Testing + +Run tests with: +```bash +SKIP_WASM_BUILD=1 cargo test -p pallet-psm +``` diff --git a/substrate/frame/psm/src/benchmarking.rs b/substrate/frame/psm/src/benchmarking.rs new file mode 100644 index 0000000000000..00fd83e6edffd --- /dev/null +++ b/substrate/frame/psm/src/benchmarking.rs @@ -0,0 +1,220 @@ +// 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. + +//! Benchmarking setup for pallet-psm + +use super::*; +use crate::Pallet as Psm; +use frame_benchmarking::v2::*; +use frame_support::{ + assert_ok, + traits::{ + fungibles::{ + Create as FungiblesCreate, Inspect as FungiblesInspect, Mutate as FungiblesMutate, + }, + Get, + }, +}; +use frame_system::RawOrigin; +use pallet::BalanceOf; +use sp_runtime::{Permill, Saturating}; + +/// Offset for benchmark asset IDs, chosen to avoid collision with typical +/// genesis asset IDs (e.g. stable asset ID = 1). +const ASSET_ID_OFFSET: u32 = 100; + +/// Set up `n` external assets ready for PSM benchmarks. +/// +/// Creates the target asset (`ASSET_ID_OFFSET`) and the stable asset in +/// `T::Fungibles`, registers `n` external assets (`ASSET_ID_OFFSET..+n`), 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 +where + T::Fungibles: FungiblesCreate, + T::AssetId: From, +{ + let admin: T::AccountId = whitelisted_caller(); + let _ = frame_system::Pallet::::inc_providers(&admin); + let target_id: T::AssetId = ASSET_ID_OFFSET.into(); + if !T::Fungibles::asset_exists(target_id) { + assert_ok!(T::Fungibles::create(target_id, admin.clone(), true, 1u32.into())); + } + let stable_id = T::StableAssetId::get(); + if !T::Fungibles::asset_exists(stable_id) { + assert_ok!(T::Fungibles::create(stable_id, admin, true, 1u32.into())); + } + + crate::MaxPsmDebtOfTotal::::put(Permill::from_percent(100)); + for i in 0..n { + let id: T::AssetId = (ASSET_ID_OFFSET + i).into(); + crate::ExternalAssets::::insert(id, CircuitBreakerLevel::AllEnabled); + crate::AssetCeilingWeight::::insert(id, Permill::from_percent(1)); + crate::PsmDebt::::insert(id, BalanceOf::::from(1u32)); + } + // Give the target a dominant weight so it can absorb the full mint amount. + crate::AssetCeilingWeight::::insert(target_id, Permill::from_percent(100)); + + target_id +} + +#[benchmarks(where T::Fungibles: FungiblesCreate, T::AssetId: From)] +mod benchmarks { + use super::*; + + /// Linear in `n`. The number of registered external assets, because + /// `total_psm_debt()` iterates `PsmDebt` and `max_asset_debt()` iterates + /// `AssetCeilingWeight`. + #[benchmark] + fn mint(n: Linear<1, { T::MaxExternalAssets::get() }>) -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + let asset_id = setup_assets::(n); + let mint_amount = T::MinSwapAmount::get().saturating_mul(10u32.into()); + + T::Fungibles::mint_into(asset_id, &caller, mint_amount.saturating_mul(2u32.into())) + .map_err(|_| BenchmarkError::Stop("Failed to fund caller"))?; + + let psm_account = Psm::::account_id(); + let reserve_before = T::Fungibles::balance(asset_id, &psm_account); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), asset_id, mint_amount); + + assert!(T::Fungibles::balance(asset_id, &psm_account) > reserve_before); + Ok(()) + } + + #[benchmark] + fn redeem() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + let asset_id = setup_assets::(1); + let setup_amount = T::MinSwapAmount::get().saturating_mul(10u32.into()); + let redeem_amount = T::MinSwapAmount::get(); + + T::Fungibles::mint_into(asset_id, &caller, setup_amount.saturating_mul(2u32.into())) + .map_err(|_| BenchmarkError::Stop("Failed to fund caller"))?; + Psm::::mint(RawOrigin::Signed(caller.clone()).into(), asset_id, setup_amount) + .map_err(|_| BenchmarkError::Stop("Failed to setup reserve via mint"))?; + + let psm_account = Psm::::account_id(); + let reserve_before = T::Fungibles::balance(asset_id, &psm_account); + + #[extrinsic_call] + _(RawOrigin::Signed(caller.clone()), asset_id, redeem_amount); + + assert!(T::Fungibles::balance(asset_id, &psm_account) < reserve_before); + Ok(()) + } + + #[benchmark] + fn set_minting_fee() -> Result<(), BenchmarkError> { + let asset_id = setup_assets::(1); + let new_fee = Permill::from_percent(2); + + #[extrinsic_call] + _(RawOrigin::Root, asset_id, new_fee); + + assert_eq!(crate::MintingFee::::get(asset_id), new_fee); + Ok(()) + } + + #[benchmark] + fn set_redemption_fee() -> Result<(), BenchmarkError> { + let asset_id = setup_assets::(1); + let new_fee = Permill::from_percent(2); + + #[extrinsic_call] + _(RawOrigin::Root, asset_id, new_fee); + + assert_eq!(crate::RedemptionFee::::get(asset_id), new_fee); + Ok(()) + } + + #[benchmark] + fn set_max_psm_debt() -> Result<(), BenchmarkError> { + let new_ratio = Permill::from_percent(20); + + #[extrinsic_call] + _(RawOrigin::Root, new_ratio); + + assert_eq!(crate::MaxPsmDebtOfTotal::::get(), new_ratio); + Ok(()) + } + + #[benchmark] + fn set_asset_status() -> Result<(), BenchmarkError> { + let asset_id = setup_assets::(1); + let new_status = CircuitBreakerLevel::MintingDisabled; + + #[extrinsic_call] + _(RawOrigin::Root, asset_id, new_status); + + assert_eq!(crate::ExternalAssets::::get(asset_id), Some(new_status)); + Ok(()) + } + + #[benchmark] + fn set_asset_ceiling_weight() -> Result<(), BenchmarkError> { + let asset_id = setup_assets::(1); + let new_weight = Permill::from_percent(50); + + #[extrinsic_call] + _(RawOrigin::Root, asset_id, new_weight); + + assert_eq!(crate::AssetCeilingWeight::::get(asset_id), new_weight); + Ok(()) + } + + /// Linear in `n`. The number of already-registered assets, because + /// the extrinsic calls `ExternalAssets::iter_keys().count()`. + #[benchmark] + fn add_external_asset( + n: Linear<0, { T::MaxExternalAssets::get() - 1 }>, + ) -> Result<(), BenchmarkError> { + let _ = crate::ExternalAssets::::clear(u32::MAX, None); + + for i in 0..n { + let id: T::AssetId = (ASSET_ID_OFFSET + i).into(); + crate::ExternalAssets::::insert(id, CircuitBreakerLevel::AllEnabled); + } + + let new_asset_id: T::AssetId = (ASSET_ID_OFFSET + n).into(); + + #[extrinsic_call] + _(RawOrigin::Root, new_asset_id); + + assert!(crate::ExternalAssets::::contains_key(new_asset_id)); + Ok(()) + } + + #[benchmark] + fn remove_external_asset() -> Result<(), BenchmarkError> { + let asset_id = setup_assets::(1); + crate::PsmDebt::::remove(asset_id); + + #[extrinsic_call] + _(RawOrigin::Root, asset_id); + + assert!(!crate::ExternalAssets::::contains_key(asset_id)); + Ok(()) + } + + impl_benchmark_test_suite!(Psm, crate::mock::new_test_ext(), crate::mock::Test); +} diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs new file mode 100644 index 0000000000000..36bb4728d53c6 --- /dev/null +++ b/substrate/frame/psm/src/lib.rs @@ -0,0 +1,876 @@ +// 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. + +//! # Peg Stability Module (PSM) Pallet +//! +//! A module enabling 1:1 swaps between pUSD and pre-approved external stablecoins. +//! +//! ## Pallet API +//! +//! See the [`pallet`] module for more information about the interfaces this pallet exposes, +//! including its configuration trait, dispatchables, storage items, events and errors. +//! +//! ## Overview +//! +//! The PSM strengthens the pUSD peg by providing arbitrage opportunities: +//! - When pUSD trades **above** $1: Users swap external stablecoins for pUSD and sell for profit +//! - When pUSD trades **below** $1: Users buy cheap pUSD and swap for external stablecoins +//! +//! This creates a price corridor bounded by the minting and redemption fees. +//! +//! ### Key Concepts +//! +//! * **Minting**: Deposit external stablecoin → receive pUSD (minus fee) +//! * **Redemption**: Burn pUSD → receive external stablecoin (minus fee) +//! * **Reserve**: External stablecoin balance held by the PSM account (derived, not stored) +//! * **PSM Debt**: Total pUSD 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. +//! +//! ### Fee Structure +//! +//! * **Minting Fee ([`MintingFee`])**: Deducted from pUSD output during minting +//! * **Redemption Fee ([`RedemptionFee`])**: Deducted from external stablecoin output during +//! redemption +//! +//! Fees are collected in pUSD and handled by [`Config::FeeHandler`]. +//! +//! ### Example +//! +//! ```ignore +//! // Mint pUSD by depositing USDC +//! Psm::mint(RuntimeOrigin::signed(user), USDC_ASSET_ID, 1000 * UNIT)?; +//! +//! // Redeem USDC by burning pUSD +//! Psm::redeem(RuntimeOrigin::signed(user), 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")] +mod benchmarking; +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +pub use frame_support::traits::PsmInterface; +pub(crate) use frame_support::traits::VaultsInterface; +pub use pallet::*; +pub use weights::WeightInfo; + +#[frame_support::pallet] +pub mod pallet { + use super::VaultsInterface; + + use alloc::collections::btree_map::BTreeMap; + use codec::DecodeWithMemTracking; + use frame_support::{ + pallet_prelude::*, + traits::{ + fungible::{ + Balanced as FungibleBalanced, Credit as FungibleCredit, Inspect as FungibleInspect, + Mutate as FungibleMutate, + }, + fungibles::{Inspect as FungiblesInspect, Mutate as FungiblesMutate}, + tokens::{imbalance::OnUnbalanced, Fortitude, Precision, Preservation}, + }, + DefaultNoBound, PalletId, + }; + use frame_system::pallet_prelude::*; + use sp_runtime::{ + traits::{AccountIdConversion, Saturating, Zero}, + Perbill, Permill, + }; + + use crate::WeightInfo; + + /// Circuit breaker levels for emergency control. + #[derive( + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + Clone, + Copy, + PartialEq, + Eq, + Debug, + Default, + )] + pub enum CircuitBreakerLevel { + /// Normal operation, all swaps enabled. + #[default] + AllEnabled, + /// Minting disabled, redemptions still allowed. + MintingDisabled, + /// All swaps disabled. + AllDisabled, + } + + /// Privilege level returned by ManagerOrigin. + /// + /// Enables tiered authorization where different origins have different + /// capabilities for managing PSM parameters. + #[derive( + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + Clone, + Copy, + PartialEq, + Eq, + Debug, + Default, + )] + pub enum PsmManagerLevel { + /// Full administrative access via GeneralAdmin origin. + /// Can modify all parameters including fees, ceilings, and asset management. + #[default] + Full, + /// Emergency access via EmergencyAction origin. + /// Can only modify circuit breaker status. + Emergency, + } + + pub(crate) type BalanceOf = <::Fungibles as FungiblesInspect< + ::AccountId, + >>::Balance; + + /// Suggested fee of 0.5% for minting and redemption. + pub(crate) struct DefaultFee; + impl Get for DefaultFee { + fn get() -> Permill { + Permill::from_parts(5_000) + } + } + + #[pallet::config] + pub trait Config: frame_system::Config { + /// Fungibles implementation for both pUSD and external stablecoins. + type Fungibles: FungiblesMutate; + + /// Asset identifier type. + type AssetId: Parameter + Member + Copy + MaybeSerializeDeserialize + MaxEncodedLen + Ord; + + /// Interface to query vaults for debt ceiling. + type VaultsInterface: VaultsInterface>; + + /// Origin allowed to update PSM parameters. + /// + /// Returns `PsmManagerLevel` to distinguish privilege levels: + /// - `Full` (via GeneralAdmin): Can modify all parameters + /// - `Emergency` (via EmergencyAction): Can only modify circuit breaker status + type ManagerOrigin: EnsureOrigin; + + /// A type representing the weights required by the dispatchables of this pallet. + type WeightInfo: WeightInfo; + + /// The stable asset ID, needed by benchmarks to create it via `T::Fungibles`. + #[cfg(feature = "runtime-benchmarks")] + type StableAssetId: Get; + + /// The pUSD asset as a single-asset `fungible` type. + /// + /// Typically `ItemOf`. + /// Must use the same `Balance` type as `Asset`. + type StableAsset: FungibleMutate> + + FungibleBalanced; + + /// Handler for pUSD fee credits collected during minting and redemption. + /// + /// Use `ResolveTo` for simple single-account deposit, + /// or implement custom `OnUnbalanced` logic for fee splitting. + type FeeHandler: OnUnbalanced>; + + /// PalletId for deriving the PSM account. + #[pallet::constant] + type PalletId: Get; + + /// Minimum swap amount. + #[pallet::constant] + type MinSwapAmount: Get>; + + /// Maximum number of approved external assets. + #[pallet::constant] + type MaxExternalAssets: Get; + } + + /// The in-code storage version. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + /// pUSD minted through PSM per external asset. + #[pallet::storage] + pub type PsmDebt = + StorageMap<_, Blake2_128Concat, T::AssetId, BalanceOf, ValueQuery>; + + /// Fee for external → pUSD swaps (minting) per asset. Suggested value is 0.5%. + #[pallet::storage] + pub(crate) type MintingFee = + StorageMap<_, Blake2_128Concat, T::AssetId, Permill, ValueQuery, DefaultFee>; + + /// Fee for pUSD → external swaps (redemption) per asset. Suggested value is 0.5%. + #[pallet::storage] + pub(crate) type RedemptionFee = + StorageMap<_, Blake2_128Concat, T::AssetId, Permill, ValueQuery, DefaultFee>; + + /// Max PSM debt as percentage of MaximumIssuance (global ceiling). + #[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. + #[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 = + StorageMap<_, Blake2_128Concat, T::AssetId, CircuitBreakerLevel, 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, + /// max_asset_debt_ratio). 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); + for (asset_id, (minting_fee, redemption_fee, max_asset_debt_ratio)) in + &self.asset_configs + { + ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); + MintingFee::::insert(asset_id, minting_fee); + RedemptionFee::::insert(asset_id, redemption_fee); + AssetCeilingWeight::::insert(asset_id, max_asset_debt_ratio); + } + Pallet::::ensure_psm_account_exists(); + } + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// User swapped external stablecoin for pUSD. + Minted { + who: T::AccountId, + asset_id: T::AssetId, + external_amount: BalanceOf, + pusd_received: BalanceOf, + fee: BalanceOf, + }, + /// User swapped pUSD for external stablecoin. + Redeemed { + who: T::AccountId, + asset_id: T::AssetId, + pusd_paid: BalanceOf, + external_received: BalanceOf, + fee: BalanceOf, + }, + /// Minting fee updated for an asset by governance. + MintingFeeUpdated { 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 }, + /// Per-asset debt ceiling ratio updated by governance. + AssetCeilingWeightUpdated { asset_id: T::AssetId, old_value: Permill, new_value: Permill }, + /// Per-asset circuit breaker status updated. + AssetStatusUpdated { asset_id: T::AssetId, status: CircuitBreakerLevel }, + /// An external asset was added to the approved list. + ExternalAssetAdded { asset_id: T::AssetId }, + /// An external asset was removed from the approved list. + ExternalAssetRemoved { asset_id: T::AssetId }, + } + + #[pallet::error] + pub enum Error { + /// PSM doesn't have enough external stablecoin for redemption. + InsufficientReserve, + /// Swap would exceed PSM debt ceiling. + ExceedsMaxPsmDebt, + /// Swap amount below minimum threshold. + BelowMinimumSwap, + /// Minting operations are disabled (circuit breaker level >= 1). + MintingStopped, + /// All swap operations are disabled (circuit breaker level = 2). + AllSwapsStopped, + /// Asset is not an approved external stablecoin. + UnsupportedAsset, + /// Mint would exceed system-wide maximum pUSD issuance. + ExceedsMaxIssuance, + /// Asset is already in the approved list. + AssetAlreadyApproved, + /// 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. + InsufficientPrivilege, + /// Maximum number of approved external assets reached. + TooManyAssets, + } + + #[pallet::call] + impl Pallet { + /// Swap external stablecoin for pUSD. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed` by the user performing the swap. + /// + /// ## Details + /// + /// Transfers `external_amount` of the specified external stablecoin from the caller + /// to the PSM account, then mints pUSD to the caller minus the minting fee. + /// The fee is calculated using ceiling rounding (`mul_ceil`), ensuring the + /// protocol never undercharges. The fee is passed to [`Config::FeeHandler`]. + /// + /// ## Parameters + /// + /// - `asset_id`: The external stablecoin to deposit (must be in [`ExternalAssets`]) + /// - `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 pUSD issuance cap + /// - [`Error::ExceedsMaxPsmDebt`]: If minting would exceed PSM debt ceiling (aggregate or + /// per-asset) + /// + /// ## Events + /// + /// - [`Event::Minted`]: Emitted on successful mint + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::mint(T::MaxExternalAssets::get()))] + pub fn mint( + origin: OriginFor, + asset_id: T::AssetId, + external_amount: BalanceOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + // Check asset is approved and minting is enabled + let asset_status = + ExternalAssets::::get(asset_id).ok_or(Error::::UnsupportedAsset)?; + ensure!(asset_status == CircuitBreakerLevel::AllEnabled, Error::::MintingStopped); + + ensure!(external_amount >= T::MinSwapAmount::get(), Error::::BelowMinimumSwap); + + // Check system-wide issuance cap + let current_total_issuance = T::StableAsset::total_issuance(); + let max_issuance = T::VaultsInterface::get_maximum_issuance(); + ensure!( + current_total_issuance.saturating_add(external_amount) <= max_issuance, + Error::::ExceedsMaxIssuance + ); + + // Check aggregate PSM ceiling across all assets + let current_total_psm_debt = Self::total_psm_debt(); + let max_psm = Self::max_psm_debt(); + ensure!( + current_total_psm_debt.saturating_add(external_amount) <= max_psm, + 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); + let new_debt = current_debt.saturating_add(external_amount); + ensure!(new_debt <= max_debt, Error::::ExceedsMaxPsmDebt); + + let psm_account = Self::account_id(); + + T::Fungibles::transfer( + asset_id, + &who, + &psm_account, + external_amount, + Preservation::Expendable, + )?; + + let fee = MintingFee::::get(asset_id).mul_ceil(external_amount); + let pusd_to_user = external_amount.saturating_sub(fee); + + T::StableAsset::mint_into(&who, pusd_to_user)?; + + if !fee.is_zero() { + let fee_credit = T::StableAsset::issue(fee); + T::FeeHandler::on_unbalanced(fee_credit); + } + + PsmDebt::::insert(asset_id, new_debt); + + Self::deposit_event(Event::Minted { + who, + asset_id, + external_amount, + pusd_received: pusd_to_user, + fee, + }); + + Ok(()) + } + + /// Swap pUSD for external stablecoin. + /// + /// ## Dispatch Origin + /// + /// Must be `Signed` by the user performing the swap. + /// + /// ## Details + /// + /// Burns `pusd_amount` pUSD from the caller minus fee (passed to [`Config::FeeHandler`]), + /// 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. + /// + /// ## Parameters + /// + /// - `asset_id`: The external stablecoin to receive (must be in [`ExternalAssets`]) + /// - `pusd_amount`: Amount of pUSD 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 `pusd_amount` is below [`Config::MinSwapAmount`] + /// - [`Error::InsufficientReserve`]: If PSM has insufficient external stablecoin + /// + /// ## Events + /// + /// - [`Event::Redeemed`]: Emitted on successful redemption + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::redeem())] + pub fn redeem( + origin: OriginFor, + asset_id: T::AssetId, + pusd_amount: BalanceOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + // Check asset is approved and redemption is enabled + let asset_status = + ExternalAssets::::get(asset_id).ok_or(Error::::UnsupportedAsset)?; + ensure!(asset_status != CircuitBreakerLevel::AllDisabled, Error::::AllSwapsStopped); + + ensure!(pusd_amount >= T::MinSwapAmount::get(), Error::::BelowMinimumSwap); + + let fee = RedemptionFee::::get(asset_id).mul_ceil(pusd_amount); + let external_to_user = pusd_amount.saturating_sub(fee); + + // 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); + ensure!(current_debt >= external_to_user, Error::::InsufficientReserve); + + // Safety check: reserve should always match debt under normal operation + let reserve = Self::get_reserve(asset_id); + ensure!(reserve >= external_to_user, Error::::InsufficientReserve); + + let pusd_to_burn = external_to_user; + + T::StableAsset::burn_from( + &who, + pusd_to_burn, + Preservation::Expendable, + Precision::Exact, + Fortitude::Polite, + )?; + + if !fee.is_zero() { + let fee_credit = T::StableAsset::withdraw( + &who, + fee, + Precision::Exact, + Preservation::Expendable, + Fortitude::Polite, + )?; + T::FeeHandler::on_unbalanced(fee_credit); + } + + let psm_account = Self::account_id(); + T::Fungibles::transfer( + asset_id, + &psm_account, + &who, + external_to_user, + Preservation::Expendable, + )?; + + PsmDebt::::mutate(asset_id, |debt| { + *debt = debt.saturating_sub(external_to_user); + }); + + Self::deposit_event(Event::Redeemed { + who, + asset_id, + pusd_paid: pusd_amount, + external_received: external_to_user, + fee, + }); + + Ok(()) + } + + /// Set the minting fee for a specific asset (external → pUSD). + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`]. + /// + /// ## Parameters + /// + /// - `asset_id`: The external stablecoin to configure + /// - `fee`: The new minting fee as a Permill + /// + /// ## Events + /// + /// - [`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, + asset_id: T::AssetId, + fee: Permill, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + let old_value = MintingFee::::get(asset_id); + MintingFee::::insert(asset_id, fee); + Self::deposit_event(Event::MintingFeeUpdated { asset_id, old_value, new_value: fee }); + Ok(()) + } + + /// Set the redemption fee for a specific asset (pUSD → external). + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`]. + /// + /// ## Parameters + /// + /// - `asset_id`: The external stablecoin to configure + /// - `fee`: The new redemption fee as a Permill + /// + /// ## Events + /// + /// - [`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, + asset_id: T::AssetId, + fee: Permill, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + let old_value = RedemptionFee::::get(asset_id); + RedemptionFee::::insert(asset_id, fee); + Self::deposit_event(Event::RedemptionFeeUpdated { + asset_id, + old_value, + new_value: fee, + }); + Ok(()) + } + + /// Set the maximum PSM debt as a percentage of total maximum issuance. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`]. + /// + /// ## Events + /// + /// - [`Event::MaxPsmDebtOfTotalUpdated`]: 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 == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + let old_value = MaxPsmDebtOfTotal::::get(); + MaxPsmDebtOfTotal::::put(ratio); + Self::deposit_event(Event::MaxPsmDebtOfTotalUpdated { old_value, new_value: ratio }); + Ok(()) + } + + /// Set the circuit breaker status for a specific external asset. + /// + /// ## 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 + /// + /// ## Parameters + /// + /// - `asset_id`: The external stablecoin to configure + /// - `status`: The new circuit breaker level for this asset + /// + /// ## Errors + /// + /// - [`Error::AssetNotApproved`]: If the asset is not in the approved list + /// + /// ## Events + /// + /// - [`Event::AssetStatusUpdated`]: Emitted with the asset ID and new status + #[pallet::call_index(5)] + #[pallet::weight(T::WeightInfo::set_asset_status())] + pub fn set_asset_status( + origin: OriginFor, + 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 }); + Ok(()) + } + + /// Set the per-asset debt ceiling ratio. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`]. + /// + /// ## Details + /// + /// The per-asset ceiling is calculated as: + /// `max_asset_debt = ratio * MaxPsmDebtOfTotal * MaximumIssuance` + /// + /// ## Parameters + /// + /// - `asset_id`: The external stablecoin to configure + /// - `ratio`: Percentage of total PSM ceiling allocated to this asset + /// + /// ## Events + /// + /// - [`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, + asset_id: T::AssetId, + ratio: Permill, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + ensure!(ExternalAssets::::contains_key(asset_id), Error::::AssetNotApproved); + let old_value = AssetCeilingWeight::::get(asset_id); + AssetCeilingWeight::::insert(asset_id, ratio); + Self::deposit_event(Event::AssetCeilingWeightUpdated { + asset_id, + old_value, + new_value: ratio, + }); + Ok(()) + } + + /// Add an external stablecoin to the approved list. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`]. + /// + /// ## Parameters + /// + /// - `asset_id`: The external stablecoin to add + /// + /// ## Errors + /// + /// - [`Error::AssetAlreadyApproved`]: If the asset is already in the approved list + /// + /// ## Events + /// + /// - [`Event::ExternalAssetAdded`]: Emitted on successful addition + #[pallet::call_index(7)] + #[pallet::weight(T::WeightInfo::add_external_asset(T::MaxExternalAssets::get()))] + pub fn add_external_asset(origin: OriginFor, asset_id: T::AssetId) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + ensure!(!ExternalAssets::::contains_key(asset_id), Error::::AssetAlreadyApproved); + let count = ExternalAssets::::iter_keys().count() as u32; + ensure!(count < T::MaxExternalAssets::get(), Error::::TooManyAssets); + ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); + Self::deposit_event(Event::ExternalAssetAdded { asset_id }); + Ok(()) + } + + /// Remove an external stablecoin from the approved list. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`]. + /// + /// ## Details + /// + /// The asset cannot be removed if it has non-zero PSM debt outstanding. + /// This prevents orphaned debt that cannot be redeemed. + /// + /// Upon removal, the associated configuration is also cleaned up: + /// - [`MintingFee`] for this asset + /// - [`RedemptionFee`] for this asset + /// - [`AssetCeilingWeight`] for this asset + /// + /// ## Parameters + /// + /// - `asset_id`: The external stablecoin to remove + /// + /// ## Errors + /// + /// - [`Error::AssetNotApproved`]: If the asset is not in the approved list + /// - [`Error::AssetHasDebt`]: If the asset has non-zero PSM debt + /// + /// ## 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 == PsmManagerLevel::Full, 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); + PsmDebt::::remove(asset_id); + Self::deposit_event(Event::ExternalAssetRemoved { asset_id }); + Ok(()) + } + } + + impl Pallet { + /// Get the PSM's derived account. + pub fn account_id() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + + /// Calculate max PSM debt based on system ceiling. + pub fn max_psm_debt() -> BalanceOf { + let max_issuance = T::VaultsInterface::get_maximum_issuance(); + MaxPsmDebtOfTotal::::get().mul_floor(max_issuance) + } + + /// 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 ratio or the ratio is zero. + /// + /// Ratios act as weights that normalize to fill the PSM ceiling. When an + /// asset is disabled, governance should set its ratio to 0% so its ceiling + /// allocation is automatically redistributed to other assets. + pub fn max_asset_debt(asset_id: T::AssetId) -> BalanceOf { + let asset_ratio = AssetCeilingWeight::::get(asset_id); + + if asset_ratio.is_zero() { + return BalanceOf::::zero(); + } + + let total_ratio_sum: u32 = AssetCeilingWeight::::iter_values() + .map(|r| r.deconstruct()) + .fold(0u32, |acc, x| acc.saturating_add(x)); + + if total_ratio_sum == 0 { + return BalanceOf::::zero(); + } + + let total_psm_ceiling = Self::max_psm_debt(); + Perbill::from_rational(asset_ratio.deconstruct(), total_ratio_sum) + .mul_floor(total_psm_ceiling) + } + + /// Calculate total PSM debt across all approved assets. + pub fn total_psm_debt() -> BalanceOf { + PsmDebt::::iter_values() + .fold(BalanceOf::::zero(), |acc, debt| acc.saturating_add(debt)) + } + + /// Check if an asset is approved for PSM swaps. + pub fn is_approved_asset(asset_id: &T::AssetId) -> bool { + ExternalAssets::::contains_key(asset_id) + } + + /// Get the reserve (balance) of an external asset held by PSM. + pub fn get_reserve(asset_id: T::AssetId) -> BalanceOf { + T::Fungibles::balance(asset_id, &Self::account_id()) + } + + /// Ensure PSM account exists by incrementing its provider count. + pub(crate) fn ensure_psm_account_exists() { + let psm_account = Self::account_id(); + if !frame_system::Pallet::::account_exists(&psm_account) { + frame_system::Pallet::::inc_providers(&psm_account); + } + } + } +} + +impl PsmInterface for pallet::Pallet { + type Balance = pallet::BalanceOf; + + fn reserved_capacity() -> Self::Balance { + Self::max_psm_debt() + } +} diff --git a/substrate/frame/psm/src/migrations/mod.rs b/substrate/frame/psm/src/migrations/mod.rs new file mode 100644 index 0000000000000..57815663b2d8a --- /dev/null +++ b/substrate/frame/psm/src/migrations/mod.rs @@ -0,0 +1,22 @@ +// 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 v1; + +pub use v1::MigrateToV1; diff --git a/substrate/frame/psm/src/migrations/v1.rs b/substrate/frame/psm/src/migrations/v1.rs new file mode 100644 index 0000000000000..7d41b6b54fd91 --- /dev/null +++ b/substrate/frame/psm/src/migrations/v1.rs @@ -0,0 +1,274 @@ +// 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. + +//! Migration to V1: 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. +//! +//! # Usage +//! +//! Include in your runtime migrations: +//! +//! ```ignore +//! pub type Migrations = ( +//! pallet_psm::migrations::v1::MigrateToV1, +//! // ... other migrations +//! ); +//! ``` +//! +//! Where `PsmInitialConfig` implements [`InitialPsmConfig`]. + +use alloc::{collections::btree_map::BTreeMap, vec::Vec}; +use frame_support::{ + pallet_prelude::{Get, StorageVersion, Weight}, + traits::{GetStorageVersion, UncheckedOnRuntimeUpgrade}, +}; +use sp_runtime::Permill; + +use crate::{ + pallet::{ + AssetCeilingWeight, CircuitBreakerLevel, ExternalAssets, MaxPsmDebtOfTotal, MintingFee, + RedemptionFee, + }, + 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 +/// [`MigrateToV1`]. +pub trait InitialPsmConfig { + /// Max PSM debt as a fraction of MaximumIssuance. + fn max_psm_debt_of_total() -> Permill; + + /// Approved external stablecoin asset IDs. + fn external_asset_ids() -> Vec; + + /// Per-asset configuration: + /// - minting fee + /// - redemption fee + /// - asset ceiling weight + fn asset_configs() -> BTreeMap; +} + +/// Migration to initialize PSM pallet parameters (V0 -> V1). +/// +/// This migration: +/// 1. Sets `MaxPsmDebtOfTotal` +/// 2. Sets approved external assets with `AllEnabled` status +/// 3. Sets per-asset fee and ceiling-weight configuration +/// 4. Ensures the PSM account exists +/// +/// Only runs if the on-chain storage version is 0 (uninitialized). +pub struct MigrateToV1(core::marker::PhantomData<(T, I)>); + +impl> UncheckedOnRuntimeUpgrade for MigrateToV1 { + fn on_runtime_upgrade() -> Weight { + let on_chain_version = Pallet::::on_chain_storage_version(); + if on_chain_version != 0 { + log::info!( + target: LOG_TARGET, + "Skipping migration: on-chain version is {:?}, expected 0", + on_chain_version + ); + return T::DbWeight::get().reads(1); + } + + log::info!( + target: LOG_TARGET, + "Running MigrateToV1: initializing PSM pallet parameters" + ); + + let external_asset_ids = I::external_asset_ids(); + let asset_configs = I::asset_configs(); + + MaxPsmDebtOfTotal::::put(I::max_psm_debt_of_total()); + + for asset_id in &external_asset_ids { + ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); + } + + for (asset_id, (minting_fee, redemption_fee, max_asset_debt_ratio)) in &asset_configs { + MintingFee::::insert(asset_id, minting_fee); + RedemptionFee::::insert(asset_id, redemption_fee); + AssetCeilingWeight::::insert(asset_id, max_asset_debt_ratio); + } + + Pallet::::ensure_psm_account_exists(); + + StorageVersion::new(1).put::>(); + + log::info!( + target: LOG_TARGET, + "MigrateToV1 complete" + ); + + let writes = 3u64 + .saturating_add(external_asset_ids.len() as u64) + .saturating_add((asset_configs.len() as u64).saturating_mul(3)); + T::DbWeight::get().reads_writes(1, writes) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, TryRuntimeError> { + let on_chain_version = Pallet::::on_chain_storage_version(); + ensure!(on_chain_version == 0, "Expected storage version 0 before migration"); + Ok(Vec::new()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), TryRuntimeError> { + let on_chain_version = Pallet::::on_chain_storage_version(); + ensure!(on_chain_version == 1, "Expected storage version 1 after migration"); + + ensure!( + MaxPsmDebtOfTotal::::get() == I::max_psm_debt_of_total(), + "MaxPsmDebtOfTotal mismatch after migration" + ); + + for asset_id in I::external_asset_ids() { + ensure!( + ExternalAssets::::get(asset_id) == Some(CircuitBreakerLevel::AllEnabled), + "External asset missing or not AllEnabled after migration" + ); + } + + for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in I::asset_configs() { + 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, Test, USDC_ASSET_ID, USDT_ASSET_ID}; + + struct TestPsmConfig; + + impl InitialPsmConfig for TestPsmConfig { + fn max_psm_debt_of_total() -> Permill { + Permill::from_percent(25) + } + + fn external_asset_ids() -> Vec { + vec![USDC_ASSET_ID, USDT_ASSET_ID, 77] + } + + 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() + } + } + + #[test] + fn migration_v0_to_v1_works() { + new_test_ext().execute_with(|| { + StorageVersion::new(0).put::>(); + + MaxPsmDebtOfTotal::::kill(); + ExternalAssets::::remove(USDC_ASSET_ID); + ExternalAssets::::remove(USDT_ASSET_ID); + ExternalAssets::::remove(77); + 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); + + let _weight = MigrateToV1::::on_runtime_upgrade(); + + assert_eq!(MaxPsmDebtOfTotal::::get(), TestPsmConfig::max_psm_debt_of_total()); + + for asset_id in TestPsmConfig::external_asset_ids() { + assert_eq!( + ExternalAssets::::get(asset_id), + Some(CircuitBreakerLevel::AllEnabled) + ); + } + + for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in + TestPsmConfig::asset_configs() + { + assert_eq!(MintingFee::::get(asset_id), minting_fee); + assert_eq!(RedemptionFee::::get(asset_id), redemption_fee); + assert_eq!(AssetCeilingWeight::::get(asset_id), ceiling_weight); + } + + assert_eq!(Pallet::::on_chain_storage_version(), 1); + }); + } + + #[test] + fn migration_skipped_if_already_v1() { + new_test_ext().execute_with(|| { + StorageVersion::new(1).put::>(); + let before = MaxPsmDebtOfTotal::::get(); + + let weight = MigrateToV1::::on_runtime_upgrade(); + + assert_eq!(MaxPsmDebtOfTotal::::get(), before); + assert_eq!(weight, ::DbWeight::get().reads(1)); + }); + } +} diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs new file mode 100644 index 0000000000000..cb0c040bf4407 --- /dev/null +++ b/substrate/frame/psm/src/mock.rs @@ -0,0 +1,272 @@ +// 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. + +use crate::VaultsInterface; +use frame_support::{ + derive_impl, parameter_types, + traits::{ + tokens::imbalance::ResolveTo, AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, + EnsureOrigin, + }, + weights::constants::RocksDbWeight, + PalletId, +}; +use frame_system::{mocking::MockBlock, EnsureRoot, EnsureSigned, GenesisConfig}; +use sp_io::TestExternalities as TestState; +use sp_runtime::{BuildStorage, Permill}; +use std::cell::RefCell; + +// Test accounts +pub const ALICE: u64 = 1; +pub const BOB: u64 = 2; +pub const CHARLIE: u64 = 3; +pub const INSURANCE_FUND: u64 = 100; + +// Asset IDs +pub const PUSD_ASSET_ID: u32 = 1; +pub const USDC_ASSET_ID: u32 = 2; +pub const USDT_ASSET_ID: u32 = 3; +pub const UNSUPPORTED_ASSET_ID: u32 = 99; + +// pUSD unit (6 decimals) +pub const PUSD_UNIT: u128 = 1_000_000; + +// Initial balances for testing +pub const INITIAL_BALANCE: u128 = 1_000_000 * PUSD_UNIT; // 1M units + +// Thread-local storage for mock VaultsInterface +thread_local! { + static MOCK_MAXIMUM_ISSUANCE: RefCell = const { RefCell::new(10_000_000 * PUSD_UNIT) }; +} + +pub fn set_mock_maximum_issuance(value: u128) { + MOCK_MAXIMUM_ISSUANCE.with(|v| *v.borrow_mut() = value); +} + +pub struct MockVaultsInterface; + +impl VaultsInterface for MockVaultsInterface { + type Balance = u128; + + fn get_maximum_issuance() -> Self::Balance { + MOCK_MAXIMUM_ISSUANCE.with(|v| *v.borrow()) + } +} + +#[frame_support::runtime] +mod test_runtime { + #[runtime::runtime] + #[runtime::derive( + RuntimeCall, + RuntimeEvent, + RuntimeError, + RuntimeOrigin, + RuntimeFreezeReason, + RuntimeHoldReason, + RuntimeSlashReason, + RuntimeLockId, + RuntimeTask, + RuntimeViewFunction + )] + pub struct Test; + + #[runtime::pallet_index(0)] + pub type System = frame_system; + #[runtime::pallet_index(1)] + pub type Balances = pallet_balances; + #[runtime::pallet_index(2)] + pub type Assets = pallet_assets; + #[runtime::pallet_index(3)] + pub type Psm = crate; +} + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type Nonce = u64; + type Block = MockBlock; + type BlockHashCount = ConstU64<250>; + type DbWeight = RocksDbWeight; + type AccountData = pallet_balances::AccountData; +} + +#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)] +impl pallet_balances::Config for Test { + type Balance = u128; + type ExistentialDeposit = ConstU128<1>; + type AccountStore = System; + type RuntimeHoldReason = RuntimeHoldReason; +} + +#[derive_impl(pallet_assets::config_preludes::TestDefaultConfig)] +impl pallet_assets::Config for Test { + type Balance = u128; + type AssetId = u32; + type AssetIdParameter = u32; + type Currency = Balances; + type CreateOrigin = AsEnsureOriginWithArg>; + type ForceOrigin = EnsureRoot; +} + +parameter_types! { + pub const StablecoinAssetId: u32 = PUSD_ASSET_ID; + pub const InsuranceFundAccount: u64 = INSURANCE_FUND; + pub const MinSwapAmount: u128 = 100 * PUSD_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()) + } +} + +impl crate::Config for Test { + type Fungibles = Assets; + type AssetId = u32; + type VaultsInterface = MockVaultsInterface; + type ManagerOrigin = MockManagerOrigin; + type WeightInfo = (); + #[cfg(feature = "runtime-benchmarks")] + type StableAssetId = StablecoinAssetId; + type StableAsset = frame_support::traits::fungible::ItemOf; + type FeeHandler = ResolveTo; + type PalletId = PsmPalletId; + type MinSwapAmount = MinSwapAmount; + type MaxExternalAssets = ConstU32<10>; +} + +pub fn new_test_ext() -> TestState { + let mut storage = GenesisConfig::::default().build_storage().unwrap(); + + pallet_balances::GenesisConfig:: { + balances: vec![ + (ALICE, INITIAL_BALANCE), + (BOB, INITIAL_BALANCE), + (CHARLIE, INITIAL_BALANCE), + (INSURANCE_FUND, 1), + ], + ..Default::default() + } + .assimilate_storage(&mut storage) + .unwrap(); + + pallet_assets::GenesisConfig:: { + assets: vec![ + (PUSD_ASSET_ID, ALICE, true, 1), + (USDC_ASSET_ID, ALICE, true, 1), + (USDT_ASSET_ID, ALICE, true, 1), + ], + metadata: vec![ + (PUSD_ASSET_ID, b"pUSD Stablecoin".to_vec(), b"pUSD".to_vec(), 6), + (USDC_ASSET_ID, b"USD Coin".to_vec(), b"USDC".to_vec(), 6), + (USDT_ASSET_ID, b"Tether USD".to_vec(), b"USDT".to_vec(), 6), + ], + accounts: vec![ + (USDC_ASSET_ID, ALICE, 10_000 * PUSD_UNIT), + (USDC_ASSET_ID, BOB, 10_000 * PUSD_UNIT), + (USDT_ASSET_ID, ALICE, 10_000 * PUSD_UNIT), + (USDT_ASSET_ID, BOB, 10_000 * PUSD_UNIT), + ], + ..Default::default() + } + .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 * PUSD_UNIT); + }); + + ext +} + +pub fn set_minting_fee(asset_id: u32, fee: Permill) { + crate::MintingFee::::insert(asset_id, fee); +} + +pub fn set_redemption_fee(asset_id: u32, fee: Permill) { + crate::RedemptionFee::::insert(asset_id, fee); +} + +pub fn set_max_psm_debt_ratio(ratio: Permill) { + crate::MaxPsmDebtOfTotal::::put(ratio); +} + +pub fn set_asset_ceiling_weight(asset_id: u32, weight: Permill) { + crate::AssetCeilingWeight::::insert(asset_id, weight); +} + +pub fn set_asset_status(asset_id: u32, status: crate::CircuitBreakerLevel) { + crate::ExternalAssets::::insert(asset_id, status); +} + +pub fn fund_external_asset(asset_id: u32, account: u64, amount: u128) { + use frame_support::traits::fungibles::Mutate; + let _ = Assets::mint_into(asset_id, &account, amount); +} + +pub fn fund_pusd(account: u64, amount: u128) { + use frame_support::traits::fungibles::Mutate; + let _ = Assets::mint_into(PUSD_ASSET_ID, &account, amount); +} + +pub fn get_asset_balance(asset_id: u32, account: u64) -> u128 { + Assets::balance(asset_id, account) +} + +pub fn psm_account() -> u64 { + crate::Pallet::::account_id() +} diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs new file mode 100644 index 0000000000000..7d2be32a4e052 --- /dev/null +++ b/substrate/frame/psm/src/tests.rs @@ -0,0 +1,1519 @@ +// 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. + +use super::mock::*; +use crate::{ + AssetCeilingWeight, CircuitBreakerLevel, Error, Event, ExternalAssets, MaxPsmDebtOfTotal, + MintingFee, PsmDebt, RedemptionFee, VaultsInterface, +}; +use frame_support::{assert_noop, assert_ok}; +use sp_runtime::{DispatchError, Permill}; + +mod mint { + use super::*; + + #[test] + fn success_basic() { + new_test_ext().execute_with(|| { + let mint_amount = 1000 * PUSD_UNIT; + let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); + + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + + let fee = Permill::from_percent(1).mul_floor(mint_amount); + let pusd_to_user = mint_amount - fee; + + assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before - mint_amount); + assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), mint_amount); + assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), pusd_to_user); + assert_eq!(get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND), fee); + assert_eq!(PsmDebt::::get(USDC_ASSET_ID), mint_amount); + + System::assert_has_event( + Event::::Minted { + who: ALICE, + asset_id: USDC_ASSET_ID, + external_amount: mint_amount, + pusd_received: pusd_to_user, + fee, + } + .into(), + ); + }); + } + + #[test] + fn fee_zero() { + new_test_ext().execute_with(|| { + set_minting_fee(USDC_ASSET_ID, Permill::zero()); + + let mint_amount = 1000 * PUSD_UNIT; + + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + + assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), mint_amount); + assert_eq!(get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND), 0); + }); + } + + #[test] + fn fee_nonzero() { + new_test_ext().execute_with(|| { + set_minting_fee(USDC_ASSET_ID, Permill::from_percent(5)); + + let mint_amount = 1000 * PUSD_UNIT; + let fee = Permill::from_percent(5).mul_floor(mint_amount); + let pusd_to_user = mint_amount - fee; + + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + + assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), pusd_to_user); + assert_eq!(get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND), fee); + }); + } + + #[test] + fn fee_100_percent() { + new_test_ext().execute_with(|| { + set_minting_fee(USDC_ASSET_ID, Permill::from_percent(100)); + + let mint_amount = 1000 * PUSD_UNIT; + + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + + assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), 0); + assert_eq!(get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND), mint_amount); + }); + } + + #[test] + fn fails_unsupported_asset() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::mint(RuntimeOrigin::signed(ALICE), UNSUPPORTED_ASSET_ID, 1000 * PUSD_UNIT), + Error::::UnsupportedAsset + ); + }); + } + + #[test] + fn fails_asset_minting_disabled() { + new_test_ext().execute_with(|| { + set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); + + assert_noop!( + Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * PUSD_UNIT), + Error::::MintingStopped + ); + + // Other assets should still work + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDT_ASSET_ID, 1000 * PUSD_UNIT)); + }); + } + + #[test] + fn fails_asset_all_disabled() { + new_test_ext().execute_with(|| { + set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::AllDisabled); + + assert_noop!( + Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * PUSD_UNIT), + Error::::MintingStopped + ); + + // Other assets should still work + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDT_ASSET_ID, 1000 * PUSD_UNIT)); + }); + } + + #[test] + fn fails_below_minimum() { + new_test_ext().execute_with(|| { + let below_min = 50 * PUSD_UNIT; + + assert_noop!( + Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, below_min), + Error::::BelowMinimumSwap + ); + }); + } + + #[test] + 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_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(100)); + + let max_debt = crate::Pallet::::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), + Error::::ExceedsMaxPsmDebt + ); + }); + } + + #[test] + fn fails_with_extreme_debt_value() { + new_test_ext().execute_with(|| { + // 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_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(100)); + + PsmDebt::::insert(USDC_ASSET_ID, u128::MAX - 100); + + assert_noop!( + Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * PUSD_UNIT), + Error::::ExceedsMaxPsmDebt + ); + }); + } + + #[test] + 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_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); + + fund_external_asset(USDC_ASSET_ID, ALICE, max_debt); + + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, max_debt)); + + assert_eq!(PsmDebt::::get(USDC_ASSET_ID), max_debt); + }); + } + + #[test] + fn fails_insufficient_external_balance() { + new_test_ext().execute_with(|| { + let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); + let alice_pusd_before = get_asset_balance(PUSD_ASSET_ID, ALICE); + let psm_usdc_before = get_asset_balance(USDC_ASSET_ID, psm_account()); + let too_much = alice_usdc_before + 1000 * PUSD_UNIT; + + assert!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much).is_err()); + + // Verify no state mutation occurred + assert_eq!(PsmDebt::::get(USDC_ASSET_ID), 0); + assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before); + assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), alice_pusd_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 = MockVaultsInterface::get_maximum_issuance(); + + // Simulate Vaults having minted most of the cap (leave only 100 pUSD room) + let vault_minted = maximum_issuance - 100 * PUSD_UNIT; + fund_pusd(BOB, vault_minted); + + // PSM per-asset ceiling would allow this, but system cap won't + let mint_amount = 1000 * PUSD_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(|| { + // Set both assets to 50% ratio each (100% total) + // This tests that aggregate PSM ceiling is enforced even when per-asset ceilings allow + 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(); + + // 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)); + + // 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), + Error::::ExceedsMaxPsmDebt + ); + }); + } +} + +mod redeem { + use super::*; + + fn setup_for_redeem() { + let mint_amount = 5000 * PUSD_UNIT; + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + } + + #[test] + fn success_basic() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + + let redeem_amount = 1000 * PUSD_UNIT; + let alice_pusd_before = get_asset_balance(PUSD_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); + + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, redeem_amount)); + + let fee = Permill::from_percent(1).mul_floor(redeem_amount); + let external_to_user = redeem_amount - fee; + + assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), alice_pusd_before - redeem_amount); + assert_eq!( + get_asset_balance(USDC_ASSET_ID, ALICE), + alice_usdc_before + external_to_user + ); + assert_eq!( + 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); + + System::assert_has_event( + Event::::Redeemed { + who: ALICE, + asset_id: USDC_ASSET_ID, + pusd_paid: redeem_amount, + external_received: external_to_user, + fee, + } + .into(), + ); + }); + } + + #[test] + fn fee_zero() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + set_redemption_fee(USDC_ASSET_ID, Permill::zero()); + + let redeem_amount = 1000 * PUSD_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_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before + redeem_amount); + }); + } + + #[test] + fn fee_nonzero() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + set_redemption_fee(USDC_ASSET_ID, Permill::from_percent(5)); + + let redeem_amount = 1000 * PUSD_UNIT; + let fee = Permill::from_percent(5).mul_floor(redeem_amount); + 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_eq!( + get_asset_balance(USDC_ASSET_ID, ALICE), + alice_usdc_before + external_to_user + ); + }); + } + + #[test] + fn fee_100_percent() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + set_redemption_fee(USDC_ASSET_ID, Permill::from_percent(100)); + + let redeem_amount = 1000 * PUSD_UNIT; + let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); + let insurance_pusd_before = get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND); + + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, redeem_amount)); + + assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before); + assert_eq!( + get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND), + insurance_pusd_before + redeem_amount + ); + }); + } + + #[test] + fn fails_unsupported_asset() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + + assert_noop!( + Psm::redeem(RuntimeOrigin::signed(ALICE), UNSUPPORTED_ASSET_ID, 1000 * PUSD_UNIT), + Error::::UnsupportedAsset + ); + }); + } + + #[test] + fn fails_asset_all_disabled() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::AllDisabled); + + assert_noop!( + Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * PUSD_UNIT), + Error::::AllSwapsStopped + ); + }); + } + + #[test] + fn allows_when_asset_minting_disabled() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); + + // Redemption should still work when only minting is disabled + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * PUSD_UNIT)); + }); + } + + #[test] + fn fails_below_minimum() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + + let below_min = 50 * PUSD_UNIT; + + assert_noop!( + Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, below_min), + Error::::BelowMinimumSwap + ); + }); + } + + #[test] + fn fails_insufficient_reserve() { + new_test_ext().execute_with(|| { + fund_pusd(BOB, 10_000 * PUSD_UNIT); + + let reserve = get_asset_balance(USDC_ASSET_ID, psm_account()); + assert_eq!(reserve, 0); + + assert_noop!( + Psm::redeem(RuntimeOrigin::signed(BOB), USDC_ASSET_ID, 1000 * PUSD_UNIT), + Error::::InsufficientReserve + ); + }); + } + + #[test] + fn fails_insufficient_pusd_balance() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + + let alice_pusd = get_asset_balance(PUSD_ASSET_ID, ALICE); + let too_much = alice_pusd + 1000 * PUSD_UNIT; + + // Fund PSM with extra reserve so InsufficientReserve isn't hit first + fund_external_asset(USDC_ASSET_ID, psm_account(), 10_000 * PUSD_UNIT); + + let debt_before = PsmDebt::::get(USDC_ASSET_ID); + let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); + let psm_usdc_before = get_asset_balance(USDC_ASSET_ID, psm_account()); + + assert!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much).is_err()); + + // Verify no state mutation occurred + assert_eq!(PsmDebt::::get(USDC_ASSET_ID), debt_before); + assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), alice_pusd); + assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before); + assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), psm_usdc_before); + }); + } + + #[test] + fn boundary_reserve_equals_output() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + set_redemption_fee(USDC_ASSET_ID, Permill::zero()); + + let reserve = get_asset_balance(USDC_ASSET_ID, psm_account()); + fund_pusd(ALICE, reserve); + + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, reserve)); + + assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), 0); + }); + } + + #[test] + fn fails_when_reserve_exceeds_debt_donated_reserves() { + new_test_ext().execute_with(|| { + setup_for_redeem(); + set_redemption_fee(USDC_ASSET_ID, Permill::zero()); + + let debt = PsmDebt::::get(USDC_ASSET_ID); + let donation = 5000 * PUSD_UNIT; + + // Donate extra external stablecoin directly to PSM (bypassing mint) + fund_external_asset(USDC_ASSET_ID, psm_account(), donation); + + let reserve = get_asset_balance(USDC_ASSET_ID, psm_account()); + assert!(reserve > debt, "reserve should exceed debt after donation"); + + // Give user enough pUSD to try redeeming more than debt + let redeem_amount = debt + donation; + fund_pusd(ALICE, redeem_amount); + + // Should fail because redemption is limited by debt, not reserve + assert_noop!( + Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, redeem_amount), + Error::::InsufficientReserve + ); + + // Redeeming exactly the debt should work + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, debt)); + + // Donated reserves remain in PSM account (not withdrawable via redeem) + assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), donation); + }); + } +} + +mod governance { + use super::*; + + #[test] + fn set_minting_fee_works() { + new_test_ext().execute_with(|| { + let old_fee = MintingFee::::get(USDC_ASSET_ID); + let new_fee = Permill::from_percent(5); + + assert_ok!(Psm::set_minting_fee(RuntimeOrigin::root(), USDC_ASSET_ID, new_fee)); + + assert_eq!(MintingFee::::get(USDC_ASSET_ID), new_fee); + + System::assert_has_event( + Event::::MintingFeeUpdated { + asset_id: USDC_ASSET_ID, + old_value: old_fee, + new_value: new_fee, + } + .into(), + ); + }); + } + + #[test] + fn set_minting_fee_unauthorized() { + new_test_ext().execute_with(|| { + let old_fee = MintingFee::::get(USDC_ASSET_ID); + + assert_noop!( + Psm::set_minting_fee( + RuntimeOrigin::signed(ALICE), + USDC_ASSET_ID, + Permill::from_percent(5) + ), + DispatchError::BadOrigin + ); + + assert_eq!(MintingFee::::get(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 new_fee = Permill::from_percent(5); + + assert_ok!(Psm::set_redemption_fee(RuntimeOrigin::root(), USDC_ASSET_ID, new_fee)); + + assert_eq!(RedemptionFee::::get(USDC_ASSET_ID), new_fee); + + System::assert_has_event( + Event::::RedemptionFeeUpdated { + asset_id: USDC_ASSET_ID, + old_value: old_fee, + new_value: new_fee, + } + .into(), + ); + }); + } + + #[test] + fn set_redemption_fee_unauthorized() { + new_test_ext().execute_with(|| { + let old_fee = RedemptionFee::::get(USDC_ASSET_ID); + + assert_noop!( + Psm::set_redemption_fee( + RuntimeOrigin::signed(ALICE), + USDC_ASSET_ID, + Permill::from_percent(5) + ), + DispatchError::BadOrigin + ); + + assert_eq!(RedemptionFee::::get(USDC_ASSET_ID), old_fee); + }); + } + + #[test] + fn set_max_psm_debt_works() { + new_test_ext().execute_with(|| { + let old_ratio = MaxPsmDebtOfTotal::::get(); + let new_ratio = Permill::from_percent(20); + + assert_ok!(Psm::set_max_psm_debt(RuntimeOrigin::root(), new_ratio)); + + assert_eq!(MaxPsmDebtOfTotal::::get(), new_ratio); + + System::assert_has_event( + Event::::MaxPsmDebtOfTotalUpdated { + old_value: old_ratio, + new_value: new_ratio, + } + .into(), + ); + }); + } + + #[test] + fn set_max_psm_debt_unauthorized() { + new_test_ext().execute_with(|| { + let old_ratio = MaxPsmDebtOfTotal::::get(); + + assert_noop!( + Psm::set_max_psm_debt(RuntimeOrigin::signed(ALICE), Permill::from_percent(20)), + DispatchError::BadOrigin + ); + + assert_eq!(MaxPsmDebtOfTotal::::get(), old_ratio); + }); + } + + #[test] + fn set_asset_status_works() { + new_test_ext().execute_with(|| { + let new_status = CircuitBreakerLevel::MintingDisabled; + + assert_ok!(Psm::set_asset_status(RuntimeOrigin::root(), USDC_ASSET_ID, new_status)); + + assert_eq!(ExternalAssets::::get(USDC_ASSET_ID), Some(new_status)); + + System::assert_has_event( + Event::::AssetStatusUpdated { asset_id: USDC_ASSET_ID, status: new_status } + .into(), + ); + }); + } + + #[test] + fn set_asset_status_unauthorized() { + new_test_ext().execute_with(|| { + let old_status = ExternalAssets::::get(USDC_ASSET_ID); + + assert_noop!( + Psm::set_asset_status( + RuntimeOrigin::signed(ALICE), + USDC_ASSET_ID, + CircuitBreakerLevel::MintingDisabled + ), + DispatchError::BadOrigin + ); + + assert_eq!(ExternalAssets::::get(USDC_ASSET_ID), old_status); + }); + } + + #[test] + fn set_asset_status_fails_unapproved_asset() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::set_asset_status( + RuntimeOrigin::root(), + UNSUPPORTED_ASSET_ID, + CircuitBreakerLevel::MintingDisabled + ), + Error::::AssetNotApproved + ); + }); + } + + #[test] + fn set_asset_ceiling_weight_works() { + new_test_ext().execute_with(|| { + let old_ratio = AssetCeilingWeight::::get(USDC_ASSET_ID); + let new_ratio = Permill::from_percent(80); + + assert_ok!(Psm::set_asset_ceiling_weight( + RuntimeOrigin::root(), + USDC_ASSET_ID, + new_ratio + )); + + assert_eq!(AssetCeilingWeight::::get(USDC_ASSET_ID), new_ratio); + + System::assert_has_event( + Event::::AssetCeilingWeightUpdated { + asset_id: USDC_ASSET_ID, + old_value: old_ratio, + new_value: new_ratio, + } + .into(), + ); + }); + } + + #[test] + fn set_asset_ceiling_weight_unauthorized() { + new_test_ext().execute_with(|| { + let old_ratio = AssetCeilingWeight::::get(USDC_ASSET_ID); + + assert_noop!( + Psm::set_asset_ceiling_weight( + RuntimeOrigin::signed(ALICE), + USDC_ASSET_ID, + Permill::from_percent(80) + ), + DispatchError::BadOrigin + ); + + assert_eq!(AssetCeilingWeight::::get(USDC_ASSET_ID), old_ratio); + }); + } + + #[test] + fn add_external_asset_works() { + new_test_ext().execute_with(|| { + let new_asset = 99u32; + assert!(!Psm::is_approved_asset(&new_asset)); + + assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), new_asset)); + + assert!(Psm::is_approved_asset(&new_asset)); + + System::assert_has_event( + Event::::ExternalAssetAdded { asset_id: new_asset }.into(), + ); + }); + } + + #[test] + fn add_external_asset_unauthorized() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::add_external_asset(RuntimeOrigin::signed(ALICE), 99u32), + DispatchError::BadOrigin + ); + }); + } + + #[test] + fn add_external_asset_fails_already_approved() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::add_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID), + Error::::AssetAlreadyApproved + ); + }); + } + + #[test] + 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::::iter_keys().count() as u32; + // Fill up to the limit. + for i in 0..(max - existing) { + let asset_id = 1000 + i; + assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), asset_id)); + } + // One more should fail. + assert_noop!( + Psm::add_external_asset(RuntimeOrigin::root(), 9999), + Error::::TooManyAssets + ); + }); + } + + #[test] + fn remove_external_asset_works() { + new_test_ext().execute_with(|| { + assert!(Psm::is_approved_asset(&USDC_ASSET_ID)); + + assert_ok!(Psm::remove_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID)); + + assert!(!Psm::is_approved_asset(&USDC_ASSET_ID)); + + System::assert_has_event( + Event::::ExternalAssetRemoved { asset_id: USDC_ASSET_ID }.into(), + ); + }); + } + + #[test] + 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_ok!(Psm::remove_external_asset(RuntimeOrigin::root(), 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)); + }); + } + + #[test] + fn remove_external_asset_unauthorized() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::remove_external_asset(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID), + DispatchError::BadOrigin + ); + }); + } + + #[test] + fn remove_external_asset_fails_not_approved() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::remove_external_asset(RuntimeOrigin::root(), 99u32), + Error::::AssetNotApproved + ); + }); + } + + #[test] + fn remove_external_asset_fails_has_debt() { + new_test_ext().execute_with(|| { + fund_external_asset(USDC_ASSET_ID, ALICE, 1000 * PUSD_UNIT); + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * PUSD_UNIT)); + + assert_noop!( + Psm::remove_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID), + Error::::AssetHasDebt + ); + }); + } + + #[test] + fn emergency_origin_can_set_asset_status() { + new_test_ext().execute_with(|| { + let new_status = CircuitBreakerLevel::MintingDisabled; + + assert_ok!(Psm::set_asset_status( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + USDC_ASSET_ID, + new_status + )); + + assert_eq!(ExternalAssets::::get(USDC_ASSET_ID), Some(new_status)); + }); + } + + #[test] + fn emergency_origin_cannot_set_minting_fee() { + new_test_ext().execute_with(|| { + let old_fee = MintingFee::::get(USDC_ASSET_ID); + + assert_noop!( + Psm::set_minting_fee( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + USDC_ASSET_ID, + Permill::from_percent(5) + ), + Error::::InsufficientPrivilege + ); + + assert_eq!(MintingFee::::get(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); + + assert_noop!( + Psm::set_redemption_fee( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + USDC_ASSET_ID, + Permill::from_percent(5) + ), + Error::::InsufficientPrivilege + ); + + assert_eq!(RedemptionFee::::get(USDC_ASSET_ID), old_fee); + }); + } + + #[test] + fn emergency_origin_cannot_set_max_psm_debt() { + new_test_ext().execute_with(|| { + let old_ratio = MaxPsmDebtOfTotal::::get(); + + assert_noop!( + Psm::set_max_psm_debt( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + Permill::from_percent(20) + ), + Error::::InsufficientPrivilege + ); + + assert_eq!(MaxPsmDebtOfTotal::::get(), old_ratio); + }); + } + + #[test] + fn emergency_origin_cannot_set_asset_ceiling_weight() { + new_test_ext().execute_with(|| { + let old_ratio = AssetCeilingWeight::::get(USDC_ASSET_ID); + + assert_noop!( + Psm::set_asset_ceiling_weight( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + USDC_ASSET_ID, + Permill::from_percent(80) + ), + Error::::InsufficientPrivilege + ); + + assert_eq!(AssetCeilingWeight::::get(USDC_ASSET_ID), old_ratio); + }); + } + + #[test] + fn emergency_origin_cannot_add_external_asset() { + new_test_ext().execute_with(|| { + let new_asset = 99u32; + + assert_noop!( + Psm::add_external_asset(RuntimeOrigin::signed(EMERGENCY_ACCOUNT), new_asset), + Error::::InsufficientPrivilege + ); + + assert!(!Psm::is_approved_asset(&new_asset)); + }); + } + + #[test] + 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), + Error::::InsufficientPrivilege + ); + + assert!(Psm::is_approved_asset(&USDC_ASSET_ID)); + }); + } +} + +mod helpers { + use super::*; + + #[test] + fn max_psm_debt_calculation() { + new_test_ext().execute_with(|| { + set_mock_maximum_issuance(10_000_000 * PUSD_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 * PUSD_UNIT); + + assert_eq!(max_debt, expected); + }); + } + + #[test] + fn max_asset_debt_calculation() { + new_test_ext().execute_with(|| { + set_mock_maximum_issuance(10_000_000 * PUSD_UNIT); + set_max_psm_debt_ratio(Permill::from_percent(10)); + 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 * PUSD_UNIT)); + + assert_eq!(max_asset_debt, expected); + }); + } + + #[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)); + }); + } + + #[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(&PUSD_ASSET_ID)); + }); + } + + #[test] + fn get_reserve_returns_balance() { + new_test_ext().execute_with(|| { + assert_eq!(crate::Pallet::::get_reserve(USDC_ASSET_ID), 0); + + let mint_amount = 1000 * PUSD_UNIT; + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); + + assert_eq!(crate::Pallet::::get_reserve(USDC_ASSET_ID), mint_amount); + }); + } + + #[test] + fn account_id_is_derived() { + new_test_ext().execute_with(|| { + let account = crate::Pallet::::account_id(); + assert_ne!(account, ALICE); + assert_ne!(account, BOB); + assert_ne!(account, INSURANCE_FUND); + }); + } +} + +mod ceiling_redistribution { + use super::*; + + #[test] + fn zero_ratio_redistributes_ceiling_to_others() { + new_test_ext().execute_with(|| { + // Setup: USDC 60%, USDT 40% of PSM ceiling + // 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_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(); + assert_eq!(max_psm, 10_000_000 * PUSD_UNIT); + + // Normal ceiling for USDT = 4M + let usdt_normal_ceiling = crate::Pallet::::max_asset_debt(USDT_ASSET_ID); + assert_eq!(usdt_normal_ceiling, 4_000_000 * PUSD_UNIT); + + // Disable USDC minting and set ratio to 0% (governance workflow) + set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); + set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(0)); + + // Now USDT should be able to use the full PSM ceiling + // total_ratio_sum = 0% + 40% = 40% + // effective_ratio = 40% / 40% = 100% + // effective_ceiling = 100% of 10M = 10M + fund_external_asset(USDT_ASSET_ID, BOB, 10_000_000 * PUSD_UNIT); + + // Mint up to the old ceiling (4M) - should work + assert_ok!(Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, 4_000_000 * PUSD_UNIT)); + + // Mint another 5M - this would fail with old logic but should work now + assert_ok!(Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, 5_000_000 * PUSD_UNIT)); + + // Total USDT debt should be 9M + assert_eq!(PsmDebt::::get(USDT_ASSET_ID), 9_000_000 * PUSD_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 * PUSD_UNIT), + Error::::ExceedsMaxPsmDebt + ); + }); + } + + #[test] + fn multiple_assets_share_redistributed_ceiling() { + new_test_ext().execute_with(|| { + // Add a third asset + let dai_asset_id = 4u32; + assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), dai_asset_id)); + + // Setup: USDC 50%, USDT 25%, DAI 25% + set_max_psm_debt_ratio(Permill::from_percent(50)); + 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(dai_asset_id, Permill::from_percent(25)); + + let max_psm = crate::Pallet::::max_psm_debt(); + assert_eq!(max_psm, 10_000_000 * PUSD_UNIT); + + // Disable USDC and set ratio to 0% + set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); + set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(0)); + + // Now USDT and DAI split the full ceiling + // total_ratio_sum = 0% + 25% + 25% = 50% + // USDT effective_ratio = 25% / 50% = 50% -> 5M ceiling + // DAI effective_ratio = 25% / 50% = 50% -> 5M ceiling + + fund_external_asset(USDT_ASSET_ID, ALICE, 6_000_000 * PUSD_UNIT); + + // USDT can mint up to 5M (redistributed ceiling) + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + USDT_ASSET_ID, + 5_000_000 * PUSD_UNIT + )); + + // USDT can't mint more than its redistributed ceiling + assert_noop!( + Psm::mint(RuntimeOrigin::signed(ALICE), USDT_ASSET_ID, 1_000_000 * PUSD_UNIT), + Error::::ExceedsMaxPsmDebt + ); + }); + } + + #[test] + fn normal_ratios_use_proportional_ceilings() { + new_test_ext().execute_with(|| { + // Setup: USDC 60%, USDT 40% + set_max_psm_debt_ratio(Permill::from_percent(50)); + set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(60)); + set_asset_ceiling_weight(USDT_ASSET_ID, Permill::from_percent(40)); + + // Both assets have non-zero ratios - should use proportional ceilings + // USDT ceiling = 40% of 10M = 4M + + fund_external_asset(USDT_ASSET_ID, BOB, 5_000_000 * PUSD_UNIT); + + // Can mint up to 4M + assert_ok!(Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, 4_000_000 * PUSD_UNIT)); + + // Can't mint more - exceeds per-asset ceiling + assert_noop!( + Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, 1_000_000 * PUSD_UNIT), + Error::::ExceedsMaxPsmDebt + ); + }); + } + + #[test] + fn restoring_ratio_restores_normal_ceilings() { + new_test_ext().execute_with(|| { + // Setup: USDC 60%, USDT 40% + set_max_psm_debt_ratio(Permill::from_percent(50)); + set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(60)); + set_asset_ceiling_weight(USDT_ASSET_ID, Permill::from_percent(40)); + + fund_external_asset(USDT_ASSET_ID, BOB, 10_000_000 * PUSD_UNIT); + + // Disable USDC and set ratio to 0% - USDT can use full ceiling + set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); + set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(0)); + assert_ok!(Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, 5_000_000 * PUSD_UNIT)); + + // Re-enable USDC and restore ratio + set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::AllEnabled); + set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(60)); + + // 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 * PUSD_UNIT), + Error::::ExceedsMaxPsmDebt + ); + }); + } +} + +mod cycles { + use super::*; + + fn last_event() -> Event { + System::events() + .into_iter() + .filter_map(|r| if let RuntimeEvent::Psm(inner) = r.event { Some(inner) } else { None }) + .next_back() + .expect("Expected at least one PSM event") + } + + #[test] + fn mint_redeem_cycles_accounting() { + new_test_ext().execute_with(|| { + let cycles = 10u128; + let amount = 1000 * PUSD_UNIT; + + // Ensure user has enough funds for all cycles + fund_external_asset(USDC_ASSET_ID, ALICE, 1_000_000 * PUSD_UNIT); + fund_external_asset(PUSD_ASSET_ID, ALICE, 1_000_000 * PUSD_UNIT); + + // Record initial balances + let user_external_before = get_asset_balance(USDC_ASSET_ID, ALICE); + let user_pusd_before = get_asset_balance(PUSD_ASSET_ID, ALICE); + let if_pusd_before = get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND); + let psm_external_before = get_asset_balance(USDC_ASSET_ID, psm_account()); + + let unit = PUSD_UNIT as f64; + + println!("=== Initial State ==="); + println!("User USDC: {:.2}", user_external_before as f64 / unit); + println!("IF pUSD: {:.2}", if_pusd_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); + + 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)); + + let (mint_fee, pusd_received) = match last_event() { + Event::Minted { fee, pusd_received, .. } => (fee, pusd_received), + _ => panic!("Expected Minted event"), + }; + total_mint_fees += mint_fee; + + println!( + "\n=== Cycle {} - After Mint ({:.2} USDC) ===", + i + 1, + amount as f64 / unit + ); + println!("Mint fee: {:.2}", mint_fee as f64 / unit); + println!("pUSD received: {:.2}", pusd_received as f64 / unit); + println!("User USDC: {:.2}", get_asset_balance(USDC_ASSET_ID, ALICE) as f64 / unit); + println!("User pUSD: {:.2}", get_asset_balance(PUSD_ASSET_ID, ALICE) as f64 / unit); + println!( + "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!( + "IF pUSD: {:.2}", + get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND) as f64 / unit + ); + + // Redeem all pUSD received + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); + + let (redeem_fee, external_received) = match last_event() { + Event::Redeemed { fee, external_received, .. } => (fee, external_received), + _ => panic!("Expected Redeemed event"), + }; + total_redeem_fees += redeem_fee; + + println!( + "\n=== Cycle {} - After Redeem ({:.2} pUSD) ===", + i + 1, + amount as f64 / unit + ); + println!("Redeem fee: {:.2}", redeem_fee as f64 / unit); + println!("USDC received: {:.2}", external_received as f64 / unit); + println!("User USDC: {:.2}", get_asset_balance(USDC_ASSET_ID, ALICE) as f64 / unit); + println!("User pUSD: {:.2}", get_asset_balance(PUSD_ASSET_ID, ALICE) as f64 / unit); + println!( + "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!( + "IF pUSD: {:.2}", + get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND) as f64 / unit + ); + } + + // Final balances + let user_external_after = get_asset_balance(USDC_ASSET_ID, ALICE); + let user_pusd_after = get_asset_balance(PUSD_ASSET_ID, ALICE); + + let if_pusd_after = get_asset_balance(PUSD_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); + + println!("\n=== Final State ==="); + println!("User USDC: {:.2}", user_external_after as f64 / unit); + println!("User pUSD: {:.2}", get_asset_balance(PUSD_ASSET_ID, ALICE) as f64 / unit); + println!("IF pUSD: {:.2}", if_pusd_after as f64 / unit); + println!("PSM USDC: {:.2}", psm_external_after as f64 / unit); + println!("PSM Debt: {:.2}", psm_debt_after as f64 / unit); + println!("Total mint fees: {:.2}", total_mint_fees as f64 / unit); + println!("Total redeem fees: {:.2}", total_redeem_fees as f64 / unit); + + let total_fees = total_mint_fees + total_redeem_fees; + let if_increase = if_pusd_after - if_pusd_before; + let user_decrease = + user_external_before - user_external_after + user_pusd_before - user_pusd_after; + + println!("\n=== Verification ==="); + println!("Total fees collected: {:.2}", total_fees as f64 / unit); + println!("IF increase: {:.2}", if_increase as f64 / unit); + println!("User decrease: {:.2}", user_decrease as f64 / unit); + + // Assertions + // 1. IF balance increased by total fees (mint fees + redeem fees) + assert_eq!(if_increase, total_fees, "IF should receive all fees"); + + // 2. PSM external balance equals what remained after redemptions + assert_eq!(psm_external_after, psm_debt_after, "PSM external = PSM debt"); + + // 3. User external decrease equals total fees paid + assert_eq!(user_decrease, total_fees, "User loss equals fees"); + + // 4. PSM debt equals PSM external stablecoin balance + assert_eq!( + psm_debt_after, + get_asset_balance(USDC_ASSET_ID, psm_account()), + "PSM debt equals PSM external balance" + ); + }); + } + + #[test] + fn infinite_until_debt_ceiling() { + new_test_ext().execute_with(|| { + let amount = 100_000 * PUSD_UNIT; + + // 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)); + set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(50)); + + let max_debt = crate::Pallet::::max_asset_debt(USDC_ASSET_ID); + + println!("MAX DEBT: {}", max_debt); + + // Fund user with more than enough to hit the ceiling + let funding = max_debt * 2; + fund_external_asset(USDC_ASSET_ID, ALICE, funding); + fund_external_asset(PUSD_ASSET_ID, ALICE, funding); + // Record initial balances + let user_external_before = get_asset_balance(USDC_ASSET_ID, ALICE); + let user_pusd_before = get_asset_balance(PUSD_ASSET_ID, ALICE); + let if_pusd_before = get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND); + let psm_external_before = get_asset_balance(USDC_ASSET_ID, psm_account()); + + let unit = PUSD_UNIT as f64; + + println!("=== Initial State ==="); + println!("Max debt ceiling: {:.2}", max_debt as f64 / unit); + println!("User USDC: {:.2}", user_external_before as f64 / unit); + println!("User pUSD: {:.2}", user_pusd_before as f64 / unit); + println!("IF pUSD: {:.2}", if_pusd_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); + + 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); + + // Check if we can mint another `amount` + if current_debt + amount > max_debt { + println!("\n=== Debt ceiling reached after {} cycles ===", cycle); + println!("Current debt: {:.2}", current_debt as f64 / unit); + println!("Max debt: {:.2}", max_debt as f64 / unit); + println!("Cannot mint {:.2} more (would exceed ceiling)", amount as f64 / unit); + break; + } + + cycle += 1; + + // Mint + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); + + let (mint_fee, pusd_received) = match last_event() { + Event::Minted { fee, pusd_received, .. } => (fee, pusd_received), + _ => panic!("Expected Minted event"), + }; + total_mint_fees += mint_fee; + + println!( + "\n=== Cycle {} - After Mint ({:.2} USDC) ===", + cycle, + amount as f64 / unit + ); + println!("Mint fee: {:.2}", mint_fee as f64 / unit); + println!("pUSD received: {:.2}", pusd_received as f64 / unit); + println!("User USDC: {:.2}", get_asset_balance(USDC_ASSET_ID, ALICE) as f64 / unit); + println!("User pUSD: {:.2}", get_asset_balance(PUSD_ASSET_ID, ALICE) as f64 / unit); + println!( + "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!( + "IF pUSD: {:.2}", + get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND) as f64 / unit + ); + + // Redeem all pUSD received + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); + + let (redeem_fee, external_received) = match last_event() { + Event::Redeemed { fee, external_received, .. } => (fee, external_received), + _ => panic!("Expected Redeemed event"), + }; + total_redeem_fees += redeem_fee; + + println!( + "\n=== Cycle {} - After Redeem ({:.2} pUSD) ===", + cycle, + amount as f64 / unit + ); + println!("Redeem fee: {:.2}", redeem_fee as f64 / unit); + println!("USDC received: {:.2}", external_received as f64 / unit); + println!("User USDC: {:.2}", get_asset_balance(USDC_ASSET_ID, ALICE) as f64 / unit); + println!("User pUSD: {:.2}", get_asset_balance(PUSD_ASSET_ID, ALICE) as f64 / unit); + println!( + "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!( + "IF pUSD: {:.2}", + get_asset_balance(PUSD_ASSET_ID, INSURANCE_FUND) as f64 / unit + ); + } + + // Final balances + let user_external_after = get_asset_balance(USDC_ASSET_ID, ALICE); + let user_pusd_after = get_asset_balance(PUSD_ASSET_ID, ALICE); + + let if_pusd_after = get_asset_balance(PUSD_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); + + println!("\n=== Final State ==="); + println!("Total cycles: {}", cycle); + println!("User USDC: {:.2}", user_external_after as f64 / unit); + println!("User pUSD: {:.2}", user_pusd_after as f64 / unit); + + println!("IF pUSD: {:.2}", if_pusd_after as f64 / unit); + println!("PSM USDC: {:.2}", psm_external_after as f64 / unit); + println!("PSM Debt: {:.2}", psm_debt_after as f64 / unit); + println!("Total mint fees: {:.2}", total_mint_fees as f64 / unit); + println!("Total redeem fees: {:.2}", total_redeem_fees as f64 / unit); + + let total_fees = total_mint_fees + total_redeem_fees; + let if_increase = if_pusd_after - if_pusd_before; + let user_decrease = + user_external_before - user_external_after + user_pusd_before - user_pusd_after; + + println!("\n=== Verification ==="); + println!("Total fees collected: {:.2}", total_fees as f64 / unit); + println!("IF increase: {:.2}", if_increase as f64 / unit); + println!("User decrease: {:.2}", user_decrease as f64 / unit); + + // Assertions + assert!(cycle > 0, "Should have completed at least one cycle"); + assert_eq!(if_increase, total_fees, "IF should receive all fees"); + assert_eq!(psm_external_after, psm_debt_after, "PSM external = PSM debt"); + assert_eq!(user_decrease, total_fees, "User loss equals fees"); + assert!(psm_debt_after <= max_debt, "PSM debt should not exceed ceiling"); + + // Redeem to fully drain PSM debt to 0 + // When redeeming: external_received = pusd_paid - fee = pusd_paid * (1 - fee_rate) + // So: pusd_paid = external_received / (1 - fee_rate) + let fee_rate = RedemptionFee::::get(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 pusd_needed = (psm_debt_after * 1_000_000).div_ceil(complement_parts); + println!("pUSD Needed: {:#?}", pusd_needed); + + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, pusd_needed)); + + let (redeem_fee, _external_received) = match last_event() { + Event::Redeemed { fee, external_received, .. } => (fee, external_received), + _ => panic!("Expected Redeemed event"), + }; + total_redeem_fees += redeem_fee; + + // Final balances (after the drain redemption) + let user_external_after = get_asset_balance(USDC_ASSET_ID, ALICE); + let user_pusd_after = get_asset_balance(PUSD_ASSET_ID, ALICE); + let if_pusd_after = get_asset_balance(PUSD_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 total_fees = total_mint_fees + total_redeem_fees; + let if_increase = if_pusd_after - if_pusd_before; + let user_decrease = + user_external_before - user_external_after + user_pusd_before - user_pusd_after; + + println!("\n=== Final State ==="); + println!("Total cycles: {}", cycle); + println!("User USDC: {:.2}", user_external_after as f64 / unit); + println!("User pUSD: {:.2}", user_pusd_after as f64 / unit); + + println!("IF pUSD: {:.2}", if_pusd_after as f64 / unit); + println!("PSM USDC: {:.2}", psm_external_after as f64 / unit); + println!("PSM Debt: {:.2}", psm_debt_after as f64 / unit); + println!("Total mint fees: {:.2}", total_mint_fees as f64 / unit); + println!("Total redeem fees: {:.2}", total_redeem_fees as f64 / unit); + + println!("\n=== Verification ==="); + println!("Total fees collected: {:.2}", total_fees as f64 / unit); + println!("IF increase: {:.2}", if_increase as f64 / unit); + println!("User decrease: {:.2}", user_decrease as f64 / unit); + + assert_eq!(psm_debt_after, 0, "PSM debt should be fully drained to 0"); + assert_eq!(psm_external_after, 0, "PSM USDC balance should be 0"); + assert_eq!(user_external_after, user_external_before, "User USDC should be unchanged"); + assert_eq!( + user_pusd_after + if_pusd_after, + user_pusd_before + if_pusd_before, + "pUSD conservation: user + IF at end should equal initial balances" + ); + assert_eq!( + total_fees, if_increase, + "Total fees (mint + redeem) should equal IF pUSD increase" + ); + }); + } +} diff --git a/substrate/frame/psm/src/weights.rs b/substrate/frame/psm/src/weights.rs new file mode 100644 index 0000000000000..dbda9bdc0cfd7 --- /dev/null +++ b/substrate/frame/psm/src/weights.rs @@ -0,0 +1,362 @@ +// 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. + +//! Autogenerated weights for `pallet_psm` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 +//! DATE: 2026-03-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `zur1-vm-devleo-001`, CPU: `AMD EPYC 9354 32-Core Processor` +//! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` + +// Executed Command: +// ./target/release/substrate-node +// benchmark +// pallet +// --chain +// dev +// --wasm-execution +// compiled +// --heap-pages +// 4096 +// --pallet +// pallet_psm +// --extrinsic +// * +// --steps +// 50 +// --repeat +// 20 +// --output +// ./substrate/frame/psm/src/weights.rs +// --header +// ./substrate/HEADER-APACHE2 +// --template +// ./substrate/.maintain/frame-weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] +#![allow(dead_code)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for `pallet_psm`. +pub trait WeightInfo { + fn mint(n: u32, ) -> Weight; + fn redeem() -> Weight; + fn set_minting_fee() -> Weight; + fn set_redemption_fee() -> Weight; + fn set_max_psm_debt() -> Weight; + fn set_asset_status() -> Weight; + fn set_asset_ceiling_weight() -> Weight; + fn add_external_asset(n: u32, ) -> Weight; + fn remove_external_asset() -> Weight; +} + +/// Weights for `pallet_psm` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `Psm::ExternalAssets` (r:1 w:0) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:2 w:2) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Psm::PsmDebt` (r:11 w:1) + /// Proof: `Psm::PsmDebt` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `Psm::MaxPsmDebtOfTotal` (r:1 w:0) + /// Proof: `Psm::MaxPsmDebtOfTotal` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Psm::AssetCeilingWeight` (r:11 w:0) + /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:4 w:4) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Psm::MintingFee` (r:1 w:0) + /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// The range of component `n` is `[1, 10]`. + fn mint(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1225 + n * (88 ±0)` + // Estimated: `11426 + n * (2511 ±0)` + // Minimum execution time: 136_570_000 picoseconds. + Weight::from_parts(135_536_770, 11426) + // Standard Error: 88_404 + .saturating_add(Weight::from_parts(8_859_854, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(13_u64)) + .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(9_u64)) + .saturating_add(Weight::from_parts(0, 2511).saturating_mul(n.into())) + } + /// Storage: `Psm::ExternalAssets` (r:1 w:0) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Psm::RedemptionFee` (r:1 w:0) + /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Psm::PsmDebt` (r:1 w:1) + /// Proof: `Psm::PsmDebt` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:4 w:4) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:2 w:2) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + fn redeem() -> Weight { + // Proof Size summary in bytes: + // Measured: `1436` + // Estimated: `11426` + // Minimum execution time: 137_720_000 picoseconds. + Weight::from_parts(145_381_000, 11426) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(7_u64)) + } + /// Storage: `Psm::MintingFee` (r:1 w:1) + /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + fn set_minting_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `3489` + // Minimum execution time: 17_030_000 picoseconds. + Weight::from_parts(20_569_000, 3489) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Psm::RedemptionFee` (r:1 w:1) + /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + fn set_redemption_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `3489` + // Minimum execution time: 16_040_000 picoseconds. + Weight::from_parts(19_499_000, 3489) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// 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 { + // Proof Size summary in bytes: + // Measured: `306` + // Estimated: `1489` + // Minimum execution time: 10_680_000 picoseconds. + Weight::from_parts(16_101_000, 1489) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Psm::ExternalAssets` (r:1 w:1) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + fn set_asset_status() -> Weight { + // Proof Size summary in bytes: + // Measured: `382` + // Estimated: `3486` + // Minimum execution time: 16_890_000 picoseconds. + Weight::from_parts(18_619_000, 3486) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Psm::ExternalAssets` (r:1 w:0) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Psm::AssetCeilingWeight` (r:1 w:1) + /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + fn set_asset_ceiling_weight() -> Weight { + // Proof Size summary in bytes: + // Measured: `431` + // Estimated: `3489` + // Minimum execution time: 19_960_000 picoseconds. + Weight::from_parts(21_880_000, 3489) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Psm::ExternalAssets` (r:11 w:1) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// The range of component `n` is `[0, 9]`. + fn add_external_asset(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `326 + n * (25 ±0)` + // Estimated: `5982 + n * (2496 ±0)` + // Minimum execution time: 16_899_000 picoseconds. + Weight::from_parts(20_232_914, 5982) + // Standard Error: 20_196 + .saturating_add(Weight::from_parts(1_654_666, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(1_u64)) + .saturating_add(Weight::from_parts(0, 2496).saturating_mul(n.into())) + } + /// Storage: `Psm::ExternalAssets` (r:1 w:1) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Psm::PsmDebt` (r:1 w:1) + /// Proof: `Psm::PsmDebt` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `Psm::AssetCeilingWeight` (r:0 w:1) + /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Psm::MintingFee` (r:0 w:1) + /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Psm::RedemptionFee` (r:0 w:1) + /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + fn remove_external_asset() -> Weight { + // Proof Size summary in bytes: + // Measured: `394` + // Estimated: `3501` + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(26_570_000, 3501) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `Psm::ExternalAssets` (r:1 w:0) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:2 w:2) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + /// Storage: `Psm::PsmDebt` (r:11 w:1) + /// Proof: `Psm::PsmDebt` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `Psm::MaxPsmDebtOfTotal` (r:1 w:0) + /// Proof: `Psm::MaxPsmDebtOfTotal` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Psm::AssetCeilingWeight` (r:11 w:0) + /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:4 w:4) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Psm::MintingFee` (r:1 w:0) + /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// The range of component `n` is `[1, 10]`. + fn mint(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1225 + n * (88 ±0)` + // Estimated: `11426 + n * (2511 ±0)` + // Minimum execution time: 136_570_000 picoseconds. + Weight::from_parts(135_536_770, 11426) + // Standard Error: 88_404 + .saturating_add(Weight::from_parts(8_859_854, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(13_u64)) + .saturating_add(RocksDbWeight::get().reads((2_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes(9_u64)) + .saturating_add(Weight::from_parts(0, 2511).saturating_mul(n.into())) + } + /// Storage: `Psm::ExternalAssets` (r:1 w:0) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Psm::RedemptionFee` (r:1 w:0) + /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Psm::PsmDebt` (r:1 w:1) + /// Proof: `Psm::PsmDebt` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `Assets::Account` (r:4 w:4) + /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) + /// Storage: `Assets::Asset` (r:2 w:2) + /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) + fn redeem() -> Weight { + // Proof Size summary in bytes: + // Measured: `1436` + // Estimated: `11426` + // Minimum execution time: 137_720_000 picoseconds. + Weight::from_parts(145_381_000, 11426) + .saturating_add(RocksDbWeight::get().reads(9_u64)) + .saturating_add(RocksDbWeight::get().writes(7_u64)) + } + /// Storage: `Psm::MintingFee` (r:1 w:1) + /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + fn set_minting_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `3489` + // Minimum execution time: 17_030_000 picoseconds. + Weight::from_parts(20_569_000, 3489) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Psm::RedemptionFee` (r:1 w:1) + /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + fn set_redemption_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `3489` + // Minimum execution time: 16_040_000 picoseconds. + Weight::from_parts(19_499_000, 3489) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// 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 { + // Proof Size summary in bytes: + // Measured: `306` + // Estimated: `1489` + // Minimum execution time: 10_680_000 picoseconds. + Weight::from_parts(16_101_000, 1489) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Psm::ExternalAssets` (r:1 w:1) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + fn set_asset_status() -> Weight { + // Proof Size summary in bytes: + // Measured: `382` + // Estimated: `3486` + // Minimum execution time: 16_890_000 picoseconds. + Weight::from_parts(18_619_000, 3486) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Psm::ExternalAssets` (r:1 w:0) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Psm::AssetCeilingWeight` (r:1 w:1) + /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + fn set_asset_ceiling_weight() -> Weight { + // Proof Size summary in bytes: + // Measured: `431` + // Estimated: `3489` + // Minimum execution time: 19_960_000 picoseconds. + Weight::from_parts(21_880_000, 3489) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Psm::ExternalAssets` (r:11 w:1) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// The range of component `n` is `[0, 9]`. + fn add_external_asset(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `326 + n * (25 ±0)` + // Estimated: `5982 + n * (2496 ±0)` + // Minimum execution time: 16_899_000 picoseconds. + Weight::from_parts(20_232_914, 5982) + // Standard Error: 20_196 + .saturating_add(Weight::from_parts(1_654_666, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + .saturating_add(Weight::from_parts(0, 2496).saturating_mul(n.into())) + } + /// Storage: `Psm::ExternalAssets` (r:1 w:1) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Psm::PsmDebt` (r:1 w:1) + /// Proof: `Psm::PsmDebt` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `Psm::AssetCeilingWeight` (r:0 w:1) + /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Psm::MintingFee` (r:0 w:1) + /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + /// Storage: `Psm::RedemptionFee` (r:0 w:1) + /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) + fn remove_external_asset() -> Weight { + // Proof Size summary in bytes: + // Measured: `394` + // Estimated: `3501` + // Minimum execution time: 25_000_000 picoseconds. + Weight::from_parts(26_570_000, 3501) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } +} diff --git a/substrate/frame/support/src/traits.rs b/substrate/frame/support/src/traits.rs index ea6e9aa2e9a8f..06e3bace2b2c3 100644 --- a/substrate/frame/support/src/traits.rs +++ b/substrate/frame/support/src/traits.rs @@ -28,8 +28,9 @@ pub use tokens::{ }, fungible, fungibles, imbalance::{Imbalance, OnUnbalanced, SignedImbalance}, - nonfungible, nonfungible_v2, nonfungibles, nonfungibles_v2, BalanceStatus, - ExistenceRequirement, Locker, WithdrawReasons, + nonfungible, nonfungible_v2, nonfungibles, nonfungibles_v2, + stable::{PsmInterface, VaultsInterface}, + BalanceStatus, ExistenceRequirement, Locker, WithdrawReasons, }; mod members; diff --git a/substrate/frame/support/src/traits/tokens.rs b/substrate/frame/support/src/traits/tokens.rs index be982cd31e33a..be32e1fada537 100644 --- a/substrate/frame/support/src/traits/tokens.rs +++ b/substrate/frame/support/src/traits/tokens.rs @@ -29,6 +29,7 @@ pub mod nonfungibles; pub mod nonfungibles_v2; pub use imbalance::Imbalance; pub mod pay; +pub mod stable; pub mod transfer; pub use misc::{ AssetId, Balance, BalanceStatus, ConversionFromAssetBalance, ConversionToAssetBalance, diff --git a/substrate/frame/support/src/traits/tokens/stable.rs b/substrate/frame/support/src/traits/tokens/stable.rs new file mode 100644 index 0000000000000..2771db0d55846 --- /dev/null +++ b/substrate/frame/support/src/traits/tokens/stable.rs @@ -0,0 +1,50 @@ +// 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. + +//! Traits for stablecoin inter-pallet communication. + +/// Trait for the PSM to query the Vaults pallet for system-wide debt ceiling. +/// +/// Implemented by the Vaults pallet, called by the PSM pallet when checking +/// whether a mint would exceed the maximum pUSD issuance. +pub trait VaultsInterface { + /// The balance type. + type Balance; + + /// Get the maximum allowed pUSD issuance across the entire system. + fn get_maximum_issuance() -> Self::Balance; +} + +/// Trait exposing the PSM pallet's reserved capacity to other pallets. +/// +/// Implemented by the PSM pallet, used by the Vaults pallet to account for +/// PSM-reserved debt ceiling when calculating available vault capacity. +pub trait PsmInterface { + /// The balance type. + type Balance; + + /// Get the amount of pUSD issuance capacity reserved by the PSM. + fn reserved_capacity() -> Self::Balance; +} + +impl PsmInterface for () { + type Balance = u128; + + fn reserved_capacity() -> Self::Balance { + 0 + } +} diff --git a/umbrella/Cargo.toml b/umbrella/Cargo.toml index 335ba43b3e2ac..9e8f670faf254 100644 --- a/umbrella/Cargo.toml +++ b/umbrella/Cargo.toml @@ -129,6 +129,7 @@ std = [ "pallet-people?/std", "pallet-preimage?/std", "pallet-proxy?/std", + "pallet-psm?/std", "pallet-ranked-collective?/std", "pallet-recovery?/std", "pallet-referenda?/std", @@ -319,6 +320,7 @@ runtime-benchmarks = [ "pallet-people?/runtime-benchmarks", "pallet-preimage?/runtime-benchmarks", "pallet-proxy?/runtime-benchmarks", + "pallet-psm?/runtime-benchmarks", "pallet-ranked-collective?/runtime-benchmarks", "pallet-recovery?/runtime-benchmarks", "pallet-referenda?/runtime-benchmarks", @@ -462,6 +464,7 @@ try-runtime = [ "pallet-people?/try-runtime", "pallet-preimage?/try-runtime", "pallet-proxy?/try-runtime", + "pallet-psm?/try-runtime", "pallet-ranked-collective?/try-runtime", "pallet-recovery?/try-runtime", "pallet-referenda?/try-runtime", @@ -682,6 +685,7 @@ runtime-full = [ "pallet-people", "pallet-preimage", "pallet-proxy", + "pallet-psm", "pallet-ranked-collective", "pallet-recovery", "pallet-referenda", @@ -1632,6 +1636,11 @@ default-features = false optional = true path = "../substrate/frame/preimage" +[dependencies.pallet-psm] +default-features = false +optional = true +path = "../substrate/frame/psm" + [dependencies.pallet-proxy] default-features = false optional = true diff --git a/umbrella/src/lib.rs b/umbrella/src/lib.rs index 14aa5033e74f8..83a10221f15a0 100644 --- a/umbrella/src/lib.rs +++ b/umbrella/src/lib.rs @@ -615,6 +615,10 @@ pub use pallet_people; #[cfg(feature = "pallet-preimage")] pub use pallet_preimage; +/// FRAME pallet for the Peg Stability Module. +#[cfg(feature = "pallet-psm")] +pub use pallet_psm; + /// FRAME proxying pallet. #[cfg(feature = "pallet-proxy")] pub use pallet_proxy; From eea3765fbd93193a9022f4c6ea7477a08f60bf5c Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Fri, 20 Mar 2026 11:55:31 +0000 Subject: [PATCH 02/32] chore: simplify the imports --- substrate/frame/psm/src/lib.rs | 4 +--- substrate/frame/psm/src/mock.rs | 5 +++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 36bb4728d53c6..40abd04dfc1a8 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -77,14 +77,12 @@ mod mock; #[cfg(test)] mod tests; -pub use frame_support::traits::PsmInterface; -pub(crate) use frame_support::traits::VaultsInterface; pub use pallet::*; pub use weights::WeightInfo; #[frame_support::pallet] pub mod pallet { - use super::VaultsInterface; + pub use frame_support::traits::tokens::stable::{PsmInterface, VaultsInterface}; use alloc::collections::btree_map::BTreeMap; use codec::DecodeWithMemTracking; diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs index cb0c040bf4407..c01675cdd2da0 100644 --- a/substrate/frame/psm/src/mock.rs +++ b/substrate/frame/psm/src/mock.rs @@ -140,8 +140,9 @@ impl EnsureOrigin for MockManagerOrigin { 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), + Ok(RawOrigin::Signed(who)) if who == EMERGENCY_ACCOUNT => { + Ok(crate::PsmManagerLevel::Emergency) + }, _ => Err(o), } } From 536e7c3dea731ea2a633e914e7fb43535256ed06 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Fri, 20 Mar 2026 12:07:31 +0000 Subject: [PATCH 03/32] feat: remove `StableAssetId` and use the new `fungible::Create` --- Cargo.lock | 4 ++-- substrate/bin/node/runtime/src/lib.rs | 2 -- substrate/frame/psm/src/benchmarking.rs | 13 ++++++------- substrate/frame/psm/src/lib.rs | 4 ---- substrate/frame/psm/src/mock.rs | 2 -- 5 files changed, 8 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1290b7ac49b86..8e5bc34973d13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13226,8 +13226,8 @@ dependencies = [ "pallet-balances", "parity-scale-codec", "scale-info", - "sp-io", - "sp-runtime", + "sp-io 30.0.0", + "sp-runtime 31.0.1", ] [[package]] diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index 71c6f4e8209ac..bc0d9d00e48b6 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -3117,8 +3117,6 @@ impl pallet_psm::Config for Runtime { type VaultsInterface = NoVaultsCeiling; type ManagerOrigin = EnsurePsmManager; type WeightInfo = pallet_psm::weights::SubstrateWeight; - #[cfg(feature = "runtime-benchmarks")] - type StableAssetId = PsmStablecoinAssetId; type StableAsset = PsmStableAsset; type FeeHandler = ResolveTo; type PalletId = PsmPalletId; diff --git a/substrate/frame/psm/src/benchmarking.rs b/substrate/frame/psm/src/benchmarking.rs index 00fd83e6edffd..6cf9e814a8600 100644 --- a/substrate/frame/psm/src/benchmarking.rs +++ b/substrate/frame/psm/src/benchmarking.rs @@ -23,6 +23,7 @@ use frame_benchmarking::v2::*; use frame_support::{ assert_ok, traits::{ + fungible::Create as FungibleCreate, fungibles::{ Create as FungiblesCreate, Inspect as FungiblesInspect, Mutate as FungiblesMutate, }, @@ -39,8 +40,8 @@ const ASSET_ID_OFFSET: u32 = 100; /// Set up `n` external assets ready for PSM benchmarks. /// -/// Creates the target asset (`ASSET_ID_OFFSET`) and the stable asset in -/// `T::Fungibles`, registers `n` external assets (`ASSET_ID_OFFSET..+n`), and +/// Creates the target asset (`ASSET_ID_OFFSET`) and the stable asset, +/// registers `n` external assets (`ASSET_ID_OFFSET..+n`), 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 @@ -49,6 +50,7 @@ const ASSET_ID_OFFSET: u32 = 100; fn setup_assets(n: u32) -> T::AssetId where T::Fungibles: FungiblesCreate, + T::StableAsset: FungibleCreate, T::AssetId: From, { let admin: T::AccountId = whitelisted_caller(); @@ -57,10 +59,7 @@ where if !T::Fungibles::asset_exists(target_id) { assert_ok!(T::Fungibles::create(target_id, admin.clone(), true, 1u32.into())); } - let stable_id = T::StableAssetId::get(); - if !T::Fungibles::asset_exists(stable_id) { - assert_ok!(T::Fungibles::create(stable_id, admin, true, 1u32.into())); - } + let _ = T::StableAsset::create(admin, true, 1u32.into()); crate::MaxPsmDebtOfTotal::::put(Permill::from_percent(100)); for i in 0..n { @@ -75,7 +74,7 @@ where target_id } -#[benchmarks(where T::Fungibles: FungiblesCreate, T::AssetId: From)] +#[benchmarks(where T::Fungibles: FungiblesCreate, T::StableAsset: FungibleCreate, T::AssetId: From)] mod benchmarks { use super::*; diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 40abd04dfc1a8..d6cc9a8d1ea78 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -190,10 +190,6 @@ pub mod pallet { /// A type representing the weights required by the dispatchables of this pallet. type WeightInfo: WeightInfo; - /// The stable asset ID, needed by benchmarks to create it via `T::Fungibles`. - #[cfg(feature = "runtime-benchmarks")] - type StableAssetId: Get; - /// The pUSD asset as a single-asset `fungible` type. /// /// Typically `ItemOf`. diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs index c01675cdd2da0..5fb5394008248 100644 --- a/substrate/frame/psm/src/mock.rs +++ b/substrate/frame/psm/src/mock.rs @@ -159,8 +159,6 @@ impl crate::Config for Test { type VaultsInterface = MockVaultsInterface; type ManagerOrigin = MockManagerOrigin; type WeightInfo = (); - #[cfg(feature = "runtime-benchmarks")] - type StableAssetId = StablecoinAssetId; type StableAsset = frame_support::traits::fungible::ItemOf; type FeeHandler = ResolveTo; type PalletId = PsmPalletId; From 3138b932f116afffe3456b599c527318dd600c1a Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Fri, 20 Mar 2026 13:15:46 +0000 Subject: [PATCH 04/32] doc: align the README to the latest changes --- substrate/frame/psm/README.md | 56 ++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/substrate/frame/psm/README.md b/substrate/frame/psm/README.md index 8e6f60a652894..cc53f7f943b05 100644 --- a/substrate/frame/psm/README.md +++ b/substrate/frame/psm/README.md @@ -8,6 +8,7 @@ The PSM pallet allows users to swap external stablecoins (e.g., USDC, USDT) for - **Reserves are held**: External stablecoins are held in a pallet-derived account (`PalletId`) - **pUSD is minted/burned**: Users receive pUSD when depositing external stablecoins, and burn pUSD when redeeming +- **Fees are routed via `FeeHandler`**: Mint and redeem fees are collected in pUSD and dispatched through a configurable handler - **Circuit breaker provides emergency control**: Per-asset circuit breaker can disable minting or all swaps ## Swap Lifecycle @@ -18,7 +19,7 @@ mint(origin, asset_id, external_amount) ``` - Deposits external stablecoin into the PSM account - Mints pUSD to the user (minus minting fee) -- Fee is minted as pUSD to the Insurance Fund +- Fee is issued as pUSD credit and routed through `FeeHandler` - Enforces three-tier debt ceiling: system-wide, aggregate PSM, and per-asset - Requires `external_amount >= MinSwapAmount` @@ -26,9 +27,9 @@ mint(origin, asset_id, external_amount) ```rust redeem(origin, asset_id, pusd_amount) ``` -- Burns pUSD from the user (minus redemption fee) +- Burns pUSD from the user equal to the external amount being redeemed - Transfers external stablecoin from PSM account to user -- Fee is transferred as pUSD from user to Insurance Fund +- Redemption fee is withdrawn from the user as pUSD credit and routed through `FeeHandler` - Limited by tracked PSM debt (not raw reserve balance) - Requires `pusd_amount >= MinSwapAmount` @@ -56,10 +57,10 @@ Setting an asset's weight to 0% disables minting and redistributes its capacity ## Fee Structure -Fees are calculated using `Permill::mul_ceil` (rounds up): +Fees are calculated using `Permill::mul_ceil` (rounds up) and handled as pUSD credits via `FeeHandler`: -- **Minting Fee**: `fee = MintingFee[asset_id].mul_ceil(external_amount)` -- deducted from pUSD output, minted to Insurance Fund -- **Redemption Fee**: `fee = RedemptionFee[asset_id].mul_ceil(pusd_amount)` -- transferred as pUSD from user to Insurance Fund +- **Minting Fee**: `fee = MintingFee[asset_id].mul_ceil(external_amount)` -- deducted from pUSD output, issued as pUSD credit, and passed to `FeeHandler` +- **Redemption Fee**: `fee = RedemptionFee[asset_id].mul_ceil(pusd_amount)` -- withdrawn from the user as pUSD credit and passed to `FeeHandler` With 0.5% fees on both sides, arbitrage opportunities exist when pUSD trades outside $0.995-$1.005. @@ -84,7 +85,7 @@ The `set_asset_status` extrinsic can be called by both `GeneralAdmin` and `Emerg | `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 (defaults to AllEnabled) | +| `add_external_asset(asset_id)` | Full | Add approved stablecoin with matching decimals; defaults to `AllEnabled` | | `remove_external_asset(asset_id)` | Full | Remove approved stablecoin (requires zero debt) | ### Privilege Levels @@ -99,22 +100,37 @@ The `ManagerOrigin` returns a privilege level: 2. Redemptions slowly drain remaining PSM debt 3. Once `PsmDebt[asset_id]` reaches zero, call `remove_external_asset(asset_id)` +### Asset Onboarding Requirements + +Before calling `add_external_asset(asset_id)`: + +- The asset must already exist in the `Fungibles` implementation +- The asset's decimals must match `StableAsset::decimals()` +- The pallet must still be below `MaxExternalAssets` + ## Configuration ```rust impl pallet_psm::Config for Runtime { - type Asset = Assets; // Fungibles impl for pUSD and external stablecoins - type AssetId = u32; // Asset identifier type - type VaultsInterface = Vaults; // Interface to query MaximumIssuance from Vaults - type ManagerOrigin = EnsurePsmManager; // Governance origin (returns privilege level) + type Fungibles = Assets; + type AssetId = u32; + type VaultsInterface = Vaults; + type ManagerOrigin = EnsurePsmManager; type WeightInfo = weights::SubstrateWeight; - type StablecoinAssetId = StablecoinAssetId; // Constant: pUSD asset ID - type InsuranceFund = InsuranceFundAccount; // Account receiving fee revenue - type PalletId = PsmPalletId; // For deriving PSM account address - type MinSwapAmount = MinSwapAmount; // Minimum swap amount (prevents dust) + type StableAsset = frame_support::traits::fungible::ItemOf< + Assets, + StablecoinAssetId, + AccountId, + >; + type FeeHandler = ResolveTo; + type PalletId = PsmPalletId; + type MinSwapAmount = MinSwapAmount; + type MaxExternalAssets = ConstU32<10>; } ``` +`Fungibles` must expose metadata for approved assets, and `StableAsset` must expose metadata for the pUSD asset because `add_external_asset` validates that decimals match before approval. + ### Parameters (Set via Governance) | Parameter | Description | Suggested Value | @@ -126,10 +142,14 @@ impl pallet_psm::Config for Runtime { ### Required Constants -- `StablecoinAssetId`: The asset ID for pUSD -- `InsuranceFund`: Account that receives fee revenue (shared with pallet-vaults) - `PalletId`: Unique identifier for deriving the PSM account - `MinSwapAmount`: Minimum amount for any swap (default: 100 pUSD) +- `MaxExternalAssets`: Maximum number of approved external assets + +Typical runtime helpers used in the configuration above: + +- `StablecoinAssetId`: Runtime constant used by `ItemOf<..., StablecoinAssetId, ...>` to bind `StableAsset` to pUSD +- `InsuranceFundAccount`: Example fee destination account when using `ResolveTo` ## Events @@ -156,6 +176,8 @@ impl pallet_psm::Config for Runtime { - `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 stable asset decimals ## Testing From 1eca095c41be5d37832c9f38aa4df75551e2c135 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Mon, 23 Mar 2026 14:26:15 +0000 Subject: [PATCH 05/32] chore: check if min balance > 0 --- substrate/frame/psm/src/benchmarking.rs | 6 +- substrate/frame/psm/src/weights.rs | 94 ++++++++++++------------- 2 files changed, 51 insertions(+), 49 deletions(-) diff --git a/substrate/frame/psm/src/benchmarking.rs b/substrate/frame/psm/src/benchmarking.rs index 6cf9e814a8600..d0212cf29f3f6 100644 --- a/substrate/frame/psm/src/benchmarking.rs +++ b/substrate/frame/psm/src/benchmarking.rs @@ -32,7 +32,7 @@ use frame_support::{ }; use frame_system::RawOrigin; use pallet::BalanceOf; -use sp_runtime::{Permill, Saturating}; +use sp_runtime::{traits::Zero, Permill, Saturating}; /// Offset for benchmark asset IDs, chosen to avoid collision with typical /// genesis asset IDs (e.g. stable asset ID = 1). @@ -59,7 +59,9 @@ where if !T::Fungibles::asset_exists(target_id) { assert_ok!(T::Fungibles::create(target_id, admin.clone(), true, 1u32.into())); } - let _ = T::StableAsset::create(admin, true, 1u32.into()); + let min_balance: BalanceOf = 1u32.into(); + assert!(min_balance > BalanceOf::::zero()); + let _ = T::StableAsset::create(admin, true, min_balance); crate::MaxPsmDebtOfTotal::::put(Permill::from_percent(100)); for i in 0..n { diff --git a/substrate/frame/psm/src/weights.rs b/substrate/frame/psm/src/weights.rs index dbda9bdc0cfd7..43f1f25158177 100644 --- a/substrate/frame/psm/src/weights.rs +++ b/substrate/frame/psm/src/weights.rs @@ -18,7 +18,7 @@ //! Autogenerated weights for `pallet_psm` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 -//! DATE: 2026-03-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-23, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `zur1-vm-devleo-001`, CPU: `AMD EPYC 9354 32-Core Processor` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -92,12 +92,12 @@ impl WeightInfo for SubstrateWeight { /// The range of component `n` is `[1, 10]`. fn mint(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1225 + n * (88 ±0)` + // Measured: `1262 + n * (88 ±0)` // Estimated: `11426 + n * (2511 ±0)` - // Minimum execution time: 136_570_000 picoseconds. - Weight::from_parts(135_536_770, 11426) - // Standard Error: 88_404 - .saturating_add(Weight::from_parts(8_859_854, 0).saturating_mul(n.into())) + // Minimum execution time: 135_770_000 picoseconds. + Weight::from_parts(137_196_343, 11426) + // Standard Error: 16_018 + .saturating_add(Weight::from_parts(7_982_951, 0).saturating_mul(n.into())) .saturating_add(T::DbWeight::get().reads(13_u64)) .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) .saturating_add(T::DbWeight::get().writes(9_u64)) @@ -117,8 +117,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1436` // Estimated: `11426` - // Minimum execution time: 137_720_000 picoseconds. - Weight::from_parts(145_381_000, 11426) + // Minimum execution time: 134_139_000 picoseconds. + Weight::from_parts(139_710_000, 11426) .saturating_add(T::DbWeight::get().reads(9_u64)) .saturating_add(T::DbWeight::get().writes(7_u64)) } @@ -128,8 +128,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `373` // Estimated: `3489` - // Minimum execution time: 17_030_000 picoseconds. - Weight::from_parts(20_569_000, 3489) + // Minimum execution time: 12_630_000 picoseconds. + Weight::from_parts(14_129_000, 3489) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -139,8 +139,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `373` // Estimated: `3489` - // Minimum execution time: 16_040_000 picoseconds. - Weight::from_parts(19_499_000, 3489) + // Minimum execution time: 12_540_000 picoseconds. + Weight::from_parts(14_240_000, 3489) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -150,8 +150,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `306` // Estimated: `1489` - // Minimum execution time: 10_680_000 picoseconds. - Weight::from_parts(16_101_000, 1489) + // Minimum execution time: 9_459_000 picoseconds. + Weight::from_parts(10_769_000, 1489) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -161,8 +161,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `382` // Estimated: `3486` - // Minimum execution time: 16_890_000 picoseconds. - Weight::from_parts(18_619_000, 3486) + // Minimum execution time: 14_820_000 picoseconds. + Weight::from_parts(16_810_000, 3486) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -174,8 +174,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `431` // Estimated: `3489` - // Minimum execution time: 19_960_000 picoseconds. - Weight::from_parts(21_880_000, 3489) + // Minimum execution time: 18_350_000 picoseconds. + Weight::from_parts(20_930_000, 3489) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -186,10 +186,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `326 + n * (25 ±0)` // Estimated: `5982 + n * (2496 ±0)` - // Minimum execution time: 16_899_000 picoseconds. - Weight::from_parts(20_232_914, 5982) - // Standard Error: 20_196 - .saturating_add(Weight::from_parts(1_654_666, 0).saturating_mul(n.into())) + // Minimum execution time: 15_691_000 picoseconds. + Weight::from_parts(18_381_252, 5982) + // Standard Error: 2_204 + .saturating_add(Weight::from_parts(1_711_426, 0).saturating_mul(n.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(n.into()))) .saturating_add(T::DbWeight::get().writes(1_u64)) @@ -209,8 +209,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `394` // Estimated: `3501` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(26_570_000, 3501) + // Minimum execution time: 23_380_000 picoseconds. + Weight::from_parts(25_680_000, 3501) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(5_u64)) } @@ -237,12 +237,12 @@ impl WeightInfo for () { /// The range of component `n` is `[1, 10]`. fn mint(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1225 + n * (88 ±0)` + // Measured: `1262 + n * (88 ±0)` // Estimated: `11426 + n * (2511 ±0)` - // Minimum execution time: 136_570_000 picoseconds. - Weight::from_parts(135_536_770, 11426) - // Standard Error: 88_404 - .saturating_add(Weight::from_parts(8_859_854, 0).saturating_mul(n.into())) + // Minimum execution time: 135_770_000 picoseconds. + Weight::from_parts(137_196_343, 11426) + // Standard Error: 16_018 + .saturating_add(Weight::from_parts(7_982_951, 0).saturating_mul(n.into())) .saturating_add(RocksDbWeight::get().reads(13_u64)) .saturating_add(RocksDbWeight::get().reads((2_u64).saturating_mul(n.into()))) .saturating_add(RocksDbWeight::get().writes(9_u64)) @@ -262,8 +262,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1436` // Estimated: `11426` - // Minimum execution time: 137_720_000 picoseconds. - Weight::from_parts(145_381_000, 11426) + // Minimum execution time: 134_139_000 picoseconds. + Weight::from_parts(139_710_000, 11426) .saturating_add(RocksDbWeight::get().reads(9_u64)) .saturating_add(RocksDbWeight::get().writes(7_u64)) } @@ -273,8 +273,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `373` // Estimated: `3489` - // Minimum execution time: 17_030_000 picoseconds. - Weight::from_parts(20_569_000, 3489) + // Minimum execution time: 12_630_000 picoseconds. + Weight::from_parts(14_129_000, 3489) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -284,8 +284,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `373` // Estimated: `3489` - // Minimum execution time: 16_040_000 picoseconds. - Weight::from_parts(19_499_000, 3489) + // Minimum execution time: 12_540_000 picoseconds. + Weight::from_parts(14_240_000, 3489) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -295,8 +295,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `306` // Estimated: `1489` - // Minimum execution time: 10_680_000 picoseconds. - Weight::from_parts(16_101_000, 1489) + // Minimum execution time: 9_459_000 picoseconds. + Weight::from_parts(10_769_000, 1489) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -306,8 +306,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `382` // Estimated: `3486` - // Minimum execution time: 16_890_000 picoseconds. - Weight::from_parts(18_619_000, 3486) + // Minimum execution time: 14_820_000 picoseconds. + Weight::from_parts(16_810_000, 3486) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -319,8 +319,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `431` // Estimated: `3489` - // Minimum execution time: 19_960_000 picoseconds. - Weight::from_parts(21_880_000, 3489) + // Minimum execution time: 18_350_000 picoseconds. + Weight::from_parts(20_930_000, 3489) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -331,10 +331,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `326 + n * (25 ±0)` // Estimated: `5982 + n * (2496 ±0)` - // Minimum execution time: 16_899_000 picoseconds. - Weight::from_parts(20_232_914, 5982) - // Standard Error: 20_196 - .saturating_add(Weight::from_parts(1_654_666, 0).saturating_mul(n.into())) + // Minimum execution time: 15_691_000 picoseconds. + Weight::from_parts(18_381_252, 5982) + // Standard Error: 2_204 + .saturating_add(Weight::from_parts(1_711_426, 0).saturating_mul(n.into())) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(n.into()))) .saturating_add(RocksDbWeight::get().writes(1_u64)) @@ -354,8 +354,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `394` // Estimated: `3501` - // Minimum execution time: 25_000_000 picoseconds. - Weight::from_parts(26_570_000, 3501) + // Minimum execution time: 23_380_000 picoseconds. + Weight::from_parts(25_680_000, 3501) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(5_u64)) } From 26f3b9a2bd5bc25b9e79449df18f2b2896a5bb0f Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Mon, 23 Mar 2026 14:29:10 +0000 Subject: [PATCH 06/32] chore: add prdoc --- prdoc/pr_11068.prdoc | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 prdoc/pr_11068.prdoc diff --git a/prdoc/pr_11068.prdoc b/prdoc/pr_11068.prdoc new file mode 100644 index 0000000000000..6c6dfcc52bb1c --- /dev/null +++ b/prdoc/pr_11068.prdoc @@ -0,0 +1,26 @@ +title: 'FRAME: Add Peg Stability Module (PSM) pallet' +doc: +- audience: Runtime Dev + description: |- + Introduces `pallet-psm`, a Peg Stability Module that enables 1:1 swaps between a native + stablecoin (pUSD) and pre-approved external stablecoins (e.g. USDC, USDT). The PSM + strengthens the stablecoin peg by creating arbitrage opportunities bounded by configurable + minting and redemption fees. + + Key features: + - Minting: deposit an external stablecoin to receive pUSD + - Redemption: burn pUSD to receive an external stablecoin + - Per-asset circuit breakers to disable minting or all swaps + - Configurable ceiling weights and maximum PSM debt ratio + - Inter-pallet traits (`VaultsInterface`, `PsmInterface`) for coordinating debt ceilings + with a companion Vaults pallet + +crates: +- name: pallet-psm + bump: major +- name: kitchensink-runtime + bump: major +- name: frame-support + bump: minor +- name: polkadot-sdk + bump: minor From 241e839dd878531062b3a72386ff901e0477dc44 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 24 Mar 2026 15:40:29 +0000 Subject: [PATCH 07/32] feat: re-introduce the `BenchmarkHelper` --- substrate/bin/node/runtime/src/lib.rs | 29 +++- substrate/frame/psm/src/benchmarking.rs | 25 ++-- substrate/frame/psm/src/lib.rs | 15 ++ substrate/frame/psm/src/mock.rs | 25 +++- substrate/frame/psm/src/weights.rs | 174 ++++++++++++------------ 5 files changed, 163 insertions(+), 105 deletions(-) diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index bc0d9d00e48b6..4e6ccbd9f9b5b 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -3110,6 +3110,31 @@ impl frame_support::traits::EnsureOrigin for EnsurePsmManager { } } +#[cfg(feature = "runtime-benchmarks")] +pub struct PsmBenchmarkHelper; +#[cfg(feature = "runtime-benchmarks")] +impl pallet_psm::BenchmarkHelper for PsmBenchmarkHelper { + fn create_asset(asset_id: u32, owner: &AccountId, decimals: u8) { + use frame_support::traits::fungibles::{metadata::Mutate as MetadataMutate, Create}; + if !>::asset_exists(asset_id) + { + let _ = >::create(asset_id, owner.clone(), true, 1); + } + let _ = Balances::force_set_balance( + RuntimeOrigin::root(), + owner.clone().into(), + 10u128.pow(18), + ); + let _ = >::set( + asset_id, + owner, + b"Benchmark".to_vec(), + b"BNC".to_vec(), + decimals, + ); + } +} + /// Configure the PSM (Peg Stability Module) pallet. impl pallet_psm::Config for Runtime { type Fungibles = Assets; @@ -3118,10 +3143,12 @@ impl pallet_psm::Config for Runtime { type ManagerOrigin = EnsurePsmManager; type WeightInfo = pallet_psm::weights::SubstrateWeight; type StableAsset = PsmStableAsset; - type FeeHandler = ResolveTo; + type FeeDestination = PsmInsuranceFundAccount; type PalletId = PsmPalletId; type MinSwapAmount = PsmMinSwapAmount; type MaxExternalAssets = ConstU32<10>; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = PsmBenchmarkHelper; } /// MMR helper types. diff --git a/substrate/frame/psm/src/benchmarking.rs b/substrate/frame/psm/src/benchmarking.rs index d0212cf29f3f6..9484a47817eea 100644 --- a/substrate/frame/psm/src/benchmarking.rs +++ b/substrate/frame/psm/src/benchmarking.rs @@ -23,7 +23,7 @@ use frame_benchmarking::v2::*; use frame_support::{ assert_ok, traits::{ - fungible::Create as FungibleCreate, + fungible::{metadata::Inspect, Create as FungibleCreate, Inspect as FungibleInspect}, fungibles::{ Create as FungiblesCreate, Inspect as FungiblesInspect, Mutate as FungiblesMutate, }, @@ -59,9 +59,9 @@ where if !T::Fungibles::asset_exists(target_id) { assert_ok!(T::Fungibles::create(target_id, admin.clone(), true, 1u32.into())); } - let min_balance: BalanceOf = 1u32.into(); - assert!(min_balance > BalanceOf::::zero()); - let _ = T::StableAsset::create(admin, true, min_balance); + if T::StableAsset::minimum_balance().is_zero() { + let _ = T::StableAsset::create(admin, true, 1u32.into()); + } crate::MaxPsmDebtOfTotal::::put(Permill::from_percent(100)); for i in 0..n { @@ -182,21 +182,12 @@ mod benchmarks { assert_eq!(crate::AssetCeilingWeight::::get(asset_id), new_weight); Ok(()) } - - /// Linear in `n`. The number of already-registered assets, because - /// the extrinsic calls `ExternalAssets::iter_keys().count()`. #[benchmark] - fn add_external_asset( - n: Linear<0, { T::MaxExternalAssets::get() - 1 }>, - ) -> Result<(), BenchmarkError> { - let _ = crate::ExternalAssets::::clear(u32::MAX, None); - - for i in 0..n { - let id: T::AssetId = (ASSET_ID_OFFSET + i).into(); - crate::ExternalAssets::::insert(id, CircuitBreakerLevel::AllEnabled); - } + fn add_external_asset() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + let new_asset_id: T::AssetId = ASSET_ID_OFFSET.into(); - let new_asset_id: T::AssetId = (ASSET_ID_OFFSET + n).into(); + T::BenchmarkHelper::create_asset(new_asset_id, &caller, T::StableAsset::decimals()); #[extrinsic_call] _(RawOrigin::Root, new_asset_id); diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index d6cc9a8d1ea78..dc4dcbbd6f9d5 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -80,6 +80,17 @@ mod tests; pub use pallet::*; pub use weights::WeightInfo; +/// Helper trait for benchmark setup. +/// +/// Provides a way to create an external asset with the correct metadata (decimals) +/// for benchmarks, abstracting over the deposit requirements of the underlying +/// asset pallet. +#[cfg(feature = "runtime-benchmarks")] +pub trait BenchmarkHelper { + /// Create an asset with metadata matching the stable asset's decimals. + fn create_asset(asset_id: AssetId, owner: &AccountId, decimals: u8); +} + #[frame_support::pallet] pub mod pallet { pub use frame_support::traits::tokens::stable::{PsmInterface, VaultsInterface}; @@ -214,6 +225,10 @@ pub mod pallet { /// Maximum number of approved external assets. #[pallet::constant] type MaxExternalAssets: Get; + + /// Helper for benchmarks to create an external asset with correct metadata. + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper: crate::BenchmarkHelper; } /// The in-code storage version. diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs index 5fb5394008248..26573567747f2 100644 --- a/substrate/frame/psm/src/mock.rs +++ b/substrate/frame/psm/src/mock.rs @@ -153,6 +153,27 @@ impl EnsureOrigin for MockManagerOrigin { } } +#[cfg(feature = "runtime-benchmarks")] +pub struct PsmBenchmarkHelper; +#[cfg(feature = "runtime-benchmarks")] +impl crate::BenchmarkHelper for PsmBenchmarkHelper { + fn create_asset(asset_id: u32, owner: &u64, decimals: u8) { + use frame_support::traits::fungibles::{metadata::Mutate as MetadataMutate, Create}; + if !>::asset_exists(asset_id) { + let _ = >::create(asset_id, *owner, true, 1); + } + // Fund the owner's native balance so they can pay the metadata deposit. + let _ = Balances::force_set_balance(RuntimeOrigin::root(), *owner, INITIAL_BALANCE); + let _ = >::set( + asset_id, + owner, + b"Benchmark".to_vec(), + b"BNC".to_vec(), + decimals, + ); + } +} + impl crate::Config for Test { type Fungibles = Assets; type AssetId = u32; @@ -160,10 +181,12 @@ impl crate::Config for Test { type ManagerOrigin = MockManagerOrigin; type WeightInfo = (); type StableAsset = frame_support::traits::fungible::ItemOf; - type FeeHandler = ResolveTo; + type FeeDestination = InsuranceFundAccount; type PalletId = PsmPalletId; type MinSwapAmount = MinSwapAmount; type MaxExternalAssets = ConstU32<10>; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = PsmBenchmarkHelper; } pub fn new_test_ext() -> TestState { diff --git a/substrate/frame/psm/src/weights.rs b/substrate/frame/psm/src/weights.rs index 43f1f25158177..54b11c97c316f 100644 --- a/substrate/frame/psm/src/weights.rs +++ b/substrate/frame/psm/src/weights.rs @@ -18,7 +18,7 @@ //! Autogenerated weights for `pallet_psm` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 32.0.0 -//! DATE: 2026-03-23, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-03-24, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `zur1-vm-devleo-001`, CPU: `AMD EPYC 9354 32-Core Processor` //! WASM-EXECUTION: `Compiled`, CHAIN: `Some("dev")`, DB CACHE: `1024` @@ -66,7 +66,7 @@ pub trait WeightInfo { fn set_max_psm_debt() -> Weight; fn set_asset_status() -> Weight; fn set_asset_ceiling_weight() -> Weight; - fn add_external_asset(n: u32, ) -> Weight; + fn add_external_asset() -> Weight; fn remove_external_asset() -> Weight; } @@ -75,6 +75,8 @@ pub struct SubstrateWeight(PhantomData); impl WeightInfo for SubstrateWeight { /// Storage: `Psm::ExternalAssets` (r:1 w:0) /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Psm::MintingFee` (r:1 w:0) + /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) /// Storage: `Assets::Asset` (r:2 w:2) /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) /// Storage: `Psm::PsmDebt` (r:11 w:1) @@ -87,17 +89,15 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Psm::MintingFee` (r:1 w:0) - /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) /// The range of component `n` is `[1, 10]`. fn mint(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1262 + n * (88 ±0)` + // Measured: `1387 + n * (88 ±0)` // Estimated: `11426 + n * (2511 ±0)` - // Minimum execution time: 135_770_000 picoseconds. - Weight::from_parts(137_196_343, 11426) - // Standard Error: 16_018 - .saturating_add(Weight::from_parts(7_982_951, 0).saturating_mul(n.into())) + // Minimum execution time: 133_370_000 picoseconds. + Weight::from_parts(134_621_616, 11426) + // Standard Error: 20_624 + .saturating_add(Weight::from_parts(8_263_331, 0).saturating_mul(n.into())) .saturating_add(T::DbWeight::get().reads(13_u64)) .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) .saturating_add(T::DbWeight::get().writes(9_u64)) @@ -115,10 +115,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) fn redeem() -> Weight { // Proof Size summary in bytes: - // Measured: `1436` + // Measured: `1459` // Estimated: `11426` - // Minimum execution time: 134_139_000 picoseconds. - Weight::from_parts(139_710_000, 11426) + // Minimum execution time: 137_370_000 picoseconds. + Weight::from_parts(145_389_000, 11426) .saturating_add(T::DbWeight::get().reads(9_u64)) .saturating_add(T::DbWeight::get().writes(7_u64)) } @@ -126,10 +126,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) fn set_minting_fee() -> Weight { // Proof Size summary in bytes: - // Measured: `373` + // Measured: `396` // Estimated: `3489` - // Minimum execution time: 12_630_000 picoseconds. - Weight::from_parts(14_129_000, 3489) + // Minimum execution time: 14_810_000 picoseconds. + Weight::from_parts(16_760_000, 3489) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -137,10 +137,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) fn set_redemption_fee() -> Weight { // Proof Size summary in bytes: - // Measured: `373` + // Measured: `396` // Estimated: `3489` - // Minimum execution time: 12_540_000 picoseconds. - Weight::from_parts(14_240_000, 3489) + // Minimum execution time: 14_910_000 picoseconds. + Weight::from_parts(16_661_000, 3489) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -150,8 +150,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `306` // Estimated: `1489` - // Minimum execution time: 9_459_000 picoseconds. - Weight::from_parts(10_769_000, 1489) + // Minimum execution time: 9_990_000 picoseconds. + Weight::from_parts(11_200_000, 1489) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -159,10 +159,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) fn set_asset_status() -> Weight { // Proof Size summary in bytes: - // Measured: `382` + // Measured: `405` // Estimated: `3486` - // Minimum execution time: 14_820_000 picoseconds. - Weight::from_parts(16_810_000, 3486) + // Minimum execution time: 17_270_000 picoseconds. + Weight::from_parts(19_160_000, 3486) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -172,33 +172,34 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) fn set_asset_ceiling_weight() -> Weight { // Proof Size summary in bytes: - // Measured: `431` + // Measured: `454` // Estimated: `3489` - // Minimum execution time: 18_350_000 picoseconds. - Weight::from_parts(20_930_000, 3489) + // Minimum execution time: 19_381_000 picoseconds. + Weight::from_parts(21_310_000, 3489) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } - /// Storage: `Psm::ExternalAssets` (r:11 w:1) + /// Storage: `Psm::ExternalAssets` (r:1 w:1) /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) - /// The range of component `n` is `[0, 9]`. - fn add_external_asset(n: u32, ) -> Weight { + /// Storage: `Psm::CounterForExternalAssets` (r:1 w:1) + /// Proof: `Psm::CounterForExternalAssets` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Assets::Metadata` (r:2 w:0) + /// Proof: `Assets::Metadata` (`max_values`: None, `max_size`: Some(140), added: 2615, mode: `MaxEncodedLen`) + fn add_external_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `326 + n * (25 ±0)` - // Estimated: `5982 + n * (2496 ±0)` - // Minimum execution time: 15_691_000 picoseconds. - Weight::from_parts(18_381_252, 5982) - // Standard Error: 2_204 - .saturating_add(Weight::from_parts(1_711_426, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(n.into()))) - .saturating_add(T::DbWeight::get().writes(1_u64)) - .saturating_add(Weight::from_parts(0, 2496).saturating_mul(n.into())) + // Measured: `560` + // Estimated: `6220` + // Minimum execution time: 24_990_000 picoseconds. + Weight::from_parts(27_370_000, 6220) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `Psm::ExternalAssets` (r:1 w:1) /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) /// Storage: `Psm::PsmDebt` (r:1 w:1) /// Proof: `Psm::PsmDebt` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `Psm::CounterForExternalAssets` (r:1 w:1) + /// Proof: `Psm::CounterForExternalAssets` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `Psm::AssetCeilingWeight` (r:0 w:1) /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) /// Storage: `Psm::MintingFee` (r:0 w:1) @@ -207,12 +208,12 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) fn remove_external_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `394` + // Measured: `417` // Estimated: `3501` - // Minimum execution time: 23_380_000 picoseconds. - Weight::from_parts(25_680_000, 3501) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().writes(5_u64)) + // Minimum execution time: 27_390_000 picoseconds. + Weight::from_parts(29_621_000, 3501) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) } } @@ -220,6 +221,8 @@ impl WeightInfo for SubstrateWeight { impl WeightInfo for () { /// Storage: `Psm::ExternalAssets` (r:1 w:0) /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) + /// Storage: `Psm::MintingFee` (r:1 w:0) + /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) /// Storage: `Assets::Asset` (r:2 w:2) /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) /// Storage: `Psm::PsmDebt` (r:11 w:1) @@ -232,17 +235,15 @@ impl WeightInfo for () { /// Proof: `Assets::Account` (`max_values`: None, `max_size`: Some(134), added: 2609, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Psm::MintingFee` (r:1 w:0) - /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) /// The range of component `n` is `[1, 10]`. fn mint(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1262 + n * (88 ±0)` + // Measured: `1387 + n * (88 ±0)` // Estimated: `11426 + n * (2511 ±0)` - // Minimum execution time: 135_770_000 picoseconds. - Weight::from_parts(137_196_343, 11426) - // Standard Error: 16_018 - .saturating_add(Weight::from_parts(7_982_951, 0).saturating_mul(n.into())) + // Minimum execution time: 133_370_000 picoseconds. + Weight::from_parts(134_621_616, 11426) + // Standard Error: 20_624 + .saturating_add(Weight::from_parts(8_263_331, 0).saturating_mul(n.into())) .saturating_add(RocksDbWeight::get().reads(13_u64)) .saturating_add(RocksDbWeight::get().reads((2_u64).saturating_mul(n.into()))) .saturating_add(RocksDbWeight::get().writes(9_u64)) @@ -260,10 +261,10 @@ impl WeightInfo for () { /// Proof: `Assets::Asset` (`max_values`: None, `max_size`: Some(210), added: 2685, mode: `MaxEncodedLen`) fn redeem() -> Weight { // Proof Size summary in bytes: - // Measured: `1436` + // Measured: `1459` // Estimated: `11426` - // Minimum execution time: 134_139_000 picoseconds. - Weight::from_parts(139_710_000, 11426) + // Minimum execution time: 137_370_000 picoseconds. + Weight::from_parts(145_389_000, 11426) .saturating_add(RocksDbWeight::get().reads(9_u64)) .saturating_add(RocksDbWeight::get().writes(7_u64)) } @@ -271,10 +272,10 @@ impl WeightInfo for () { /// Proof: `Psm::MintingFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) fn set_minting_fee() -> Weight { // Proof Size summary in bytes: - // Measured: `373` + // Measured: `396` // Estimated: `3489` - // Minimum execution time: 12_630_000 picoseconds. - Weight::from_parts(14_129_000, 3489) + // Minimum execution time: 14_810_000 picoseconds. + Weight::from_parts(16_760_000, 3489) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -282,10 +283,10 @@ impl WeightInfo for () { /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) fn set_redemption_fee() -> Weight { // Proof Size summary in bytes: - // Measured: `373` + // Measured: `396` // Estimated: `3489` - // Minimum execution time: 12_540_000 picoseconds. - Weight::from_parts(14_240_000, 3489) + // Minimum execution time: 14_910_000 picoseconds. + Weight::from_parts(16_661_000, 3489) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -295,8 +296,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `306` // Estimated: `1489` - // Minimum execution time: 9_459_000 picoseconds. - Weight::from_parts(10_769_000, 1489) + // Minimum execution time: 9_990_000 picoseconds. + Weight::from_parts(11_200_000, 1489) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -304,10 +305,10 @@ impl WeightInfo for () { /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) fn set_asset_status() -> Weight { // Proof Size summary in bytes: - // Measured: `382` + // Measured: `405` // Estimated: `3486` - // Minimum execution time: 14_820_000 picoseconds. - Weight::from_parts(16_810_000, 3486) + // Minimum execution time: 17_270_000 picoseconds. + Weight::from_parts(19_160_000, 3486) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } @@ -317,33 +318,34 @@ impl WeightInfo for () { /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) fn set_asset_ceiling_weight() -> Weight { // Proof Size summary in bytes: - // Measured: `431` + // Measured: `454` // Estimated: `3489` - // Minimum execution time: 18_350_000 picoseconds. - Weight::from_parts(20_930_000, 3489) + // Minimum execution time: 19_381_000 picoseconds. + Weight::from_parts(21_310_000, 3489) .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } - /// Storage: `Psm::ExternalAssets` (r:11 w:1) + /// Storage: `Psm::ExternalAssets` (r:1 w:1) /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) - /// The range of component `n` is `[0, 9]`. - fn add_external_asset(n: u32, ) -> Weight { + /// Storage: `Psm::CounterForExternalAssets` (r:1 w:1) + /// Proof: `Psm::CounterForExternalAssets` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Assets::Metadata` (r:2 w:0) + /// Proof: `Assets::Metadata` (`max_values`: None, `max_size`: Some(140), added: 2615, mode: `MaxEncodedLen`) + fn add_external_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `326 + n * (25 ±0)` - // Estimated: `5982 + n * (2496 ±0)` - // Minimum execution time: 15_691_000 picoseconds. - Weight::from_parts(18_381_252, 5982) - // Standard Error: 2_204 - .saturating_add(Weight::from_parts(1_711_426, 0).saturating_mul(n.into())) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(n.into()))) - .saturating_add(RocksDbWeight::get().writes(1_u64)) - .saturating_add(Weight::from_parts(0, 2496).saturating_mul(n.into())) + // Measured: `560` + // Estimated: `6220` + // Minimum execution time: 24_990_000 picoseconds. + Weight::from_parts(27_370_000, 6220) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) } /// Storage: `Psm::ExternalAssets` (r:1 w:1) /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, mode: `MaxEncodedLen`) /// Storage: `Psm::PsmDebt` (r:1 w:1) /// Proof: `Psm::PsmDebt` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `Psm::CounterForExternalAssets` (r:1 w:1) + /// Proof: `Psm::CounterForExternalAssets` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `Psm::AssetCeilingWeight` (r:0 w:1) /// Proof: `Psm::AssetCeilingWeight` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) /// Storage: `Psm::MintingFee` (r:0 w:1) @@ -352,11 +354,11 @@ impl WeightInfo for () { /// Proof: `Psm::RedemptionFee` (`max_values`: None, `max_size`: Some(24), added: 2499, mode: `MaxEncodedLen`) fn remove_external_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `394` + // Measured: `417` // Estimated: `3501` - // Minimum execution time: 23_380_000 picoseconds. - Weight::from_parts(25_680_000, 3501) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().writes(5_u64)) + // Minimum execution time: 27_390_000 picoseconds. + Weight::from_parts(29_621_000, 3501) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(6_u64)) } } From 8266af1b063b1196141d8e4d887df148a2f76ea1 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 24 Mar 2026 15:42:08 +0000 Subject: [PATCH 08/32] feat: check the decimals of the new asser --- substrate/frame/psm/src/lib.rs | 18 ++++++++++++++---- substrate/frame/psm/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index dc4dcbbd6f9d5..c9ebf6c9bccc1 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -101,11 +101,14 @@ pub mod pallet { pallet_prelude::*, traits::{ fungible::{ - Balanced as FungibleBalanced, Credit as FungibleCredit, Inspect as FungibleInspect, + metadata::Inspect as FungibleMetadataInspect, Inspect as FungibleInspect, Mutate as FungibleMutate, }, - fungibles::{Inspect as FungiblesInspect, Mutate as FungiblesMutate}, - tokens::{imbalance::OnUnbalanced, Fortitude, Precision, Preservation}, + fungibles::{ + metadata::Inspect as FungiblesMetadataInspect, Inspect as FungiblesInspect, + Mutate as FungiblesMutate, + }, + tokens::{Fortitude, Precision, Preservation}, }, DefaultNoBound, PalletId, }; @@ -183,7 +186,8 @@ pub mod pallet { #[pallet::config] pub trait Config: frame_system::Config { /// Fungibles implementation for both pUSD and external stablecoins. - type Fungibles: FungiblesMutate; + type Fungibles: FungiblesMutate + + FungiblesMetadataInspect; /// Asset identifier type. type AssetId: Parameter + Member + Copy + MaybeSerializeDeserialize + MaxEncodedLen + Ord; @@ -365,6 +369,8 @@ pub mod pallet { InsufficientPrivilege, /// Maximum number of approved external assets reached. TooManyAssets, + /// External asset decimals do not match the stable asset decimals. + DecimalsMismatch, } #[pallet::call] @@ -757,6 +763,10 @@ pub mod pallet { ensure!(!ExternalAssets::::contains_key(asset_id), Error::::AssetAlreadyApproved); let count = ExternalAssets::::iter_keys().count() as u32; ensure!(count < T::MaxExternalAssets::get(), Error::::TooManyAssets); + ensure!( + T::Fungibles::decimals(asset_id) == T::StableAsset::decimals(), + Error::::DecimalsMismatch + ); ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); Self::deposit_event(Event::ExternalAssetAdded { asset_id }); Ok(()) diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index 7d2be32a4e052..e63e2c3cd8e2b 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -736,6 +736,27 @@ mod governance { }); } + #[test] + fn add_external_asset_fails_decimals_mismatch() { + new_test_ext().execute_with(|| { + let new_asset = 99u32; + // Create asset with wrong decimals (8 instead of 6) + assert_ok!(Assets::create(RuntimeOrigin::signed(ALICE), new_asset, ALICE, 1)); + assert_ok!(Assets::set_metadata( + RuntimeOrigin::signed(ALICE), + new_asset, + b"Bad Decimals".to_vec(), + b"BAD".to_vec(), + 8 + )); + + assert_noop!( + Psm::add_external_asset(RuntimeOrigin::root(), new_asset), + Error::::DecimalsMismatch + ); + }); + } + #[test] fn add_external_asset_unauthorized() { new_test_ext().execute_with(|| { From fa4f31b265c3d93f8790c1d87eae82df747dc97b Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 24 Mar 2026 15:42:33 +0000 Subject: [PATCH 09/32] feat: simplify the migration --- substrate/frame/psm/src/migrations/v1.rs | 42 ++++++++---------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/substrate/frame/psm/src/migrations/v1.rs b/substrate/frame/psm/src/migrations/v1.rs index 7d41b6b54fd91..8ce4bc0d202a9 100644 --- a/substrate/frame/psm/src/migrations/v1.rs +++ b/substrate/frame/psm/src/migrations/v1.rs @@ -33,7 +33,9 @@ //! //! Where `PsmInitialConfig` implements [`InitialPsmConfig`]. -use alloc::{collections::btree_map::BTreeMap, vec::Vec}; +use alloc::collections::btree_map::BTreeMap; +#[cfg(feature = "try-runtime")] +use alloc::vec::Vec; use frame_support::{ pallet_prelude::{Get, StorageVersion, Weight}, traits::{GetStorageVersion, UncheckedOnRuntimeUpgrade}, @@ -63,13 +65,12 @@ pub trait InitialPsmConfig { /// Max PSM debt as a fraction of MaximumIssuance. fn max_psm_debt_of_total() -> Permill; - /// Approved external stablecoin asset IDs. - fn external_asset_ids() -> Vec; - /// Per-asset configuration: /// - minting fee /// - redemption fee /// - asset ceiling weight + /// + /// Keys also define the set of approved external assets. fn asset_configs() -> BTreeMap; } @@ -101,22 +102,19 @@ impl> UncheckedOnRuntimeUpgrade for MigrateToV "Running MigrateToV1: initializing PSM pallet parameters" ); - let external_asset_ids = I::external_asset_ids(); let asset_configs = I::asset_configs(); MaxPsmDebtOfTotal::::put(I::max_psm_debt_of_total()); - for asset_id in &external_asset_ids { - ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); - } - for (asset_id, (minting_fee, redemption_fee, max_asset_debt_ratio)) in &asset_configs { + ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); MintingFee::::insert(asset_id, minting_fee); RedemptionFee::::insert(asset_id, redemption_fee); AssetCeilingWeight::::insert(asset_id, max_asset_debt_ratio); } - Pallet::::ensure_psm_account_exists(); + Pallet::::ensure_account_exists(&Pallet::::account_id()); + Pallet::::ensure_account_exists(&T::FeeDestination::get()); StorageVersion::new(1).put::>(); @@ -125,9 +123,8 @@ impl> UncheckedOnRuntimeUpgrade for MigrateToV "MigrateToV1 complete" ); - let writes = 3u64 - .saturating_add(external_asset_ids.len() as u64) - .saturating_add((asset_configs.len() as u64).saturating_mul(3)); + // 1 read + (MaxPsmDebtOfTotal + StorageVersion + 2 accounts) + 4 writes per asset + let writes = 4u64.saturating_add((asset_configs.len() as u64).saturating_mul(4)); T::DbWeight::get().reads_writes(1, writes) } @@ -148,14 +145,11 @@ impl> UncheckedOnRuntimeUpgrade for MigrateToV "MaxPsmDebtOfTotal mismatch after migration" ); - for asset_id in I::external_asset_ids() { + 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" ); - } - - for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in I::asset_configs() { ensure!( MintingFee::::get(asset_id) == minting_fee, "MintingFee mismatch after migration" @@ -192,10 +186,6 @@ mod tests { Permill::from_percent(25) } - fn external_asset_ids() -> Vec { - vec![USDC_ASSET_ID, USDT_ASSET_ID, 77] - } - fn asset_configs() -> BTreeMap { [ ( @@ -228,7 +218,6 @@ mod tests { MaxPsmDebtOfTotal::::kill(); ExternalAssets::::remove(USDC_ASSET_ID); ExternalAssets::::remove(USDT_ASSET_ID); - ExternalAssets::::remove(77); MintingFee::::remove(USDC_ASSET_ID); MintingFee::::remove(USDT_ASSET_ID); RedemptionFee::::remove(USDC_ASSET_ID); @@ -240,16 +229,13 @@ mod tests { assert_eq!(MaxPsmDebtOfTotal::::get(), TestPsmConfig::max_psm_debt_of_total()); - for asset_id in TestPsmConfig::external_asset_ids() { + for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in + TestPsmConfig::asset_configs() + { assert_eq!( ExternalAssets::::get(asset_id), Some(CircuitBreakerLevel::AllEnabled) ); - } - - for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in - TestPsmConfig::asset_configs() - { assert_eq!(MintingFee::::get(asset_id), minting_fee); assert_eq!(RedemptionFee::::get(asset_id), redemption_fee); assert_eq!(AssetCeilingWeight::::get(asset_id), ceiling_weight); From 01c3c0b7eb0bae7fc80362c4fc20c3c5d5c8fbe7 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 24 Mar 2026 15:43:42 +0000 Subject: [PATCH 10/32] feat: use a `CountedStorageMap` --- substrate/frame/psm/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index c9ebf6c9bccc1..f43988ffef7e8 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -271,7 +271,7 @@ pub mod pallet { /// Key existence indicates the asset is approved; the value is the circuit breaker level. #[pallet::storage] pub(crate) type ExternalAssets = - StorageMap<_, Blake2_128Concat, T::AssetId, CircuitBreakerLevel, OptionQuery>; + CountedStorageMap<_, Blake2_128Concat, T::AssetId, CircuitBreakerLevel, OptionQuery>; /// Genesis configuration for the PSM pallet. #[pallet::genesis_config] @@ -756,12 +756,12 @@ pub mod pallet { /// /// - [`Event::ExternalAssetAdded`]: Emitted on successful addition #[pallet::call_index(7)] - #[pallet::weight(T::WeightInfo::add_external_asset(T::MaxExternalAssets::get()))] + #[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 == PsmManagerLevel::Full, Error::::InsufficientPrivilege); ensure!(!ExternalAssets::::contains_key(asset_id), Error::::AssetAlreadyApproved); - let count = ExternalAssets::::iter_keys().count() as u32; + let count = ExternalAssets::::count(); ensure!(count < T::MaxExternalAssets::get(), Error::::TooManyAssets); ensure!( T::Fungibles::decimals(asset_id) == T::StableAsset::decimals(), From c6c8c60fe31bdf2fb520eb70babe88de147d7322 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 24 Mar 2026 15:44:19 +0000 Subject: [PATCH 11/32] feat: restore the `FeeDestination` approach --- substrate/frame/psm/src/lib.rs | 59 ++++++++++++++------------------ substrate/frame/psm/src/mock.rs | 17 ++++++--- substrate/frame/psm/src/tests.rs | 14 +++++--- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index f43988ffef7e8..5735405f363be 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -51,7 +51,7 @@ //! * **Redemption Fee ([`RedemptionFee`])**: Deducted from external stablecoin output during //! redemption //! -//! Fees are collected in pUSD and handled by [`Config::FeeHandler`]. +//! Fees are collected in pUSD and transferred to [`Config::FeeDestination`]. //! //! ### Example //! @@ -210,13 +210,13 @@ pub mod pallet { /// Typically `ItemOf`. /// Must use the same `Balance` type as `Asset`. type StableAsset: FungibleMutate> - + FungibleBalanced; + + FungibleMetadataInspect; - /// Handler for pUSD fee credits collected during minting and redemption. + /// Account that receives pUSD fees from minting and redemption. /// - /// Use `ResolveTo` for simple single-account deposit, - /// or implement custom `OnUnbalanced` logic for fee splitting. - type FeeHandler: OnUnbalanced>; + /// Must exist before any swap; initialized at genesis and migration + /// via [`Pallet::ensure_account_exists`]. + type FeeDestination: Get; /// PalletId for deriving the PSM account. #[pallet::constant] @@ -304,7 +304,8 @@ pub mod pallet { RedemptionFee::::insert(asset_id, redemption_fee); AssetCeilingWeight::::insert(asset_id, max_asset_debt_ratio); } - Pallet::::ensure_psm_account_exists(); + Pallet::::ensure_account_exists(&Pallet::::account_id()); + Pallet::::ensure_account_exists(&T::FeeDestination::get()); } } @@ -386,7 +387,7 @@ pub mod pallet { /// Transfers `external_amount` of the specified external stablecoin from the caller /// to the PSM account, then mints pUSD to the caller minus the minting fee. /// The fee is calculated using ceiling rounding (`mul_ceil`), ensuring the - /// protocol never undercharges. The fee is passed to [`Config::FeeHandler`]. + /// protocol never undercharges. The fee is transferred to [`Config::FeeDestination`]. /// /// ## Parameters /// @@ -421,7 +422,10 @@ pub mod pallet { ensure!(external_amount >= T::MinSwapAmount::get(), Error::::BelowMinimumSwap); - // Check system-wide issuance cap + let fee = MintingFee::::get(asset_id).mul_ceil(external_amount); + let pusd_to_user = external_amount.saturating_sub(fee); + + // Total new issuance = pusd_to_user + fee = external_amount. let current_total_issuance = T::StableAsset::total_issuance(); let max_issuance = T::VaultsInterface::get_maximum_issuance(); ensure!( @@ -452,15 +456,9 @@ pub mod pallet { external_amount, Preservation::Expendable, )?; - - let fee = MintingFee::::get(asset_id).mul_ceil(external_amount); - let pusd_to_user = external_amount.saturating_sub(fee); - T::StableAsset::mint_into(&who, pusd_to_user)?; - if !fee.is_zero() { - let fee_credit = T::StableAsset::issue(fee); - T::FeeHandler::on_unbalanced(fee_credit); + T::StableAsset::mint_into(&T::FeeDestination::get(), fee)?; } PsmDebt::::insert(asset_id, new_debt); @@ -484,10 +482,10 @@ pub mod pallet { /// /// ## Details /// - /// Burns `pusd_amount` pUSD from the caller minus fee (passed to [`Config::FeeHandler`]), - /// 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 `pusd_amount` pUSD 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. /// /// ## Parameters /// @@ -532,25 +530,21 @@ pub mod pallet { let reserve = Self::get_reserve(asset_id); ensure!(reserve >= external_to_user, Error::::InsufficientReserve); - let pusd_to_burn = external_to_user; - + // Burn the redeemed portion, then transfer fee to destination. T::StableAsset::burn_from( &who, - pusd_to_burn, + external_to_user, Preservation::Expendable, Precision::Exact, Fortitude::Polite, )?; - if !fee.is_zero() { - let fee_credit = T::StableAsset::withdraw( + T::StableAsset::transfer( &who, + &T::FeeDestination::get(), fee, - Precision::Exact, Preservation::Expendable, - Fortitude::Polite, )?; - T::FeeHandler::on_unbalanced(fee_credit); } let psm_account = Self::account_id(); @@ -876,11 +870,10 @@ pub mod pallet { T::Fungibles::balance(asset_id, &Self::account_id()) } - /// Ensure PSM account exists by incrementing its provider count. - pub(crate) fn ensure_psm_account_exists() { - let psm_account = Self::account_id(); - if !frame_system::Pallet::::account_exists(&psm_account) { - frame_system::Pallet::::inc_providers(&psm_account); + /// 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); } } } diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs index 26573567747f2..4275606c1cd21 100644 --- a/substrate/frame/psm/src/mock.rs +++ b/substrate/frame/psm/src/mock.rs @@ -18,10 +18,7 @@ use crate::VaultsInterface; use frame_support::{ derive_impl, parameter_types, - traits::{ - tokens::imbalance::ResolveTo, AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, - EnsureOrigin, - }, + traits::{AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, EnsureOrigin}, weights::constants::RocksDbWeight, PalletId, }; @@ -285,6 +282,18 @@ pub fn fund_pusd(account: u64, amount: u128) { let _ = Assets::mint_into(PUSD_ASSET_ID, &account, amount); } +pub fn create_asset_with_metadata(asset_id: u32) { + use frame_support::assert_ok; + assert_ok!(Assets::create(RuntimeOrigin::signed(ALICE), asset_id, ALICE, 1)); + assert_ok!(Assets::set_metadata( + RuntimeOrigin::signed(ALICE), + asset_id, + b"Test Asset".to_vec(), + b"TST".to_vec(), + 6 + )); +} + pub fn get_asset_balance(asset_id: u32, account: u64) -> u128 { Assets::balance(asset_id, account) } diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index e63e2c3cd8e2b..5024b8ed4358b 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -34,7 +34,7 @@ mod mint { assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); - let fee = Permill::from_percent(1).mul_floor(mint_amount); + let fee = Permill::from_percent(1).mul_ceil(mint_amount); let pusd_to_user = mint_amount - fee; assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before - mint_amount); @@ -76,7 +76,7 @@ mod mint { set_minting_fee(USDC_ASSET_ID, Permill::from_percent(5)); let mint_amount = 1000 * PUSD_UNIT; - let fee = Permill::from_percent(5).mul_floor(mint_amount); + let fee = Permill::from_percent(5).mul_ceil(mint_amount); let pusd_to_user = mint_amount - fee; assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); @@ -293,7 +293,7 @@ mod redeem { assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, redeem_amount)); - let fee = Permill::from_percent(1).mul_floor(redeem_amount); + let fee = Permill::from_percent(1).mul_ceil(redeem_amount); let external_to_user = redeem_amount - fee; assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), alice_pusd_before - redeem_amount); @@ -342,7 +342,7 @@ mod redeem { set_redemption_fee(USDC_ASSET_ID, Permill::from_percent(5)); let redeem_amount = 1000 * PUSD_UNIT; - let fee = Permill::from_percent(5).mul_floor(redeem_amount); + let fee = Permill::from_percent(5).mul_ceil(redeem_amount); let external_to_user = redeem_amount - fee; let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); @@ -724,6 +724,7 @@ mod governance { fn add_external_asset_works() { new_test_ext().execute_with(|| { let new_asset = 99u32; + create_asset_with_metadata(new_asset); assert!(!Psm::is_approved_asset(&new_asset)); assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), new_asset)); @@ -782,13 +783,15 @@ mod governance { new_test_ext().execute_with(|| { use frame_support::traits::Get; let max: u32 = ::MaxExternalAssets::get(); - let existing = crate::ExternalAssets::::iter_keys().count() as u32; + let existing = crate::ExternalAssets::::count(); // 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)); } // One more should fail. + create_asset_with_metadata(9999); assert_noop!( Psm::add_external_asset(RuntimeOrigin::root(), 9999), Error::::TooManyAssets @@ -1098,6 +1101,7 @@ mod ceiling_redistribution { new_test_ext().execute_with(|| { // Add a third asset let dai_asset_id = 4u32; + create_asset_with_metadata(dai_asset_id); assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), dai_asset_id)); // Setup: USDC 50%, USDT 25%, DAI 25% From 94b74bdbfb874ecfe452964e21cdd5b5628cf0e2 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 16:26:17 +0200 Subject: [PATCH 12/32] Update substrate/frame/psm/src/tests.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> --- substrate/frame/psm/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index 5024b8ed4358b..de20133ebe192 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -215,7 +215,7 @@ mod mint { let psm_usdc_before = get_asset_balance(USDC_ASSET_ID, psm_account()); let too_much = alice_usdc_before + 1000 * PUSD_UNIT; - assert!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much).is_err()); + assert_noop!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much).is_err()); // Verify no state mutation occurred assert_eq!(PsmDebt::::get(USDC_ASSET_ID), 0); From b61a6e084bda9291da6000e63f036d07f5516641 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 16:26:38 +0200 Subject: [PATCH 13/32] Update substrate/frame/psm/src/tests.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> --- substrate/frame/psm/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index de20133ebe192..022589b296b62 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -143,7 +143,7 @@ mod mint { #[test] fn fails_below_minimum() { new_test_ext().execute_with(|| { - let below_min = 50 * PUSD_UNIT; + let below_min = MinMint::::get() - 1 assert_noop!( Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, below_min), From 880d46f7e35622a5fed5a44d6e9d7dbffc065ef4 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 16:27:01 +0200 Subject: [PATCH 14/32] Update substrate/frame/psm/src/lib.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> --- substrate/frame/psm/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 5735405f363be..6344099e9768c 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -594,6 +594,7 @@ pub mod pallet { ) -> DispatchResult { let level = T::ManagerOrigin::ensure_origin(origin)?; ensure!(level == PsmManagerLevel::Full, 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 }); From a0b92fd2442c063a6bf72ce2937ab45b2e68841b Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 16:27:16 +0200 Subject: [PATCH 15/32] Update substrate/frame/psm/src/lib.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> --- substrate/frame/psm/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 6344099e9768c..df71e74212ef4 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -624,6 +624,7 @@ pub mod pallet { ) -> DispatchResult { let level = T::ManagerOrigin::ensure_origin(origin)?; ensure!(level == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + ensure!(ExternalAssets::::contains_key(asset_id), Error::::AssetNotApproved); let old_value = RedemptionFee::::get(asset_id); RedemptionFee::::insert(asset_id, fee); Self::deposit_event(Event::RedemptionFeeUpdated { From 4950ae9d9ee67b8a980dbe84c232775e6ff61a23 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 14:40:54 +0000 Subject: [PATCH 16/32] chore: use parameter_types + static --- substrate/frame/psm/src/mock.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs index 4275606c1cd21..8cf27ed41c4b6 100644 --- a/substrate/frame/psm/src/mock.rs +++ b/substrate/frame/psm/src/mock.rs @@ -25,7 +25,6 @@ use frame_support::{ use frame_system::{mocking::MockBlock, EnsureRoot, EnsureSigned, GenesisConfig}; use sp_io::TestExternalities as TestState; use sp_runtime::{BuildStorage, Permill}; -use std::cell::RefCell; // Test accounts pub const ALICE: u64 = 1; @@ -45,13 +44,12 @@ pub const PUSD_UNIT: u128 = 1_000_000; // Initial balances for testing pub const INITIAL_BALANCE: u128 = 1_000_000 * PUSD_UNIT; // 1M units -// Thread-local storage for mock VaultsInterface -thread_local! { - static MOCK_MAXIMUM_ISSUANCE: RefCell = const { RefCell::new(10_000_000 * PUSD_UNIT) }; +parameter_types! { + pub static MockMaximumIssuance: u128 = 10_000_000 * PUSD_UNIT; } pub fn set_mock_maximum_issuance(value: u128) { - MOCK_MAXIMUM_ISSUANCE.with(|v| *v.borrow_mut() = value); + MockMaximumIssuance::set(value); } pub struct MockVaultsInterface; @@ -60,7 +58,7 @@ impl VaultsInterface for MockVaultsInterface { type Balance = u128; fn get_maximum_issuance() -> Self::Balance { - MOCK_MAXIMUM_ISSUANCE.with(|v| *v.borrow()) + MockMaximumIssuance::get() } } From 2c769ae91c7c6232128803a87b60725d417890f7 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 14:41:27 +0000 Subject: [PATCH 17/32] chore: fix the assert_noop syntax --- substrate/frame/psm/src/tests.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index 022589b296b62..ace5aef07e4a4 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -21,7 +21,7 @@ use crate::{ MintingFee, PsmDebt, RedemptionFee, VaultsInterface, }; use frame_support::{assert_noop, assert_ok}; -use sp_runtime::{DispatchError, Permill}; +use sp_runtime::{DispatchError, Permill, TokenError}; mod mint { use super::*; @@ -143,7 +143,7 @@ mod mint { #[test] fn fails_below_minimum() { new_test_ext().execute_with(|| { - let below_min = MinMint::::get() - 1 + let below_min = MinSwapAmount::get() - 1; assert_noop!( Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, below_min), @@ -215,7 +215,10 @@ mod mint { let psm_usdc_before = get_asset_balance(USDC_ASSET_ID, psm_account()); let too_much = alice_usdc_before + 1000 * PUSD_UNIT; - assert_noop!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much).is_err()); + assert_noop!( + Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much), + TokenError::FundsUnavailable + ); // Verify no state mutation occurred assert_eq!(PsmDebt::::get(USDC_ASSET_ID), 0); From 4831e74ec000391d90275ea4e4f69ee7f9eada80 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 19:55:31 +0000 Subject: [PATCH 18/32] feat: remove `VaultsInterface` and use the simpler `MaximumIssuance` --- prdoc/pr_11068.prdoc | 2 -- substrate/bin/node/runtime/src/lib.rs | 12 ++++------- substrate/frame/psm/README.md | 21 ++++++++++--------- substrate/frame/psm/src/lib.rs | 12 +++++------ substrate/frame/psm/src/mock.rs | 13 +----------- substrate/frame/psm/src/tests.rs | 4 ++-- substrate/frame/support/src/traits.rs | 2 +- .../frame/support/src/traits/tokens/stable.rs | 12 ----------- 8 files changed, 25 insertions(+), 53 deletions(-) diff --git a/prdoc/pr_11068.prdoc b/prdoc/pr_11068.prdoc index 6c6dfcc52bb1c..372933a7d8532 100644 --- a/prdoc/pr_11068.prdoc +++ b/prdoc/pr_11068.prdoc @@ -12,8 +12,6 @@ doc: - Redemption: burn pUSD to receive an external stablecoin - Per-asset circuit breakers to disable minting or all swaps - Configurable ceiling weights and maximum PSM debt ratio - - Inter-pallet traits (`VaultsInterface`, `PsmInterface`) for coordinating debt ceilings - with a companion Vaults pallet crates: - name: pallet-psm diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index 4e6ccbd9f9b5b..98b8c66e81ea2 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -3081,13 +3081,9 @@ parameter_types! { type PsmStableAsset = ItemOf; -/// Stub VaultsInterface that imposes no debt ceiling. -pub struct NoVaultsCeiling; -impl frame_support::traits::VaultsInterface for NoVaultsCeiling { - type Balance = Balance; - fn get_maximum_issuance() -> Balance { - Balance::MAX - } +parameter_types! { + /// No debt ceiling: maximum possible issuance. + pub const NoVaultsCeiling: Balance = Balance::MAX; } /// EnsureOrigin implementation for PSM management that supports privilege levels. @@ -3139,7 +3135,7 @@ impl pallet_psm::BenchmarkHelper for PsmBenchmarkHelper { impl pallet_psm::Config for Runtime { type Fungibles = Assets; type AssetId = u32; - type VaultsInterface = NoVaultsCeiling; + type MaximumIssuance = NoVaultsCeiling; type ManagerOrigin = EnsurePsmManager; type WeightInfo = pallet_psm::weights::SubstrateWeight; type StableAsset = PsmStableAsset; diff --git a/substrate/frame/psm/README.md b/substrate/frame/psm/README.md index cc53f7f943b05..6080669d4659e 100644 --- a/substrate/frame/psm/README.md +++ b/substrate/frame/psm/README.md @@ -8,7 +8,7 @@ The PSM pallet allows users to swap external stablecoins (e.g., USDC, USDT) for - **Reserves are held**: External stablecoins are held in a pallet-derived account (`PalletId`) - **pUSD is minted/burned**: Users receive pUSD when depositing external stablecoins, and burn pUSD when redeeming -- **Fees are routed via `FeeHandler`**: Mint and redeem fees are collected in pUSD and dispatched through a configurable handler +- **Fees are routed to `FeeDestination`**: Mint and redeem fees are collected in pUSD and transferred to a configurable account - **Circuit breaker provides emergency control**: Per-asset circuit breaker can disable minting or all swaps ## Swap Lifecycle @@ -19,7 +19,7 @@ mint(origin, asset_id, external_amount) ``` - Deposits external stablecoin into the PSM account - Mints pUSD to the user (minus minting fee) -- Fee is issued as pUSD credit and routed through `FeeHandler` +- Fee is minted as pUSD and transferred to `FeeDestination` - Enforces three-tier debt ceiling: system-wide, aggregate PSM, and per-asset - Requires `external_amount >= MinSwapAmount` @@ -29,7 +29,7 @@ redeem(origin, asset_id, pusd_amount) ``` - Burns pUSD from the user equal to the external amount being redeemed - Transfers external stablecoin from PSM account to user -- Redemption fee is withdrawn from the user as pUSD credit and routed through `FeeHandler` +- Redemption fee is transferred from the user as pUSD to `FeeDestination` - Limited by tracked PSM debt (not raw reserve balance) - Requires `pusd_amount >= MinSwapAmount` @@ -57,10 +57,10 @@ Setting an asset's weight to 0% disables minting and redistributes its capacity ## Fee Structure -Fees are calculated using `Permill::mul_ceil` (rounds up) and handled as pUSD credits via `FeeHandler`: +Fees are calculated using `Permill::mul_ceil` (rounds up) and transferred as pUSD to `FeeDestination`: -- **Minting Fee**: `fee = MintingFee[asset_id].mul_ceil(external_amount)` -- deducted from pUSD output, issued as pUSD credit, and passed to `FeeHandler` -- **Redemption Fee**: `fee = RedemptionFee[asset_id].mul_ceil(pusd_amount)` -- withdrawn from the user as pUSD credit and passed to `FeeHandler` +- **Minting Fee**: `fee = MintingFee[asset_id].mul_ceil(external_amount)` -- deducted from pUSD output, minted to `FeeDestination` +- **Redemption Fee**: `fee = RedemptionFee[asset_id].mul_ceil(pusd_amount)` -- transferred from the user to `FeeDestination` With 0.5% fees on both sides, arbitrage opportunities exist when pUSD trades outside $0.995-$1.005. @@ -114,7 +114,7 @@ Before calling `add_external_asset(asset_id)`: impl pallet_psm::Config for Runtime { type Fungibles = Assets; type AssetId = u32; - type VaultsInterface = Vaults; + type MaximumIssuance = MaximumIssuance; type ManagerOrigin = EnsurePsmManager; type WeightInfo = weights::SubstrateWeight; type StableAsset = frame_support::traits::fungible::ItemOf< @@ -122,14 +122,14 @@ impl pallet_psm::Config for Runtime { StablecoinAssetId, AccountId, >; - type FeeHandler = ResolveTo; + type FeeDestination = InsuranceFundAccount; type PalletId = PsmPalletId; type MinSwapAmount = MinSwapAmount; type MaxExternalAssets = ConstU32<10>; } ``` -`Fungibles` must expose metadata for approved assets, and `StableAsset` must expose metadata for the pUSD asset because `add_external_asset` validates that decimals match before approval. +`Fungibles` must expose metadata for approved assets, and `StableAsset` must expose metadata for the pUSD asset because `add_external_asset` validates that decimals match before approval. `MaximumIssuance` provides the system-wide pUSD cap (typically from the Vaults pallet or a constant). ### Parameters (Set via Governance) @@ -149,7 +149,7 @@ impl pallet_psm::Config for Runtime { Typical runtime helpers used in the configuration above: - `StablecoinAssetId`: Runtime constant used by `ItemOf<..., StablecoinAssetId, ...>` to bind `StableAsset` to pUSD -- `InsuranceFundAccount`: Example fee destination account when using `ResolveTo` +- `InsuranceFundAccount`: Account that receives pUSD fees via `FeeDestination` ## Events @@ -178,6 +178,7 @@ Typical runtime helpers used in the configuration above: - `InsufficientPrivilege`: Emergency origin tried a Full-only operation - `TooManyAssets`: Maximum number of approved external assets reached - `DecimalsMismatch`: External asset decimals do not match the stable asset decimals +- `Unexpected`: An unexpected invariant violation occurred (defensive check) ## Testing diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index df71e74212ef4..15fd5f5d301c2 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -93,7 +93,7 @@ pub trait BenchmarkHelper { #[frame_support::pallet] pub mod pallet { - pub use frame_support::traits::tokens::stable::{PsmInterface, VaultsInterface}; + pub use frame_support::traits::tokens::stable::PsmInterface; use alloc::collections::btree_map::BTreeMap; use codec::DecodeWithMemTracking; @@ -192,8 +192,8 @@ pub mod pallet { /// Asset identifier type. type AssetId: Parameter + Member + Copy + MaybeSerializeDeserialize + MaxEncodedLen + Ord; - /// Interface to query vaults for debt ceiling. - type VaultsInterface: VaultsInterface>; + /// Maximum allowed pUSD issuance across the entire system. + type MaximumIssuance: Get>; /// Origin allowed to update PSM parameters. /// @@ -427,7 +427,7 @@ pub mod pallet { // Total new issuance = pusd_to_user + fee = external_amount. let current_total_issuance = T::StableAsset::total_issuance(); - let max_issuance = T::VaultsInterface::get_maximum_issuance(); + let max_issuance = T::MaximumIssuance::get(); ensure!( current_total_issuance.saturating_add(external_amount) <= max_issuance, Error::::ExceedsMaxIssuance @@ -822,8 +822,8 @@ pub mod pallet { } /// Calculate max PSM debt based on system ceiling. - pub fn max_psm_debt() -> BalanceOf { - let max_issuance = T::VaultsInterface::get_maximum_issuance(); + pub(crate) fn max_psm_debt() -> BalanceOf { + let max_issuance = T::MaximumIssuance::get(); MaxPsmDebtOfTotal::::get().mul_floor(max_issuance) } diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs index 8cf27ed41c4b6..3fbb6062128c2 100644 --- a/substrate/frame/psm/src/mock.rs +++ b/substrate/frame/psm/src/mock.rs @@ -15,7 +15,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::VaultsInterface; use frame_support::{ derive_impl, parameter_types, traits::{AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, EnsureOrigin}, @@ -52,16 +51,6 @@ pub fn set_mock_maximum_issuance(value: u128) { MockMaximumIssuance::set(value); } -pub struct MockVaultsInterface; - -impl VaultsInterface for MockVaultsInterface { - type Balance = u128; - - fn get_maximum_issuance() -> Self::Balance { - MockMaximumIssuance::get() - } -} - #[frame_support::runtime] mod test_runtime { #[runtime::runtime] @@ -172,7 +161,7 @@ impl crate::BenchmarkHelper for PsmBenchmarkHelper { impl crate::Config for Test { type Fungibles = Assets; type AssetId = u32; - type VaultsInterface = MockVaultsInterface; + type MaximumIssuance = MockMaximumIssuance; type ManagerOrigin = MockManagerOrigin; type WeightInfo = (); type StableAsset = frame_support::traits::fungible::ItemOf; diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index ace5aef07e4a4..1c571ba4f8217 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -18,7 +18,7 @@ use super::mock::*; use crate::{ AssetCeilingWeight, CircuitBreakerLevel, Error, Event, ExternalAssets, MaxPsmDebtOfTotal, - MintingFee, PsmDebt, RedemptionFee, VaultsInterface, + MintingFee, PsmDebt, RedemptionFee, }; use frame_support::{assert_noop, assert_ok}; use sp_runtime::{DispatchError, Permill, TokenError}; @@ -231,7 +231,7 @@ mod mint { #[test] fn fails_mint_exceeds_system_wide_issuance() { new_test_ext().execute_with(|| { - let maximum_issuance = MockVaultsInterface::get_maximum_issuance(); + let maximum_issuance = MockMaximumIssuance::get(); // Simulate Vaults having minted most of the cap (leave only 100 pUSD room) let vault_minted = maximum_issuance - 100 * PUSD_UNIT; diff --git a/substrate/frame/support/src/traits.rs b/substrate/frame/support/src/traits.rs index 06e3bace2b2c3..4acacb463988c 100644 --- a/substrate/frame/support/src/traits.rs +++ b/substrate/frame/support/src/traits.rs @@ -29,7 +29,7 @@ pub use tokens::{ fungible, fungibles, imbalance::{Imbalance, OnUnbalanced, SignedImbalance}, nonfungible, nonfungible_v2, nonfungibles, nonfungibles_v2, - stable::{PsmInterface, VaultsInterface}, + stable::PsmInterface, BalanceStatus, ExistenceRequirement, Locker, WithdrawReasons, }; diff --git a/substrate/frame/support/src/traits/tokens/stable.rs b/substrate/frame/support/src/traits/tokens/stable.rs index 2771db0d55846..2188a1eb53c7b 100644 --- a/substrate/frame/support/src/traits/tokens/stable.rs +++ b/substrate/frame/support/src/traits/tokens/stable.rs @@ -17,18 +17,6 @@ //! Traits for stablecoin inter-pallet communication. -/// Trait for the PSM to query the Vaults pallet for system-wide debt ceiling. -/// -/// Implemented by the Vaults pallet, called by the PSM pallet when checking -/// whether a mint would exceed the maximum pUSD issuance. -pub trait VaultsInterface { - /// The balance type. - type Balance; - - /// Get the maximum allowed pUSD issuance across the entire system. - fn get_maximum_issuance() -> Self::Balance; -} - /// Trait exposing the PSM pallet's reserved capacity to other pallets. /// /// Implemented by the PSM pallet, used by the Vaults pallet to account for From def49865c27110b32ddfd845d76b2af066557f81 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 19:57:04 +0000 Subject: [PATCH 19/32] chore: add permissions helpers --- substrate/frame/psm/src/lib.rs | 56 +++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 15fd5f5d301c2..451d4f5fcd6f2 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -144,6 +144,18 @@ pub mod pallet { AllDisabled, } + impl CircuitBreakerLevel { + /// Whether this level allows minting (external → pUSD). + pub fn allows_minting(&self) -> bool { + matches!(self, CircuitBreakerLevel::AllEnabled) + } + + /// Whether this level allows redemption (pUSD → external). + pub fn allows_redemption(&self) -> bool { + !matches!(self, CircuitBreakerLevel::AllDisabled) + } + } + /// Privilege level returned by ManagerOrigin. /// /// Enables tiered authorization where different origins have different @@ -171,6 +183,34 @@ pub mod pallet { Emergency, } + impl PsmManagerLevel { + /// Whether this level allows modifying minting/redemption fees. + pub fn can_set_fees(&self) -> bool { + matches!(self, PsmManagerLevel::Full) + } + + /// Whether this level allows modifying the circuit breaker status. + /// Both Full and Emergency levels can set circuit breaker. + pub fn can_set_circuit_breaker(&self) -> bool { + true + } + + /// Whether this level allows modifying the global PSM debt ratio. + pub fn can_set_max_psm_debt(&self) -> bool { + matches!(self, PsmManagerLevel::Full) + } + + /// Whether this level allows modifying per-asset ceiling weights. + pub fn can_set_asset_ceiling(&self) -> bool { + matches!(self, PsmManagerLevel::Full) + } + + /// Whether this level allows adding or removing external assets. + pub fn can_manage_assets(&self) -> bool { + matches!(self, PsmManagerLevel::Full) + } + } + pub(crate) type BalanceOf = <::Fungibles as FungiblesInspect< ::AccountId, >>::Balance; @@ -418,7 +458,7 @@ pub mod pallet { // Check asset is approved and minting is enabled let asset_status = ExternalAssets::::get(asset_id).ok_or(Error::::UnsupportedAsset)?; - ensure!(asset_status == CircuitBreakerLevel::AllEnabled, Error::::MintingStopped); + ensure!(asset_status.allows_minting(), Error::::MintingStopped); ensure!(external_amount >= T::MinSwapAmount::get(), Error::::BelowMinimumSwap); @@ -514,7 +554,7 @@ pub mod pallet { // Check asset is approved and redemption is enabled let asset_status = ExternalAssets::::get(asset_id).ok_or(Error::::UnsupportedAsset)?; - ensure!(asset_status != CircuitBreakerLevel::AllDisabled, Error::::AllSwapsStopped); + ensure!(asset_status.allows_redemption(), Error::::AllSwapsStopped); ensure!(pusd_amount >= T::MinSwapAmount::get(), Error::::BelowMinimumSwap); @@ -593,7 +633,7 @@ pub mod pallet { fee: Permill, ) -> DispatchResult { let level = T::ManagerOrigin::ensure_origin(origin)?; - ensure!(level == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + 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); @@ -623,7 +663,7 @@ pub mod pallet { fee: Permill, ) -> DispatchResult { let level = T::ManagerOrigin::ensure_origin(origin)?; - ensure!(level == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + 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); @@ -648,7 +688,7 @@ pub mod pallet { #[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 == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + 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 }); @@ -722,7 +762,7 @@ pub mod pallet { ratio: Permill, ) -> DispatchResult { let level = T::ManagerOrigin::ensure_origin(origin)?; - ensure!(level == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + 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, ratio); @@ -755,7 +795,7 @@ pub mod pallet { #[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 == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + ensure!(level.can_manage_assets(), Error::::InsufficientPrivilege); ensure!(!ExternalAssets::::contains_key(asset_id), Error::::AssetAlreadyApproved); let count = ExternalAssets::::count(); ensure!(count < T::MaxExternalAssets::get(), Error::::TooManyAssets); @@ -800,7 +840,7 @@ pub mod pallet { #[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 == PsmManagerLevel::Full, Error::::InsufficientPrivilege); + 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); From 811e6691595b2c2f469decccde6d655efe2ed369 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 19:57:30 +0000 Subject: [PATCH 20/32] feat: add `try-runtime` checks --- substrate/frame/psm/src/lib.rs | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 451d4f5fcd6f2..732abd357c395 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -282,6 +282,18 @@ pub mod pallet { #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(_); + #[pallet::hooks] + impl Hooks> for Pallet { + fn integrity_test() { + assert!(!T::MinSwapAmount::get().is_zero(), "MinSwapAmount must be greater than zero"); + } + + #[cfg(feature = "try-runtime")] + fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { + Self::do_try_state() + } + } + /// pUSD minted through PSM per external asset. #[pallet::storage] pub type PsmDebt = @@ -918,6 +930,54 @@ pub mod pallet { frame_system::Pallet::::inc_providers(account); } } + + #[cfg(feature = "try-runtime")] + fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { + use sp_runtime::traits::CheckedAdd; + + let stable_decimals = T::StableAsset::decimals(); + + // Check 1: All approved assets must have matching decimals. + for (asset_id, _) in ExternalAssets::::iter() { + ensure!( + T::Fungibles::decimals(asset_id) == stable_decimals, + "External asset decimals do not match stable asset decimals" + ); + } + + // Check 2: Per-asset reserve must be >= per-asset debt. + // The PSM holds 1:1 backing; donated reserves may cause reserve > debt. + for (asset_id, _) in ExternalAssets::::iter() { + let debt = PsmDebt::::get(asset_id); + let reserve = Self::get_reserve(asset_id); + ensure!(reserve >= debt, "PSM reserve is less than tracked debt for an asset"); + } + + // 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" + ); + + // 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); + ensure!(debt <= ceiling, "Per-asset PSM debt exceeds its ceiling"); + } + } + + Ok(()) + } } } From 424ac6f038d5ce1336011ed3ff5cd1ae2ca1adb9 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 19:57:58 +0000 Subject: [PATCH 21/32] chore: restrict visibility --- substrate/frame/psm/src/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 732abd357c395..204cdecc27e53 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -909,18 +909,19 @@ pub mod pallet { } /// Calculate total PSM debt across all approved assets. - pub fn total_psm_debt() -> BalanceOf { + pub(crate) fn total_psm_debt() -> BalanceOf { PsmDebt::::iter_values() .fold(BalanceOf::::zero(), |acc, debt| acc.saturating_add(debt)) } /// Check if an asset is approved for PSM swaps. - pub fn is_approved_asset(asset_id: &T::AssetId) -> bool { + #[cfg(test)] + pub(crate) fn is_approved_asset(asset_id: &T::AssetId) -> bool { ExternalAssets::::contains_key(asset_id) } /// Get the reserve (balance) of an external asset held by PSM. - pub fn get_reserve(asset_id: T::AssetId) -> BalanceOf { + pub(crate) fn get_reserve(asset_id: T::AssetId) -> BalanceOf { T::Fungibles::balance(asset_id, &Self::account_id()) } From f5a596a110e8f02a263eb022e0238d30f275ec53 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 19:58:27 +0000 Subject: [PATCH 22/32] chore: restrict visibility, take 2 --- substrate/frame/psm/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 204cdecc27e53..dc4b72c3504e0 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -869,7 +869,7 @@ pub mod pallet { impl Pallet { /// Get the PSM's derived account. - pub fn account_id() -> T::AccountId { + pub(crate) fn account_id() -> T::AccountId { T::PalletId::get().into_account_truncating() } @@ -888,7 +888,7 @@ pub mod pallet { /// Ratios act as weights that normalize to fill the PSM ceiling. When an /// asset is disabled, governance should set its ratio to 0% so its ceiling /// allocation is automatically redistributed to other assets. - pub fn max_asset_debt(asset_id: T::AssetId) -> BalanceOf { + pub(crate) fn max_asset_debt(asset_id: T::AssetId) -> BalanceOf { let asset_ratio = AssetCeilingWeight::::get(asset_id); if asset_ratio.is_zero() { From a54a32e9f4dc130b641d9e9d16fb4f630d88e2fe Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Tue, 31 Mar 2026 19:58:48 +0000 Subject: [PATCH 23/32] feat: more defensive cheks --- substrate/frame/psm/src/lib.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index dc4b72c3504e0..118e7bfdc9904 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -348,9 +348,15 @@ pub mod pallet { T::MaxExternalAssets::get(), ); MaxPsmDebtOfTotal::::put(self.max_psm_debt_of_total); + let stable_decimals = T::StableAsset::decimals(); for (asset_id, (minting_fee, redemption_fee, max_asset_debt_ratio)) in &self.asset_configs { + assert!( + T::Fungibles::decimals(*asset_id) == stable_decimals, + "PSM genesis: asset {:?} decimals do not match stable asset decimals", + asset_id, + ); ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); MintingFee::::insert(asset_id, minting_fee); RedemptionFee::::insert(asset_id, redemption_fee); @@ -424,6 +430,8 @@ pub mod pallet { TooManyAssets, /// External asset decimals do not match the stable asset decimals. DecimalsMismatch, + /// An unexpected invariant violation occurred. This should be reported. + Unexpected, } #[pallet::call] @@ -578,9 +586,11 @@ pub mod pallet { let current_debt = PsmDebt::::get(asset_id); ensure!(current_debt >= external_to_user, Error::::InsufficientReserve); - // Safety check: reserve should always match debt under normal operation let reserve = Self::get_reserve(asset_id); - ensure!(reserve >= external_to_user, Error::::InsufficientReserve); + if reserve < external_to_user { + defensive!("PSM reserve is less than expected output amount"); + return Err(Error::::Unexpected.into()); + } // Burn the redeemed portion, then transfer fee to destination. T::StableAsset::burn_from( From 14c2714e2dd7f0a429b2df445eaa6b3c18eb7389 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Wed, 1 Apr 2026 05:43:10 +0000 Subject: [PATCH 24/32] feat: relax can_set_asset_ceiling --- substrate/frame/psm/src/lib.rs | 17 +++++++++-------- substrate/frame/psm/src/tests.rs | 19 ++++++++----------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 118e7bfdc9904..af293678ef329 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -146,12 +146,12 @@ pub mod pallet { impl CircuitBreakerLevel { /// Whether this level allows minting (external → pUSD). - pub fn allows_minting(&self) -> bool { + pub const fn allows_minting(&self) -> bool { matches!(self, CircuitBreakerLevel::AllEnabled) } /// Whether this level allows redemption (pUSD → external). - pub fn allows_redemption(&self) -> bool { + pub const fn allows_redemption(&self) -> bool { !matches!(self, CircuitBreakerLevel::AllDisabled) } } @@ -185,28 +185,29 @@ pub mod pallet { impl PsmManagerLevel { /// Whether this level allows modifying minting/redemption fees. - pub fn can_set_fees(&self) -> bool { + pub const fn can_set_fees(&self) -> bool { matches!(self, PsmManagerLevel::Full) } /// Whether this level allows modifying the circuit breaker status. /// Both Full and Emergency levels can set circuit breaker. - pub fn can_set_circuit_breaker(&self) -> bool { + pub const fn can_set_circuit_breaker(&self) -> bool { true } /// Whether this level allows modifying the global PSM debt ratio. - pub fn can_set_max_psm_debt(&self) -> bool { + pub const fn can_set_max_psm_debt(&self) -> bool { matches!(self, PsmManagerLevel::Full) } /// Whether this level allows modifying per-asset ceiling weights. - pub fn can_set_asset_ceiling(&self) -> bool { - matches!(self, PsmManagerLevel::Full) + /// Both Full and Emergency levels can set asset ceilings. + pub const fn can_set_asset_ceiling(&self) -> bool { + true } /// Whether this level allows adding or removing external assets. - pub fn can_manage_assets(&self) -> bool { + pub const fn can_manage_assets(&self) -> bool { matches!(self, PsmManagerLevel::Full) } } diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index 1c571ba4f8217..a868e19793ebb 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -936,20 +936,17 @@ mod governance { } #[test] - fn emergency_origin_cannot_set_asset_ceiling_weight() { + fn emergency_origin_can_set_asset_ceiling_weight() { new_test_ext().execute_with(|| { - let old_ratio = AssetCeilingWeight::::get(USDC_ASSET_ID); + let new_ratio = Permill::from_percent(80); - assert_noop!( - Psm::set_asset_ceiling_weight( - RuntimeOrigin::signed(EMERGENCY_ACCOUNT), - USDC_ASSET_ID, - Permill::from_percent(80) - ), - Error::::InsufficientPrivilege - ); + assert_ok!(Psm::set_asset_ceiling_weight( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + USDC_ASSET_ID, + new_ratio + )); - assert_eq!(AssetCeilingWeight::::get(USDC_ASSET_ID), old_ratio); + assert_eq!(AssetCeilingWeight::::get(USDC_ASSET_ID), new_ratio); }); } From 8475fb485492c715802954995bccfd9763166afe Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Wed, 1 Apr 2026 05:54:59 +0000 Subject: [PATCH 25/32] tests: add mint_ops to ExtBuilder --- substrate/frame/psm/src/mock.rs | 36 ++++++++++++++ substrate/frame/psm/src/tests.rs | 80 ++++++++++++-------------------- 2 files changed, 65 insertions(+), 51 deletions(-) diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs index 3fbb6062128c2..a1ec56fc7531d 100644 --- a/substrate/frame/psm/src/mock.rs +++ b/substrate/frame/psm/src/mock.rs @@ -239,6 +239,42 @@ pub fn new_test_ext() -> TestState { ext } +pub struct ExtBuilder { + mint_ops: Vec<(u64, u32, u128)>, +} + +impl Default for ExtBuilder { + fn default() -> Self { + Self { mint_ops: vec![] } + } +} + +impl ExtBuilder { + /// Queue a PSM mint: `who` mints `amount` of USDC. + pub fn mints(self, who: u64, amount: u128) -> Self { + self.mints_asset(who, USDC_ASSET_ID, amount) + } + + /// Queue a PSM mint of a specific asset. + pub fn mints_asset(mut self, who: u64, asset_id: u32, amount: u128) -> Self { + self.mint_ops.push((who, asset_id, amount)); + self + } + + pub fn build_and_execute(self, test: impl FnOnce()) { + new_test_ext().execute_with(|| { + for (who, asset_id, amount) in self.mint_ops { + frame_support::assert_ok!(crate::Pallet::::mint( + RuntimeOrigin::signed(who), + asset_id, + amount, + )); + } + test(); + }); + } +} + pub fn set_minting_fee(asset_id: u32, fee: Permill) { crate::MintingFee::::insert(asset_id, fee); } diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index a868e19793ebb..5ceb2267f3c26 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -278,16 +278,9 @@ mod mint { mod redeem { use super::*; - fn setup_for_redeem() { - let mint_amount = 5000 * PUSD_UNIT; - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount)); - } - #[test] fn success_basic() { - new_test_ext().execute_with(|| { - setup_for_redeem(); - + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { let redeem_amount = 1000 * PUSD_UNIT; let alice_pusd_before = get_asset_balance(PUSD_ASSET_ID, ALICE); let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); @@ -325,8 +318,7 @@ mod redeem { #[test] fn fee_zero() { - new_test_ext().execute_with(|| { - setup_for_redeem(); + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { set_redemption_fee(USDC_ASSET_ID, Permill::zero()); let redeem_amount = 1000 * PUSD_UNIT; @@ -340,8 +332,7 @@ mod redeem { #[test] fn fee_nonzero() { - new_test_ext().execute_with(|| { - setup_for_redeem(); + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { set_redemption_fee(USDC_ASSET_ID, Permill::from_percent(5)); let redeem_amount = 1000 * PUSD_UNIT; @@ -360,8 +351,7 @@ mod redeem { #[test] fn fee_100_percent() { - new_test_ext().execute_with(|| { - setup_for_redeem(); + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { set_redemption_fee(USDC_ASSET_ID, Permill::from_percent(100)); let redeem_amount = 1000 * PUSD_UNIT; @@ -380,9 +370,7 @@ mod redeem { #[test] fn fails_unsupported_asset() { - new_test_ext().execute_with(|| { - setup_for_redeem(); - + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { assert_noop!( Psm::redeem(RuntimeOrigin::signed(ALICE), UNSUPPORTED_ASSET_ID, 1000 * PUSD_UNIT), Error::::UnsupportedAsset @@ -392,8 +380,7 @@ mod redeem { #[test] fn fails_asset_all_disabled() { - new_test_ext().execute_with(|| { - setup_for_redeem(); + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::AllDisabled); assert_noop!( @@ -405,8 +392,7 @@ mod redeem { #[test] fn allows_when_asset_minting_disabled() { - new_test_ext().execute_with(|| { - setup_for_redeem(); + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); // Redemption should still work when only minting is disabled @@ -416,9 +402,7 @@ mod redeem { #[test] fn fails_below_minimum() { - new_test_ext().execute_with(|| { - setup_for_redeem(); - + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { let below_min = 50 * PUSD_UNIT; assert_noop!( @@ -445,33 +429,30 @@ mod redeem { #[test] fn fails_insufficient_pusd_balance() { - new_test_ext().execute_with(|| { - setup_for_redeem(); + ExtBuilder::default() + .mints(ALICE, 5000 * PUSD_UNIT) + .mints(BOB, 10_000 * PUSD_UNIT) + .build_and_execute(|| { + let alice_pusd = get_asset_balance(PUSD_ASSET_ID, ALICE); + let too_much = alice_pusd + 1000 * PUSD_UNIT; - let alice_pusd = get_asset_balance(PUSD_ASSET_ID, ALICE); - let too_much = alice_pusd + 1000 * PUSD_UNIT; + let debt_before = PsmDebt::::get(USDC_ASSET_ID); + let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); + let psm_usdc_before = get_asset_balance(USDC_ASSET_ID, psm_account()); - // Fund PSM with extra reserve so InsufficientReserve isn't hit first - fund_external_asset(USDC_ASSET_ID, psm_account(), 10_000 * PUSD_UNIT); + assert!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much).is_err()); - let debt_before = PsmDebt::::get(USDC_ASSET_ID); - let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); - let psm_usdc_before = get_asset_balance(USDC_ASSET_ID, psm_account()); - - assert!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much).is_err()); - - // Verify no state mutation occurred - assert_eq!(PsmDebt::::get(USDC_ASSET_ID), debt_before); - assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), alice_pusd); - assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before); - assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), psm_usdc_before); - }); + // Verify no state mutation occurred + assert_eq!(PsmDebt::::get(USDC_ASSET_ID), debt_before); + assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), alice_pusd); + assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before); + assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), psm_usdc_before); + }); } #[test] fn boundary_reserve_equals_output() { - new_test_ext().execute_with(|| { - setup_for_redeem(); + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { set_redemption_fee(USDC_ASSET_ID, Permill::zero()); let reserve = get_asset_balance(USDC_ASSET_ID, psm_account()); @@ -485,14 +466,14 @@ mod redeem { #[test] fn fails_when_reserve_exceeds_debt_donated_reserves() { - new_test_ext().execute_with(|| { - setup_for_redeem(); + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { set_redemption_fee(USDC_ASSET_ID, Permill::zero()); let debt = PsmDebt::::get(USDC_ASSET_ID); let donation = 5000 * PUSD_UNIT; - // Donate extra external stablecoin directly to PSM (bypassing mint) + // Defensive path: simulate donated reserves by funding psm_account() + // directly, bypassing mint to create a reserve > debt scenario. fund_external_asset(USDC_ASSET_ID, psm_account(), donation); let reserve = get_asset_balance(USDC_ASSET_ID, psm_account()); @@ -856,10 +837,7 @@ mod governance { #[test] fn remove_external_asset_fails_has_debt() { - new_test_ext().execute_with(|| { - fund_external_asset(USDC_ASSET_ID, ALICE, 1000 * PUSD_UNIT); - assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * PUSD_UNIT)); - + ExtBuilder::default().mints(ALICE, 1000 * PUSD_UNIT).build_and_execute(|| { assert_noop!( Psm::remove_external_asset(RuntimeOrigin::root(), USDC_ASSET_ID), Error::::AssetHasDebt From dae84b543fd1a03db6714833c60a3cba91b4c3be Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Wed, 1 Apr 2026 06:39:45 +0000 Subject: [PATCH 26/32] feat: use UncheckedMigrate pattern --- substrate/frame/psm/src/migrations/v1.rs | 69 +++++++++--------------- 1 file changed, 25 insertions(+), 44 deletions(-) diff --git a/substrate/frame/psm/src/migrations/v1.rs b/substrate/frame/psm/src/migrations/v1.rs index 8ce4bc0d202a9..ad146fe65ffec 100644 --- a/substrate/frame/psm/src/migrations/v1.rs +++ b/substrate/frame/psm/src/migrations/v1.rs @@ -37,8 +37,11 @@ use alloc::collections::btree_map::BTreeMap; #[cfg(feature = "try-runtime")] use alloc::vec::Vec; use frame_support::{ - pallet_prelude::{Get, StorageVersion, Weight}, - traits::{GetStorageVersion, UncheckedOnRuntimeUpgrade}, + pallet_prelude::{Get, Weight}, + traits::{ + fungible::metadata::Inspect as FungibleMetadataInspect, + fungibles::metadata::Inspect as FungiblesMetadataInspect, UncheckedOnRuntimeUpgrade, + }, }; use sp_runtime::Permill; @@ -81,22 +84,18 @@ pub trait InitialPsmConfig { /// 2. Sets approved external assets with `AllEnabled` status /// 3. Sets per-asset fee and ceiling-weight configuration /// 4. Ensures the PSM account exists -/// -/// Only runs if the on-chain storage version is 0 (uninitialized). -pub struct MigrateToV1(core::marker::PhantomData<(T, I)>); +pub type MigrateToV1 = frame_support::migrations::VersionedMigration< + 0, + 1, + UncheckedMigrateToV1, + Pallet, + ::DbWeight, +>; -impl> UncheckedOnRuntimeUpgrade for MigrateToV1 { - fn on_runtime_upgrade() -> Weight { - let on_chain_version = Pallet::::on_chain_storage_version(); - if on_chain_version != 0 { - log::info!( - target: LOG_TARGET, - "Skipping migration: on-chain version is {:?}, expected 0", - on_chain_version - ); - return T::DbWeight::get().reads(1); - } +pub struct UncheckedMigrateToV1(core::marker::PhantomData<(T, I)>); +impl> UncheckedOnRuntimeUpgrade for UncheckedMigrateToV1 { + fn on_runtime_upgrade() -> Weight { log::info!( target: LOG_TARGET, "Running MigrateToV1: initializing PSM pallet parameters" @@ -106,7 +105,13 @@ impl> UncheckedOnRuntimeUpgrade for MigrateToV MaxPsmDebtOfTotal::::put(I::max_psm_debt_of_total()); + let stable_decimals = T::StableAsset::decimals(); for (asset_id, (minting_fee, redemption_fee, max_asset_debt_ratio)) in &asset_configs { + assert!( + T::Fungibles::decimals(*asset_id) == stable_decimals, + "PSM migration: asset {:?} decimals do not match stable asset decimals", + asset_id, + ); ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); MintingFee::::insert(asset_id, minting_fee); RedemptionFee::::insert(asset_id, redemption_fee); @@ -116,30 +121,23 @@ impl> UncheckedOnRuntimeUpgrade for MigrateToV Pallet::::ensure_account_exists(&Pallet::::account_id()); Pallet::::ensure_account_exists(&T::FeeDestination::get()); - StorageVersion::new(1).put::>(); - log::info!( target: LOG_TARGET, "MigrateToV1 complete" ); - // 1 read + (MaxPsmDebtOfTotal + StorageVersion + 2 accounts) + 4 writes per asset - let writes = 4u64.saturating_add((asset_configs.len() as u64).saturating_mul(4)); - T::DbWeight::get().reads_writes(1, writes) + // (MaxPsmDebtOfTotal + 2 accounts) + 4 writes per asset + let writes = 3u64.saturating_add((asset_configs.len() as u64).saturating_mul(4)); + T::DbWeight::get().writes(writes) } #[cfg(feature = "try-runtime")] fn pre_upgrade() -> Result, TryRuntimeError> { - let on_chain_version = Pallet::::on_chain_storage_version(); - ensure!(on_chain_version == 0, "Expected storage version 0 before migration"); Ok(Vec::new()) } #[cfg(feature = "try-runtime")] fn post_upgrade(_state: Vec) -> Result<(), TryRuntimeError> { - let on_chain_version = Pallet::::on_chain_storage_version(); - ensure!(on_chain_version == 1, "Expected storage version 1 after migration"); - ensure!( MaxPsmDebtOfTotal::::get() == I::max_psm_debt_of_total(), "MaxPsmDebtOfTotal mismatch after migration" @@ -213,8 +211,6 @@ mod tests { #[test] fn migration_v0_to_v1_works() { new_test_ext().execute_with(|| { - StorageVersion::new(0).put::>(); - MaxPsmDebtOfTotal::::kill(); ExternalAssets::::remove(USDC_ASSET_ID); ExternalAssets::::remove(USDT_ASSET_ID); @@ -225,7 +221,7 @@ mod tests { AssetCeilingWeight::::remove(USDC_ASSET_ID); AssetCeilingWeight::::remove(USDT_ASSET_ID); - let _weight = MigrateToV1::::on_runtime_upgrade(); + let _weight = UncheckedMigrateToV1::::on_runtime_upgrade(); assert_eq!(MaxPsmDebtOfTotal::::get(), TestPsmConfig::max_psm_debt_of_total()); @@ -240,21 +236,6 @@ mod tests { assert_eq!(RedemptionFee::::get(asset_id), redemption_fee); assert_eq!(AssetCeilingWeight::::get(asset_id), ceiling_weight); } - - assert_eq!(Pallet::::on_chain_storage_version(), 1); - }); - } - - #[test] - fn migration_skipped_if_already_v1() { - new_test_ext().execute_with(|| { - StorageVersion::new(1).put::>(); - let before = MaxPsmDebtOfTotal::::get(); - - let weight = MigrateToV1::::on_runtime_upgrade(); - - assert_eq!(MaxPsmDebtOfTotal::::get(), before); - assert_eq!(weight, ::DbWeight::get().reads(1)); }); } } From 039270602c2a259638ed0887859a72ff39f66384 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Wed, 1 Apr 2026 06:41:20 +0000 Subject: [PATCH 27/32] tests: address feedback --- substrate/frame/psm/src/tests.rs | 109 ++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 31 deletions(-) diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index 5ceb2267f3c26..392d2b856b353 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -20,7 +20,7 @@ use crate::{ AssetCeilingWeight, CircuitBreakerLevel, Error, Event, ExternalAssets, MaxPsmDebtOfTotal, MintingFee, PsmDebt, RedemptionFee, }; -use frame_support::{assert_noop, assert_ok}; +use frame_support::{assert_noop, assert_ok, hypothetically}; use sp_runtime::{DispatchError, Permill, TokenError}; mod mint { @@ -436,29 +436,22 @@ mod redeem { let alice_pusd = get_asset_balance(PUSD_ASSET_ID, ALICE); let too_much = alice_pusd + 1000 * PUSD_UNIT; - let debt_before = PsmDebt::::get(USDC_ASSET_ID); - let alice_usdc_before = get_asset_balance(USDC_ASSET_ID, ALICE); - let psm_usdc_before = get_asset_balance(USDC_ASSET_ID, psm_account()); - - assert!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much).is_err()); - - // Verify no state mutation occurred - assert_eq!(PsmDebt::::get(USDC_ASSET_ID), debt_before); - assert_eq!(get_asset_balance(PUSD_ASSET_ID, ALICE), alice_pusd); - assert_eq!(get_asset_balance(USDC_ASSET_ID, ALICE), alice_usdc_before); - assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), psm_usdc_before); + assert_noop!( + Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much), + TokenError::FundsUnavailable + ); }); } #[test] fn boundary_reserve_equals_output() { - ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { + new_test_ext().execute_with(|| { + set_minting_fee(USDC_ASSET_ID, Permill::zero()); set_redemption_fee(USDC_ASSET_ID, Permill::zero()); - let reserve = get_asset_balance(USDC_ASSET_ID, psm_account()); - fund_pusd(ALICE, reserve); - - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, reserve)); + let amount = 5000 * PUSD_UNIT; + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, amount)); assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), 0); }); @@ -489,11 +482,16 @@ mod redeem { Error::::InsufficientReserve ); - // Redeeming exactly the debt should work - assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, debt)); + // Verify boundary: exactly debt works, but debt+1 does not + hypothetically!({ + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, debt)); + assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), donation); + }); - // Donated reserves remain in PSM account (not withdrawable via redeem) - assert_eq!(get_asset_balance(USDC_ASSET_ID, psm_account()), donation); + assert_noop!( + Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, debt + 1), + Error::::InsufficientReserve + ); }); } } @@ -953,6 +951,48 @@ mod governance { assert!(Psm::is_approved_asset(&USDC_ASSET_ID)); }); } + + #[test] + fn set_minting_fee_fails_unapproved_asset() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::set_minting_fee( + RuntimeOrigin::root(), + UNSUPPORTED_ASSET_ID, + Permill::from_percent(5) + ), + Error::::AssetNotApproved + ); + }); + } + + #[test] + fn set_redemption_fee_fails_unapproved_asset() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::set_redemption_fee( + RuntimeOrigin::root(), + UNSUPPORTED_ASSET_ID, + Permill::from_percent(5) + ), + Error::::AssetNotApproved + ); + }); + } + + #[test] + fn set_asset_ceiling_weight_fails_unapproved_asset() { + new_test_ext().execute_with(|| { + assert_noop!( + Psm::set_asset_ceiling_weight( + RuntimeOrigin::root(), + UNSUPPORTED_ASSET_ID, + Permill::from_percent(50) + ), + Error::::AssetNotApproved + ); + }); + } } mod helpers { @@ -1078,27 +1118,34 @@ mod ceiling_redistribution { fn multiple_assets_share_redistributed_ceiling() { new_test_ext().execute_with(|| { // Add a third asset - let dai_asset_id = 4u32; - create_asset_with_metadata(dai_asset_id); - assert_ok!(Psm::add_external_asset(RuntimeOrigin::root(), dai_asset_id)); + 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)); - // Setup: USDC 50%, USDT 25%, DAI 25% + // Setup: USDC 50%, USDT 25%, ETH:USDC 25% set_max_psm_debt_ratio(Permill::from_percent(50)); 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(dai_asset_id, Permill::from_percent(25)); + set_asset_ceiling_weight(bridged_usdc_asset_id, Permill::from_percent(25)); - let max_psm = crate::Pallet::::max_psm_debt(); - assert_eq!(max_psm, 10_000_000 * PUSD_UNIT); + // PSM ceiling = 10M. USDC ceiling = 5M. + // Mint 4M against USDC: creating real debt before lowering ceiling. + fund_external_asset(USDC_ASSET_ID, ALICE, 4_000_000 * PUSD_UNIT); + assert_ok!(Psm::mint( + RuntimeOrigin::signed(ALICE), + USDC_ASSET_ID, + 4_000_000 * PUSD_UNIT + )); + assert_eq!(PsmDebt::::get(USDC_ASSET_ID), 4_000_000 * PUSD_UNIT); - // Disable USDC and set ratio to 0% + // Now disable USDC and set ratio to 0% set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(0)); - // Now USDT and DAI split the full ceiling + // USDT and ETH:USDC now split the full ceiling // total_ratio_sum = 0% + 25% + 25% = 50% // USDT effective_ratio = 25% / 50% = 50% -> 5M ceiling - // DAI effective_ratio = 25% / 50% = 50% -> 5M ceiling + // ETH:USDC effective_ratio = 25% / 50% = 50% -> 5M ceiling fund_external_asset(USDT_ASSET_ID, ALICE, 6_000_000 * PUSD_UNIT); From 513751e132badfc89d4429077e999c31fe03e248 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Wed, 1 Apr 2026 17:19:49 +0000 Subject: [PATCH 28/32] tests: run do_try_state in the mock --- substrate/frame/psm/src/mock.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs index a1ec56fc7531d..1a964d9efff29 100644 --- a/substrate/frame/psm/src/mock.rs +++ b/substrate/frame/psm/src/mock.rs @@ -271,6 +271,7 @@ impl ExtBuilder { )); } test(); + crate::Pallet::::do_try_state().expect("try_state post-condition failed"); }); } } From 9a4e8833e9867b151441b16556778f232cd8dab6 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Wed, 1 Apr 2026 17:21:16 +0000 Subject: [PATCH 29/32] doc: clarify manager level rights --- substrate/frame/psm/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index af293678ef329..3da018aa27273 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -179,7 +179,7 @@ pub mod pallet { #[default] Full, /// Emergency access via EmergencyAction origin. - /// Can only modify circuit breaker status. + /// Can modify circuit breaker status and asset ceiling weights. Emergency, } From e40a9b35ed7238b32f825ce34e4831f77c67da36 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Wed, 1 Apr 2026 17:26:09 +0000 Subject: [PATCH 30/32] chore: markdown lint --- substrate/frame/psm/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/substrate/frame/psm/README.md b/substrate/frame/psm/README.md index 6080669d4659e..83475b6da3221 100644 --- a/substrate/frame/psm/README.md +++ b/substrate/frame/psm/README.md @@ -14,9 +14,11 @@ The PSM pallet allows users to swap external stablecoins (e.g., USDC, USDT) for ## Swap Lifecycle ### 1. Mint (External -> pUSD) + ```rust mint(origin, asset_id, external_amount) ``` + - Deposits external stablecoin into the PSM account - Mints pUSD to the user (minus minting fee) - Fee is minted as pUSD and transferred to `FeeDestination` @@ -24,9 +26,11 @@ mint(origin, asset_id, external_amount) - Requires `external_amount >= MinSwapAmount` ### 2. Redeem (pUSD -> External) + ```rust redeem(origin, asset_id, pusd_amount) ``` + - Burns pUSD 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 pUSD to `FeeDestination` @@ -91,6 +95,7 @@ The `set_asset_status` extrinsic can be called by both `GeneralAdmin` and `Emerg ### Privilege Levels The `ManagerOrigin` returns a privilege level: + - **Full** (via GeneralAdmin): Can modify all parameters - **Emergency** (via EmergencyAction): Can only modify circuit breaker status @@ -183,6 +188,7 @@ Typical runtime helpers used in the configuration above: ## Testing Run tests with: + ```bash SKIP_WASM_BUILD=1 cargo test -p pallet-psm ``` From d226b43c44a09ff8df09533f69b9125baf3ab0f9 Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Wed, 1 Apr 2026 17:33:01 +0000 Subject: [PATCH 31/32] chore: adjust ratio vs weight terminology --- substrate/frame/psm/src/lib.rs | 50 ++++++++++++------------ substrate/frame/psm/src/migrations/v1.rs | 4 +- substrate/frame/psm/src/tests.rs | 27 +++++++------ 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/substrate/frame/psm/src/lib.rs b/substrate/frame/psm/src/lib.rs index 3da018aa27273..0485d7f0c955e 100644 --- a/substrate/frame/psm/src/lib.rs +++ b/substrate/frame/psm/src/lib.rs @@ -333,7 +333,7 @@ pub mod pallet { /// 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, - /// max_asset_debt_ratio). Keys also define the set of approved external assets. + /// ceiling_weight). Keys also define the set of approved external assets. pub asset_configs: BTreeMap, #[serde(skip)] pub _marker: core::marker::PhantomData, @@ -350,9 +350,7 @@ pub mod pallet { ); MaxPsmDebtOfTotal::::put(self.max_psm_debt_of_total); let stable_decimals = T::StableAsset::decimals(); - for (asset_id, (minting_fee, redemption_fee, max_asset_debt_ratio)) in - &self.asset_configs - { + for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in &self.asset_configs { assert!( T::Fungibles::decimals(*asset_id) == stable_decimals, "PSM genesis: asset {:?} decimals do not match stable asset decimals", @@ -361,7 +359,7 @@ pub mod pallet { ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); MintingFee::::insert(asset_id, minting_fee); RedemptionFee::::insert(asset_id, redemption_fee); - AssetCeilingWeight::::insert(asset_id, max_asset_debt_ratio); + AssetCeilingWeight::::insert(asset_id, ceiling_weight); } Pallet::::ensure_account_exists(&Pallet::::account_id()); Pallet::::ensure_account_exists(&T::FeeDestination::get()); @@ -393,7 +391,7 @@ pub mod pallet { 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 }, - /// Per-asset debt ceiling ratio updated by governance. + /// Per-asset debt ceiling weight updated by governance. AssetCeilingWeightUpdated { asset_id: T::AssetId, old_value: Permill, new_value: Permill }, /// Per-asset circuit breaker status updated. AssetStatusUpdated { asset_id: T::AssetId, status: CircuitBreakerLevel }, @@ -758,7 +756,7 @@ pub mod pallet { Ok(()) } - /// Set the per-asset debt ceiling ratio. + /// Set the per-asset debt ceiling weight. /// /// ## Dispatch Origin /// @@ -766,13 +764,16 @@ pub mod pallet { /// /// ## Details /// - /// The per-asset ceiling is calculated as: - /// `max_asset_debt = ratio * MaxPsmDebtOfTotal * MaximumIssuance` + /// Ratios act as weights normalized against the sum of all asset weights: + /// `max_asset_debt = (ratio / sum_of_all_ratios) * MaxPsmDebtOfTotal * MaximumIssuance` + /// + /// With a single asset, the weight always normalizes to 100% of the PSM + /// ceiling. /// /// ## Parameters /// /// - `asset_id`: The external stablecoin to configure - /// - `ratio`: Percentage of total PSM ceiling allocated to this asset + /// - `ratio`: Weight for this asset's share of the total PSM ceiling /// /// ## Events /// @@ -782,17 +783,17 @@ pub mod pallet { pub fn set_asset_ceiling_weight( origin: OriginFor, asset_id: T::AssetId, - ratio: Permill, + 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, ratio); + AssetCeilingWeight::::insert(asset_id, weight); Self::deposit_event(Event::AssetCeilingWeightUpdated { asset_id, old_value, - new_value: ratio, + new_value: weight, }); Ok(()) } @@ -894,28 +895,27 @@ pub mod pallet { /// /// Assumes the caller has verified the asset is approved and `AllEnabled`. /// - /// Returns zero if the asset has no configured ratio or the ratio is zero. + /// Returns zero if the asset has no configured weight or the weight is zero. /// - /// Ratios act as weights that normalize to fill the PSM ceiling. When an - /// asset is disabled, governance should set its ratio to 0% so its ceiling - /// allocation is automatically redistributed to other assets. + /// 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_ratio = AssetCeilingWeight::::get(asset_id); + let asset_weight = AssetCeilingWeight::::get(asset_id); - if asset_ratio.is_zero() { + if asset_weight.is_zero() { return BalanceOf::::zero(); } - let total_ratio_sum: u32 = AssetCeilingWeight::::iter_values() - .map(|r| r.deconstruct()) + let total_weight_sum: u32 = AssetCeilingWeight::::iter_values() + .map(|w| w.deconstruct()) .fold(0u32, |acc, x| acc.saturating_add(x)); - if total_ratio_sum == 0 { + if total_weight_sum == 0 { return BalanceOf::::zero(); } let total_psm_ceiling = Self::max_psm_debt(); - Perbill::from_rational(asset_ratio.deconstruct(), total_ratio_sum) + Perbill::from_rational(asset_weight.deconstruct(), total_weight_sum) .mul_floor(total_psm_ceiling) } @@ -943,8 +943,8 @@ pub mod pallet { } } - #[cfg(feature = "try-runtime")] - fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { + #[cfg(any(feature = "try-runtime", test))] + pub(crate) fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { use sp_runtime::traits::CheckedAdd; let stable_decimals = T::StableAsset::decimals(); diff --git a/substrate/frame/psm/src/migrations/v1.rs b/substrate/frame/psm/src/migrations/v1.rs index ad146fe65ffec..48e22808f2cb3 100644 --- a/substrate/frame/psm/src/migrations/v1.rs +++ b/substrate/frame/psm/src/migrations/v1.rs @@ -106,7 +106,7 @@ impl> UncheckedOnRuntimeUpgrade for UncheckedM MaxPsmDebtOfTotal::::put(I::max_psm_debt_of_total()); let stable_decimals = T::StableAsset::decimals(); - for (asset_id, (minting_fee, redemption_fee, max_asset_debt_ratio)) in &asset_configs { + for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in &asset_configs { assert!( T::Fungibles::decimals(*asset_id) == stable_decimals, "PSM migration: asset {:?} decimals do not match stable asset decimals", @@ -115,7 +115,7 @@ impl> UncheckedOnRuntimeUpgrade for UncheckedM ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); MintingFee::::insert(asset_id, minting_fee); RedemptionFee::::insert(asset_id, redemption_fee); - AssetCeilingWeight::::insert(asset_id, max_asset_debt_ratio); + AssetCeilingWeight::::insert(asset_id, ceiling_weight); } Pallet::::ensure_account_exists(&Pallet::::account_id()); diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index 392d2b856b353..9f2ce2601311c 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -1070,7 +1070,7 @@ mod ceiling_redistribution { use super::*; #[test] - fn zero_ratio_redistributes_ceiling_to_others() { + fn zero_weight_redistributes_ceiling_to_others() { new_test_ext().execute_with(|| { // Setup: USDC 60%, USDT 40% of PSM ceiling // PSM ceiling = 50% of 20M = 10M @@ -1087,13 +1087,13 @@ mod ceiling_redistribution { let usdt_normal_ceiling = crate::Pallet::::max_asset_debt(USDT_ASSET_ID); assert_eq!(usdt_normal_ceiling, 4_000_000 * PUSD_UNIT); - // Disable USDC minting and set ratio to 0% (governance workflow) + // Disable USDC minting and set weight to 0% (governance workflow) set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(0)); // Now USDT should be able to use the full PSM ceiling - // total_ratio_sum = 0% + 40% = 40% - // effective_ratio = 40% / 40% = 100% + // total_weight_sum = 0% + 40% = 40% + // effective_weight = 40% / 40% = 100% // effective_ceiling = 100% of 10M = 10M fund_external_asset(USDT_ASSET_ID, BOB, 10_000_000 * PUSD_UNIT); @@ -1138,14 +1138,14 @@ mod ceiling_redistribution { )); assert_eq!(PsmDebt::::get(USDC_ASSET_ID), 4_000_000 * PUSD_UNIT); - // Now disable USDC and set ratio to 0% + // Now disable USDC and set weight to 0% set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(0)); // USDT and ETH:USDC now split the full ceiling - // total_ratio_sum = 0% + 25% + 25% = 50% - // USDT effective_ratio = 25% / 50% = 50% -> 5M ceiling - // ETH:USDC effective_ratio = 25% / 50% = 50% -> 5M ceiling + // total_weight_sum = 0% + 25% + 25% = 50% + // USDT effective_weight = 25% / 50% = 50% -> 5M ceiling + // ETH:USDC effective_weight = 25% / 50% = 50% -> 5M ceiling fund_external_asset(USDT_ASSET_ID, ALICE, 6_000_000 * PUSD_UNIT); @@ -1165,14 +1165,14 @@ mod ceiling_redistribution { } #[test] - fn normal_ratios_use_proportional_ceilings() { + 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_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(60)); set_asset_ceiling_weight(USDT_ASSET_ID, Permill::from_percent(40)); - // Both assets have non-zero ratios - should use proportional ceilings + // Both assets have non-zero weights, should use proportional ceilings // USDT ceiling = 40% of 10M = 4M fund_external_asset(USDT_ASSET_ID, BOB, 5_000_000 * PUSD_UNIT); @@ -1189,7 +1189,8 @@ mod ceiling_redistribution { } #[test] - fn restoring_ratio_restores_normal_ceilings() { + #[test] + 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)); @@ -1198,12 +1199,12 @@ mod ceiling_redistribution { fund_external_asset(USDT_ASSET_ID, BOB, 10_000_000 * PUSD_UNIT); - // Disable USDC and set ratio to 0% - USDT can use full ceiling + // Disable USDC and set weight to 0% - USDT can use full ceiling set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(0)); assert_ok!(Psm::mint(RuntimeOrigin::signed(BOB), USDT_ASSET_ID, 5_000_000 * PUSD_UNIT)); - // Re-enable USDC and restore ratio + // Re-enable USDC and restore weight set_asset_status(USDC_ASSET_ID, CircuitBreakerLevel::AllEnabled); set_asset_ceiling_weight(USDC_ASSET_ID, Permill::from_percent(60)); From f4e11114718f7e88f23f674da395e6f1be3381ec Mon Sep 17 00:00:00 2001 From: Leonardo Razovic Date: Wed, 1 Apr 2026 17:33:39 +0000 Subject: [PATCH 32/32] tests: add new test to check what happens when setting weight to 0% --- substrate/frame/psm/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/substrate/frame/psm/src/tests.rs b/substrate/frame/psm/src/tests.rs index 9f2ce2601311c..30cc7966f65eb 100644 --- a/substrate/frame/psm/src/tests.rs +++ b/substrate/frame/psm/src/tests.rs @@ -1189,6 +1189,37 @@ mod ceiling_redistribution { } #[test] + 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)); + + set_max_psm_debt_ratio(Permill::from_percent(50)); + // PSM ceiling = 50% of 20M = 10M + let mint_amount = 1000 * PUSD_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); + assert_eq!(ceiling_at_30, 10_000_000 * PUSD_UNIT); + assert_ok!(Psm::mint(RuntimeOrigin::signed(ALICE), 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); + assert_eq!(ceiling_at_80, 10_000_000 * PUSD_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); + assert_eq!(ceiling_at_0, 0); + assert_noop!( + Psm::mint(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, mint_amount), + Error::::ExceedsMaxPsmDebt + ); + }); + } + #[test] fn restoring_weight_restores_normal_ceilings() { new_test_ext().execute_with(|| {