Skip to content

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

Open
lrazovic wants to merge 34 commits intoparitytech:masterfrom
amforc:leo/psm
Open

Introduce PSM pallet (part of the pUSD Project)#11068
lrazovic wants to merge 34 commits intoparitytech:masterfrom
amforc:leo/psm

Conversation

@lrazovic
Copy link
Copy Markdown

@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.

@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
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.

@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
lrazovic and others added 6 commits March 31, 2026 16:26
Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com>
Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com>
Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com>
Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@kianenigma kianenigma left a comment

Choose a reason for hiding this comment

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

Can only approve once the minor stuff is addressed, but looks pretty ready to me.

amount,
));
}
test();
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.

You should call your do_try_state() here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in 513751e

/// 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
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants