Skip to content

Introduce PSM pallet (part of the pUSD Project)#11068

Merged
muharem merged 37 commits intoparitytech:masterfrom
amforc:leo/psm
Apr 8, 2026
Merged

Introduce PSM pallet (part of the pUSD Project)#11068
muharem merged 37 commits intoparitytech:masterfrom
amforc:leo/psm

Conversation

@lrazovic
Copy link
Copy Markdown
Contributor

@lrazovic lrazovic commented Feb 13, 2026

Description

This PR introduces pallet-psm, a new FRAME pallet that implements a Peg Stability Module (PSM) for pUSD. The pallet enables 1:1 swaps between pUSD and approved external stablecoins (e.g. USDC/USDT), with configurable mint/redeem fees and per-asset circuit breakers.

The pallet enforces a three-tier debt ceiling model before minting:

  • System-wide cap from Vaults (MaximumIssuance)
  • Aggregate PSM cap (MaxPsmDebtOfTotal)
  • Per-asset normalized ceiling (AssetCeilingWeight)

It also adds cross-pallet interfaces in frame_support::traits::tokens::stable:

  • VaultsInterface (PSM -> Vaults): query system issuance ceiling
  • PsmInterface (Vaults/others -> PSM): query reserved PSM capacity

Integration

For Runtime Developers

To integrate pallet-psm into your runtime:

  1. Add dependency to your runtime Cargo.toml:
pallet-psm = { version = "0.1.0", default-features = false }
  1. Implement the Config trait in your runtime:
impl pallet_psm::Config for Runtime {
    type Fungibles = Assets;                     // fungibles impl (must impl metadata::Inspect)
    type AssetId = u32;                          // asset identifier type
    type VaultsInterface = Vaults;               // must implement VaultsInterface
    type ManagerOrigin = EnsurePsmManager;       // returns PsmManagerLevel (Full/Emergency)
    type WeightInfo = pallet_psm::weights::SubstrateWeight<Runtime>;
    type StableAsset = ItemOf<Assets, StablecoinAssetId, AccountId>;  // pUSD as fungible
    type FeeHandler = ResolveTo<InsuranceFundAccount, Self::StableAsset>;
    type PalletId = PsmPalletId;                 // PSM reserve account derivation
    type MinSwapAmount = MinSwapAmount;          // minimum mint/redeem amount
    type MaxExternalAssets = ConstU32<10>;        // max approved external assets
}
  1. Add to construct_runtime!:
construct_runtime!(
    pub enum Runtime {
        // ... other pallets
        Psm: pallet_psm,
    }
);
  1. Ensure Vaults exposes issuance ceiling to PSM:
use frame_support::traits::tokens::stable::VaultsInterface;

impl VaultsInterface for Vaults {
    type Balance = Balance;
    fn get_maximum_issuance() -> Balance {
        // return system-wide pUSD ceiling
    }
}
  1. For existing chains, include the migration:
pub struct PsmInitialConfig;

impl pallet_psm::migrations::v1::InitialPsmConfig<Runtime> for PsmInitialConfig {
    fn max_psm_debt_of_total() -> Permill { Permill::from_percent(10) }
    fn external_asset_ids() -> Vec<AssetId> { vec![USDC_ASSET_ID, USDT_ASSET_ID] }
    fn asset_configs() -> BTreeMap<AssetId, (Permill, Permill, Permill)> {
        // asset -> (mint_fee, redeem_fee, ceiling_weight)
        [
            (USDC_ASSET_ID, (Permill::from_percent(1), Permill::from_percent(1), Permill::from_percent(50))),
            (USDT_ASSET_ID, (Permill::from_percent(1), Permill::from_percent(1), Permill::from_percent(50))),
        ].into_iter().collect()
    }
}

pub type Migrations = (
    pallet_psm::migrations::v1::MigrateToV1<Runtime, PsmInitialConfig>,
);

For Pallet Developers

Other pallets can query PSM-reserved issuance capacity via PsmInterface:

use frame_support::traits::tokens::stable::PsmInterface;

let reserved = <Psm as PsmInterface>::reserved_capacity();

This can be used to account for PSM-reserved issuance when computing vault minting headroom.

Review Notes

Key Features

  • 1:1 swaps: mint (external -> pUSD) and redeem (pUSD -> external)
  • Multi-asset support with explicit approval list (add_external_asset / remove_external_asset)
  • Three-tier debt ceiling enforcement (system-wide, aggregate PSM, per-asset normalized)
  • Per-asset circuit breaker: AllEnabled -> MintingDisabled -> AllDisabled
  • Tiered governance origin:
    • Full: all parameter and asset-management operations
    • Emergency: can only set circuit breaker status
  • Fee model:
    • Mint fee: deducted from minted pUSD, fee credit issued to FeeHandler
    • Redeem fee: deducted from pUSD input, fee withdrawn as credit to FeeHandler
  • Safety invariant on redeem: limited by tracked PsmDebt (not just raw reserve), preventing withdrawal of donated reserves
  • Includes benchmarks and V0 -> V1 migration for post-genesis deployment

Swap Lifecycle

Mint (External -> pUSD):

  1. User calls mint(asset_id, external_amount)
  2. Checks: approved asset, circuit breaker, min amount
  3. Enforces ceilings in order: system-wide -> aggregate PSM -> per-asset
  4. Transfers external asset into PSM account
  5. Mints pUSD to user minus fee
  6. Issues fee as pUSD credit to FeeHandler
  7. Increases PsmDebt[asset_id]

Redeem (pUSD -> External):

  1. User calls redeem(asset_id, pusd_amount)
  2. Checks: approved asset, circuit breaker, min amount
  3. Calculates fee and external output amount
  4. Verifies tracked debt and reserve are sufficient
  5. Burns pUSD principal portion from user
  6. Withdraws pUSD fee from user as credit to FeeHandler
  7. Transfers external asset from PSM account to user
  8. Decreases PsmDebt[asset_id]

Governance/Operations

  • set_minting_fee
  • set_redemption_fee
  • set_max_psm_debt
  • set_asset_ceiling_weight
  • set_asset_status
  • add_external_asset
  • remove_external_asset (requires zero debt; cleans up config storage)

Config Trait

Type Purpose
Fungibles Fungibles impl for pUSD + external assets.
AssetId Asset identifier type.
VaultsInterface Query system-wide issuance ceiling.
ManagerOrigin Returns PsmManagerLevel (Full / Emergency).
WeightInfo Benchmark weights.
StableAsset pUSD as a single-asset fungible type (typically ItemOf<Assets, StablecoinAssetId>). Must implement FungibleMutate + FungibleBalanced.
FeeHandler OnUnbalanced handler for fee credits.
PalletId Derives the PSM reserve account.
MinSwapAmount Minimum mint/redeem amount.
MaxExternalAssets Maximum number of approved external assets.

Testing

The pallet includes comprehensive coverage for:

  • Mint/redeem success paths and failure modes
  • Fee edge cases (0%, non-zero, 100%)
  • Three-tier ceiling enforcement and boundary conditions
  • Per-asset ceiling redistribution when weight is set to 0%
  • Circuit breaker behavior per asset
  • Full vs emergency governance permissions
  • Asset onboarding/offboarding invariants and cleanup
  • Reserve-vs-debt safety (donated reserve cannot be redeemed)
  • Long-running mint/redeem cycles and accounting invariants
  • Migration tests (v0 -> v1 and skip-when-already-v1)

@lrazovic lrazovic requested a review from a team as a code owner February 13, 2026 14:49
@muharem muharem self-requested a review March 10, 2026 07:27
Copy link
Copy Markdown
Contributor

@muharem muharem left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

One issue I see is that the pallet assumes both pUSD and the external stablecoin share the same decimal precision. The asset pallet does not know the precision. Either we only add assets with the same precision (how we don’t forget this), or we make the pallet aware of the precision and normalize the values.

Comment thread substrate/frame/psm/src/lib.rs Outdated
Comment thread substrate/frame/psm/src/benchmarking.rs Outdated
Comment thread substrate/frame/psm/src/lib.rs Outdated
Comment thread substrate/frame/psm/src/lib.rs Outdated
Comment thread substrate/frame/psm/src/lib.rs
Comment thread substrate/frame/psm/src/lib.rs
Comment thread substrate/frame/psm/src/lib.rs Outdated
Comment thread substrate/frame/psm/src/lib.rs
@muharem
Copy link
Copy Markdown
Contributor

muharem commented Mar 18, 2026

@lrazovic actually I was wrong, there is the metadata inspect trait that fetches the decimals for the asset.

I think we should add the support for assets with different decimals. We cannot even guarantee that the asset's decimal precision did not change over time. Moreover restricting pUSD to 6 decimal scale is quite limiting.

I would snapshot the decimals on initialisation and on external asset addition, normalise the amounts, and verify on every mint and redeem with the inspect trait that the decimals have not been changed (halt it if they did).

what you think?

@lrazovic
Copy link
Copy Markdown
Contributor Author

what you think?

I agree we could make the PSM generic over assets with different decimal precisions, since the asset owner could technical change the decimals of the asset, but I don’t think that extra complexity is justified for the current scope. The pallet is intended for a small, governance-curated set of stables (e.g. USDC and USDT), so I’d prefer to keep the accounting simple and make the assumption explicit. We can enforce an invariant when adding a new external asset: its decimals must match pUSD decimals, i.e. external_asset.decimals == pUSD.decimals. This lets us reject unsupported assets at least at onboarding time instead of introducing normalization logic, extra storage/migration work, and rounding edge cases for a scenario we do not currently plan to support (Of course this is not fixing the case of the decimals changed after it's added to the PSM)

Another small issue: unfortunately there's no fungible::metadata::Inspect trait, only fungibles::metadata::Inspect. And afaik ItemOf doesn't forward metadata traits, so T::PusdAsset has no way to expose decimals(). In that case, at least in this pallet, we'd have to go back to using the fungibles + constant asset ID combo, like before

@muharem
Copy link
Copy Markdown
Contributor

muharem commented Mar 18, 2026

I will provide fungible::metadata::Inspect tomorrow. For the rest lets continue conversation with the rest of the team offline.

Comment thread substrate/frame/psm/src/benchmarking.rs Outdated
@muharem
Copy link
Copy Markdown
Contributor

muharem commented Mar 23, 2026

we need prdoc

@muharem muharem requested a review from kianenigma March 23, 2026 16:48
Comment thread substrate/frame/psm/src/tests.rs Outdated
Comment thread substrate/frame/psm/src/lib.rs
Comment thread substrate/frame/psm/src/benchmarking.rs
Comment thread substrate/frame/psm/src/lib.rs Outdated
Comment thread substrate/frame/psm/src/lib.rs
Comment thread substrate/frame/psm/src/lib.rs Outdated
Comment thread substrate/frame/psm/src/mock.rs
/// 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contradicts with

/// Can only modify circuit breaker status.

also if there only a single external asset, we cannot really change ceiling with set_asset_ceiling_weight. only disable asset by setting 0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated doc in 9a4e883. In d226b43 I adjusted the wording to better use the weight terminology, and in f4e1111 I added a test to show the single external asset case

/// - [`Event::ExternalAssetAdded`]: Emitted on successful addition
#[pallet::call_index(7)]
#[pallet::weight(T::WeightInfo::add_external_asset())]
pub fn add_external_asset(origin: OriginFor<T>, asset_id: T::AssetId) -> DispatchResult {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big issue, just a suggestion. Maybe it would be a good idea to accept minting_fee and redemption_fee as parameters here.

These fees have a default value and it seems pretty possible to forget to call the separate extrinsics to set them, which would result in charging the default 0.5% which might not be the wanted behaviour.

Copy link
Copy Markdown
Contributor

@rockbmb rockbmb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are some suggestions for additional do_try_state checks; a summary below, and the proposed diff in the pallet's source.

Current (checks 1-4):

# Check Severity
1 All approved assets have decimals matching the stable asset error
2 Per-asset reserve >= per-asset debt error
3 total_psm_debt() equals sum of all per-asset PsmDebt entries error
4 Per-asset debt does not exceed its ceiling (when minting is enabled) error

Proposed additions (checks 5-11):

# Check Severity Rationale
5 No non-zero PsmDebt entry for a non-approved asset error Orphan debt after asset removal
6 Warn on MintingFee/RedemptionFee/AssetCeilingWeight for non-approved asset warn May be intentional pre-configuration
7 PSM account exists error Required for transfers
8 ExternalAssets count <= MaxExternalAssets error Bounded storage
9 Zero ceiling weight + zero debt implies zero reserve warn Non-zero reserve with no debt/ceiling is likely a donation or bug
10 Total PSM debt <= MaxPsmDebtOfTotal ceiling warn May be transiently violated if governance lowers ceiling
11 Fee destination account exists error Required for fee transfers

The #[cfg] gate should also be widened from try-runtime || test to try-runtime || test || fuzzing to support cargo-fuzz harnesses. It will not be in time for this PR's merger; I am working on such a fuzzer.

Comment thread substrate/frame/psm/src/lib.rs
@muharem muharem added the T1-FRAME This PR/Issue is related to core FRAME, the framework. label Apr 8, 2026
@muharem muharem added this pull request to the merge queue Apr 8, 2026
Merged via the queue into paritytech:master with commit dd79f96 Apr 8, 2026
369 of 384 checks passed
Comment on lines +110 to +114
assert!(
T::Fungibles::decimals(*asset_id) == stable_decimals,
"PSM migration: asset {:?} decimals do not match stable asset decimals",
asset_id,
);
Copy link
Copy Markdown
Member

@ggwpez ggwpez Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panicing in a runtime upgrade is a sure way to brick the chain. Please only panic in pre and post upgrade checks. I am changing it now. Upgrades themselves must be infallible.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rockbmb rockbmb mentioned this pull request Apr 16, 2026
6 tasks
github-merge-queue Bot pushed a commit that referenced this pull request Apr 18, 2026
…11807)

Addresses issue @ggwpez raised in this comment:
#11068 (comment)

## Summary

Replaces the `assert!` in the PSM `InitializePsm` migration with a log +
skip when an asset's decimals don't match the stable asset. Panicking in
a runtime upgrade bricks the chain. Migrations must be infallible.

### Test

Adds `initialize_psm_skips_assets_with_wrong_decimals` which verifies
that assets with mismatched decimals are skipped while
correctly-configured assets in the same migration are still added.
@redzsina redzsina moved this from Scheduled to In progress in Security Audit (PRs) - SRLabs Apr 20, 2026
sigurpol pushed a commit that referenced this pull request Apr 20, 2026
…11807)

Addresses issue @ggwpez raised in this comment:
#11068 (comment)

## Summary

Replaces the `assert!` in the PSM `InitializePsm` migration with a log +
skip when an asset's decimals don't match the stable asset. Panicking in
a runtime upgrade bricks the chain. Migrations must be infallible.

### Test

Adds `initialize_psm_skips_assets_with_wrong_decimals` which verifies
that assets with mismatched decimals are skipped while
correctly-configured assets in the same migration are still added.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T1-FRAME This PR/Issue is related to core FRAME, the framework.

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

9 participants