diff --git a/Cargo.lock b/Cargo.lock index 9944660da045a..771511a650d0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13255,6 +13255,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 30.0.0", + "sp-runtime 31.0.1", +] + [[package]] name = "pallet-ranked-collective" version = "28.0.0" @@ -16627,6 +16643,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 6630f35ad1882..ac4a506e7bb70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -414,6 +414,7 @@ members = [ "substrate/frame/people", "substrate/frame/preimage", "substrate/frame/proxy", + "substrate/frame/psm", "substrate/frame/ranked-collective", "substrate/frame/recovery", "substrate/frame/referenda", @@ -1039,6 +1040,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/prdoc/pr_11068.prdoc b/prdoc/pr_11068.prdoc new file mode 100644 index 0000000000000..372933a7d8532 --- /dev/null +++ b/prdoc/pr_11068.prdoc @@ -0,0 +1,24 @@ +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 + +crates: +- name: pallet-psm + bump: major +- name: kitchensink-runtime + bump: major +- name: frame-support + bump: minor +- name: polkadot-sdk + bump: minor diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs index 835b56f82394b..7d3ef47a27cb5 100644 --- a/substrate/bin/node/runtime/src/lib.rs +++ b/substrate/bin/node/runtime/src/lib.rs @@ -2881,6 +2881,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; @@ -3063,6 +3066,88 @@ 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; + +parameter_types! { + /// No debt ceiling: maximum possible issuance. + pub const NoVaultsCeiling: Balance = Balance::MAX; +} + +/// EnsureOrigin implementation for PSM management that supports privilege levels. +pub struct EnsurePsmManager; +impl frame_support::traits::EnsureOrigin for EnsurePsmManager { + type Success = pallet_psm::PsmManagerLevel; + + fn try_origin(o: RuntimeOrigin) -> Result { + use frame_system::RawOrigin; + + match o.clone().into() { + Ok(RawOrigin::Root) => Ok(pallet_psm::PsmManagerLevel::Full), + _ => Err(o), + } + } + + #[cfg(feature = "runtime-benchmarks")] + fn try_successful_origin() -> Result { + Ok(RuntimeOrigin::root()) + } +} + +#[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; + type AssetId = u32; + type MaximumIssuance = NoVaultsCeiling; + type ManagerOrigin = EnsurePsmManager; + type WeightInfo = pallet_psm::weights::SubstrateWeight; + type StableAsset = PsmStableAsset; + type FeeDestination = PsmInsuranceFundAccount; + type PalletId = PsmPalletId; + type MinSwapAmount = PsmMinSwapAmount; + type MaxExternalAssets = ConstU32<10>; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = PsmBenchmarkHelper; +} + /// MMR helper types. mod mmr { use super::*; @@ -3208,6 +3293,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..83475b6da3221 --- /dev/null +++ b/substrate/frame/psm/README.md @@ -0,0 +1,194 @@ +# 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 +- **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 + +### 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` +- 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 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` +- 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) and transferred as pUSD to `FeeDestination`: + +- **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. + +## 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 with matching decimals; 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)` + +### 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 Fungibles = Assets; + type AssetId = u32; + type MaximumIssuance = MaximumIssuance; + type ManagerOrigin = EnsurePsmManager; + type WeightInfo = weights::SubstrateWeight; + type StableAsset = frame_support::traits::fungible::ItemOf< + Assets, + StablecoinAssetId, + AccountId, + >; + 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. `MaximumIssuance` provides the system-wide pUSD cap (typically from the Vaults pallet or a constant). + +### 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 + +- `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`: Account that receives pUSD fees via `FeeDestination` + +## 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 +- `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 + +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..9484a47817eea --- /dev/null +++ b/substrate/frame/psm/src/benchmarking.rs @@ -0,0 +1,212 @@ +// 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::{ + fungible::{metadata::Inspect, Create as FungibleCreate, Inspect as FungibleInspect}, + fungibles::{ + Create as FungiblesCreate, Inspect as FungiblesInspect, Mutate as FungiblesMutate, + }, + Get, + }, +}; +use frame_system::RawOrigin; +use pallet::BalanceOf; +use sp_runtime::{traits::Zero, Permill, Saturating}; + +/// Offset for benchmark asset IDs, chosen to avoid collision with typical +/// genesis asset IDs (e.g. 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, +/// 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::StableAsset: FungibleCreate, + 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())); + } + 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 { + 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::StableAsset: FungibleCreate, 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(()) + } + #[benchmark] + fn add_external_asset() -> Result<(), BenchmarkError> { + let caller: T::AccountId = whitelisted_caller(); + let new_asset_id: T::AssetId = ASSET_ID_OFFSET.into(); + + T::BenchmarkHelper::create_asset(new_asset_id, &caller, T::StableAsset::decimals()); + + #[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..0485d7f0c955e --- /dev/null +++ b/substrate/frame/psm/src/lib.rs @@ -0,0 +1,1002 @@ +// 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 transferred to [`Config::FeeDestination`]. +//! +//! ### 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 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; + + use alloc::collections::btree_map::BTreeMap; + use codec::DecodeWithMemTracking; + use frame_support::{ + pallet_prelude::*, + traits::{ + fungible::{ + metadata::Inspect as FungibleMetadataInspect, Inspect as FungibleInspect, + Mutate as FungibleMutate, + }, + fungibles::{ + metadata::Inspect as FungiblesMetadataInspect, Inspect as FungiblesInspect, + Mutate as FungiblesMutate, + }, + tokens::{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, + } + + impl CircuitBreakerLevel { + /// Whether this level allows minting (external → pUSD). + pub const fn allows_minting(&self) -> bool { + matches!(self, CircuitBreakerLevel::AllEnabled) + } + + /// Whether this level allows redemption (pUSD → external). + pub const fn allows_redemption(&self) -> bool { + !matches!(self, CircuitBreakerLevel::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 modify circuit breaker status and asset ceiling weights. + Emergency, + } + + impl PsmManagerLevel { + /// Whether this level allows modifying minting/redemption fees. + 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 const fn can_set_circuit_breaker(&self) -> bool { + true + } + + /// Whether this level allows modifying the global PSM debt ratio. + pub const fn can_set_max_psm_debt(&self) -> bool { + matches!(self, PsmManagerLevel::Full) + } + + /// Whether this level allows modifying per-asset ceiling weights. + /// 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 const fn can_manage_assets(&self) -> bool { + matches!(self, PsmManagerLevel::Full) + } + } + + 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 + + FungiblesMetadataInspect; + + /// Asset identifier type. + type AssetId: Parameter + Member + Copy + MaybeSerializeDeserialize + MaxEncodedLen + Ord; + + /// Maximum allowed pUSD issuance across the entire system. + type MaximumIssuance: Get>; + + /// 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 pUSD asset as a single-asset `fungible` type. + /// + /// Typically `ItemOf`. + /// Must use the same `Balance` type as `Asset`. + type StableAsset: FungibleMutate> + + FungibleMetadataInspect; + + /// Account that receives pUSD fees from minting and redemption. + /// + /// Must exist before any swap; initialized at genesis and migration + /// via [`Pallet::ensure_account_exists`]. + type FeeDestination: Get; + + /// PalletId for deriving the PSM account. + #[pallet::constant] + type PalletId: Get; + + /// Minimum swap amount. + #[pallet::constant] + type MinSwapAmount: Get>; + + /// 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. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + + #[pallet::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 = + 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 = + CountedStorageMap<_, 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, + /// ceiling_weight). Keys also define the set of approved external assets. + pub asset_configs: BTreeMap, + #[serde(skip)] + pub _marker: core::marker::PhantomData, + } + + #[pallet::genesis_build] + impl BuildGenesisConfig for GenesisConfig { + fn build(&self) { + assert!( + self.asset_configs.len() as u32 <= T::MaxExternalAssets::get(), + "PSM genesis: asset_configs ({}) exceeds MaxExternalAssets ({})", + self.asset_configs.len(), + T::MaxExternalAssets::get(), + ); + MaxPsmDebtOfTotal::::put(self.max_psm_debt_of_total); + let stable_decimals = T::StableAsset::decimals(); + 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", + asset_id, + ); + ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); + MintingFee::::insert(asset_id, minting_fee); + RedemptionFee::::insert(asset_id, redemption_fee); + AssetCeilingWeight::::insert(asset_id, ceiling_weight); + } + Pallet::::ensure_account_exists(&Pallet::::account_id()); + Pallet::::ensure_account_exists(&T::FeeDestination::get()); + } + } + + #[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 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 }, + /// 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, + /// External asset decimals do not match the stable asset decimals. + DecimalsMismatch, + /// An unexpected invariant violation occurred. This should be reported. + Unexpected, + } + + #[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 transferred to [`Config::FeeDestination`]. + /// + /// ## 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.allows_minting(), Error::::MintingStopped); + + ensure!(external_amount >= T::MinSwapAmount::get(), Error::::BelowMinimumSwap); + + 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::MaximumIssuance::get(); + 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, + )?; + T::StableAsset::mint_into(&who, pusd_to_user)?; + if !fee.is_zero() { + T::StableAsset::mint_into(&T::FeeDestination::get(), fee)?; + } + + 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 (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 + /// + /// - `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.allows_redemption(), 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); + + let reserve = Self::get_reserve(asset_id); + 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( + &who, + external_to_user, + Preservation::Expendable, + Precision::Exact, + Fortitude::Polite, + )?; + if !fee.is_zero() { + T::StableAsset::transfer( + &who, + &T::FeeDestination::get(), + fee, + Preservation::Expendable, + )?; + } + + 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.can_set_fees(), Error::::InsufficientPrivilege); + ensure!(ExternalAssets::::contains_key(asset_id), Error::::AssetNotApproved); + let old_value = MintingFee::::get(asset_id); + MintingFee::::insert(asset_id, fee); + Self::deposit_event(Event::MintingFeeUpdated { asset_id, old_value, new_value: fee }); + 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.can_set_fees(), Error::::InsufficientPrivilege); + ensure!(ExternalAssets::::contains_key(asset_id), Error::::AssetNotApproved); + let old_value = RedemptionFee::::get(asset_id); + RedemptionFee::::insert(asset_id, fee); + Self::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.can_set_max_psm_debt(), Error::::InsufficientPrivilege); + let old_value = MaxPsmDebtOfTotal::::get(); + MaxPsmDebtOfTotal::::put(ratio); + Self::deposit_event(Event::MaxPsmDebtOfTotalUpdated { old_value, new_value: ratio }); + Ok(()) + } + + /// 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 weight. + /// + /// ## Dispatch Origin + /// + /// Must be [`Config::ManagerOrigin`]. + /// + /// ## Details + /// + /// 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`: Weight for this asset's share of the total PSM ceiling + /// + /// ## 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, + weight: Permill, + ) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level.can_set_asset_ceiling(), Error::::InsufficientPrivilege); + ensure!(ExternalAssets::::contains_key(asset_id), Error::::AssetNotApproved); + let old_value = AssetCeilingWeight::::get(asset_id); + AssetCeilingWeight::::insert(asset_id, weight); + Self::deposit_event(Event::AssetCeilingWeightUpdated { + asset_id, + old_value, + new_value: weight, + }); + 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())] + pub fn add_external_asset(origin: OriginFor, asset_id: T::AssetId) -> DispatchResult { + let level = T::ManagerOrigin::ensure_origin(origin)?; + ensure!(level.can_manage_assets(), Error::::InsufficientPrivilege); + ensure!(!ExternalAssets::::contains_key(asset_id), Error::::AssetAlreadyApproved); + let count = ExternalAssets::::count(); + 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(()) + } + + /// 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.can_manage_assets(), Error::::InsufficientPrivilege); + ensure!(ExternalAssets::::contains_key(asset_id), Error::::AssetNotApproved); + ensure!(PsmDebt::::get(asset_id).is_zero(), Error::::AssetHasDebt); + ExternalAssets::::remove(asset_id); + + // Clean up associated configuration + MintingFee::::remove(asset_id); + RedemptionFee::::remove(asset_id); + AssetCeilingWeight::::remove(asset_id); + PsmDebt::::remove(asset_id); + Self::deposit_event(Event::ExternalAssetRemoved { asset_id }); + Ok(()) + } + } + + impl Pallet { + /// Get the PSM's derived account. + pub(crate) fn account_id() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + + /// Calculate max PSM debt based on system ceiling. + pub(crate) fn max_psm_debt() -> BalanceOf { + let max_issuance = T::MaximumIssuance::get(); + MaxPsmDebtOfTotal::::get().mul_floor(max_issuance) + } + + /// Calculate max debt for a specific asset. + /// + /// Assumes the caller has verified the asset is approved and `AllEnabled`. + /// + /// Returns zero if the asset has no configured weight or the weight is zero. + /// + /// Weights are normalized against the sum of all asset weights to fill the + /// PSM ceiling. + pub(crate) fn max_asset_debt(asset_id: T::AssetId) -> BalanceOf { + let asset_weight = AssetCeilingWeight::::get(asset_id); + + if asset_weight.is_zero() { + return BalanceOf::::zero(); + } + + let total_weight_sum: u32 = AssetCeilingWeight::::iter_values() + .map(|w| w.deconstruct()) + .fold(0u32, |acc, x| acc.saturating_add(x)); + + if total_weight_sum == 0 { + return BalanceOf::::zero(); + } + + let total_psm_ceiling = Self::max_psm_debt(); + Perbill::from_rational(asset_weight.deconstruct(), total_weight_sum) + .mul_floor(total_psm_ceiling) + } + + /// Calculate total PSM debt across all approved assets. + 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. + #[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(crate) fn get_reserve(asset_id: T::AssetId) -> BalanceOf { + T::Fungibles::balance(asset_id, &Self::account_id()) + } + + /// 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); + } + } + + #[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(); + + // 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(()) + } + } +} + +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..48e22808f2cb3 --- /dev/null +++ b/substrate/frame/psm/src/migrations/v1.rs @@ -0,0 +1,241 @@ +// 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; +#[cfg(feature = "try-runtime")] +use alloc::vec::Vec; +use frame_support::{ + pallet_prelude::{Get, Weight}, + traits::{ + fungible::metadata::Inspect as FungibleMetadataInspect, + fungibles::metadata::Inspect as FungiblesMetadataInspect, 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; + + /// Per-asset configuration: + /// - minting fee + /// - redemption fee + /// - asset ceiling weight + /// + /// Keys also define the set of approved external assets. + 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 +pub type MigrateToV1 = frame_support::migrations::VersionedMigration< + 0, + 1, + UncheckedMigrateToV1, + Pallet, + ::DbWeight, +>; + +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" + ); + + let asset_configs = I::asset_configs(); + + MaxPsmDebtOfTotal::::put(I::max_psm_debt_of_total()); + + let stable_decimals = T::StableAsset::decimals(); + 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", + asset_id, + ); + ExternalAssets::::insert(asset_id, CircuitBreakerLevel::AllEnabled); + MintingFee::::insert(asset_id, minting_fee); + RedemptionFee::::insert(asset_id, redemption_fee); + AssetCeilingWeight::::insert(asset_id, ceiling_weight); + } + + Pallet::::ensure_account_exists(&Pallet::::account_id()); + Pallet::::ensure_account_exists(&T::FeeDestination::get()); + + log::info!( + target: LOG_TARGET, + "MigrateToV1 complete" + ); + + // (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> { + Ok(Vec::new()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), TryRuntimeError> { + ensure!( + MaxPsmDebtOfTotal::::get() == I::max_psm_debt_of_total(), + "MaxPsmDebtOfTotal mismatch after migration" + ); + + for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in I::asset_configs() { + ensure!( + ExternalAssets::::get(asset_id) == Some(CircuitBreakerLevel::AllEnabled), + "External asset missing or not AllEnabled after migration" + ); + ensure!( + MintingFee::::get(asset_id) == minting_fee, + "MintingFee mismatch after migration" + ); + ensure!( + RedemptionFee::::get(asset_id) == redemption_fee, + "RedemptionFee mismatch after migration" + ); + ensure!( + AssetCeilingWeight::::get(asset_id) == ceiling_weight, + "AssetCeilingWeight mismatch after migration" + ); + } + + let psm_account = Pallet::::account_id(); + ensure!( + frame_system::Pallet::::account_exists(&psm_account), + "PSM account does not exist after migration" + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mock::{new_test_ext, 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 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(|| { + MaxPsmDebtOfTotal::::kill(); + ExternalAssets::::remove(USDC_ASSET_ID); + ExternalAssets::::remove(USDT_ASSET_ID); + MintingFee::::remove(USDC_ASSET_ID); + MintingFee::::remove(USDT_ASSET_ID); + RedemptionFee::::remove(USDC_ASSET_ID); + RedemptionFee::::remove(USDT_ASSET_ID); + AssetCeilingWeight::::remove(USDC_ASSET_ID); + AssetCeilingWeight::::remove(USDT_ASSET_ID); + + let _weight = UncheckedMigrateToV1::::on_runtime_upgrade(); + + assert_eq!(MaxPsmDebtOfTotal::::get(), TestPsmConfig::max_psm_debt_of_total()); + + for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in + TestPsmConfig::asset_configs() + { + assert_eq!( + ExternalAssets::::get(asset_id), + Some(CircuitBreakerLevel::AllEnabled) + ); + assert_eq!(MintingFee::::get(asset_id), minting_fee); + assert_eq!(RedemptionFee::::get(asset_id), redemption_fee); + assert_eq!(AssetCeilingWeight::::get(asset_id), ceiling_weight); + } + }); + } +} diff --git a/substrate/frame/psm/src/mock.rs b/substrate/frame/psm/src/mock.rs new file mode 100644 index 0000000000000..1a964d9efff29 --- /dev/null +++ b/substrate/frame/psm/src/mock.rs @@ -0,0 +1,327 @@ +// 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 frame_support::{ + derive_impl, parameter_types, + traits::{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}; + +// 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 + +parameter_types! { + pub static MockMaximumIssuance: u128 = 10_000_000 * PUSD_UNIT; +} + +pub fn set_mock_maximum_issuance(value: u128) { + MockMaximumIssuance::set(value); +} + +#[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()) + } +} + +#[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; + type MaximumIssuance = MockMaximumIssuance; + type ManagerOrigin = MockManagerOrigin; + type WeightInfo = (); + type StableAsset = frame_support::traits::fungible::ItemOf; + 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 { + 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 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(); + crate::Pallet::::do_try_state().expect("try_state post-condition failed"); + }); + } +} + +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 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) +} + +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..30cc7966f65eb --- /dev/null +++ b/substrate/frame/psm/src/tests.rs @@ -0,0 +1,1601 @@ +// 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, +}; +use frame_support::{assert_noop, assert_ok, hypothetically}; +use sp_runtime::{DispatchError, Permill, TokenError}; + +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_ceil(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_ceil(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 = MinSwapAmount::get() - 1; + + 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_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); + 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 = MockMaximumIssuance::get(); + + // 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::*; + + #[test] + fn success_basic() { + 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); + 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_ceil(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() { + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { + 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() { + 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; + 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); + + 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() { + 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; + 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() { + 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 + ); + }); + } + + #[test] + fn fails_asset_all_disabled() { + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { + 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() { + 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 + assert_ok!(Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, 1000 * PUSD_UNIT)); + }); + } + + #[test] + fn fails_below_minimum() { + ExtBuilder::default().mints(ALICE, 5000 * PUSD_UNIT).build_and_execute(|| { + 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() { + 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; + + assert_noop!( + Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, too_much), + TokenError::FundsUnavailable + ); + }); + } + + #[test] + fn boundary_reserve_equals_output() { + new_test_ext().execute_with(|| { + set_minting_fee(USDC_ASSET_ID, Permill::zero()); + set_redemption_fee(USDC_ASSET_ID, Permill::zero()); + + 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); + }); + } + + #[test] + fn fails_when_reserve_exceeds_debt_donated_reserves() { + 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; + + // 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()); + 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 + ); + + // 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); + }); + + assert_noop!( + Psm::redeem(RuntimeOrigin::signed(ALICE), USDC_ASSET_ID, debt + 1), + Error::::InsufficientReserve + ); + }); + } +} + +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; + create_asset_with_metadata(new_asset); + 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_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(|| { + 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::::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 + ); + }); + } + + #[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() { + ExtBuilder::default().mints(ALICE, 1000 * PUSD_UNIT).build_and_execute(|| { + 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_can_set_asset_ceiling_weight() { + new_test_ext().execute_with(|| { + let new_ratio = Permill::from_percent(80); + + assert_ok!(Psm::set_asset_ceiling_weight( + RuntimeOrigin::signed(EMERGENCY_ACCOUNT), + USDC_ASSET_ID, + new_ratio + )); + + assert_eq!(AssetCeilingWeight::::get(USDC_ASSET_ID), new_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)); + }); + } + + #[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 { + 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_weight_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 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_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); + + // 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 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%, 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(bridged_usdc_asset_id, Permill::from_percent(25)); + + // 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); + + // 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_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); + + // 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_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 weights, 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 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(|| { + // 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 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 weight + 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..54b11c97c316f --- /dev/null +++ b/substrate/frame/psm/src/weights.rs @@ -0,0 +1,364 @@ +// 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-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` + +// 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() -> 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: `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) + /// 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`) + /// The range of component `n` is `[1, 10]`. + fn mint(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1387 + n * (88 ±0)` + // Estimated: `11426 + n * (2511 ±0)` + // 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)) + .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: `1459` + // Estimated: `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)) + } + /// 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: `396` + // Estimated: `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)) + } + /// 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: `396` + // Estimated: `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)) + } + /// 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: 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)) + } + /// 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: `405` + // Estimated: `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)) + } + /// 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: `454` + // Estimated: `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:1 w:1) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, 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: `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: `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) + /// 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: `417` + // Estimated: `3501` + // 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)) + } +} + +// 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: `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) + /// 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`) + /// The range of component `n` is `[1, 10]`. + fn mint(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1387 + n * (88 ±0)` + // Estimated: `11426 + n * (2511 ±0)` + // 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)) + .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: `1459` + // Estimated: `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)) + } + /// 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: `396` + // Estimated: `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)) + } + /// 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: `396` + // Estimated: `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)) + } + /// 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: 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)) + } + /// 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: `405` + // Estimated: `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)) + } + /// 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: `454` + // Estimated: `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:1 w:1) + /// Proof: `Psm::ExternalAssets` (`max_values`: None, `max_size`: Some(21), added: 2496, 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: `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: `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) + /// 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: `417` + // Estimated: `3501` + // 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)) + } +} diff --git a/substrate/frame/support/src/traits.rs b/substrate/frame/support/src/traits.rs index ea6e9aa2e9a8f..4acacb463988c 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, + 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 36c659c07c1bd..a032bea37f346 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..2188a1eb53c7b --- /dev/null +++ b/substrate/frame/support/src/traits/tokens/stable.rs @@ -0,0 +1,38 @@ +// 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 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 a45cbb14343c5..b25b42c24a702 100644 --- a/umbrella/Cargo.toml +++ b/umbrella/Cargo.toml @@ -130,6 +130,7 @@ std = [ "pallet-people?/std", "pallet-preimage?/std", "pallet-proxy?/std", + "pallet-psm?/std", "pallet-ranked-collective?/std", "pallet-recovery?/std", "pallet-referenda?/std", @@ -321,6 +322,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", @@ -465,6 +467,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", @@ -686,6 +689,7 @@ runtime-full = [ "pallet-people", "pallet-preimage", "pallet-proxy", + "pallet-psm", "pallet-ranked-collective", "pallet-recovery", "pallet-referenda", @@ -1641,6 +1645,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 be821fb478352..8d6c33254452c 100644 --- a/umbrella/src/lib.rs +++ b/umbrella/src/lib.rs @@ -619,6 +619,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;