|
| 1 | +# PSM Pallet |
| 2 | + |
| 3 | +A Peg Stability Module enabling 1:1 swaps between pUSD and pre-approved external stablecoins on Substrate-based blockchains. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +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: |
| 8 | + |
| 9 | +- **Reserves are held**: External stablecoins are held in a pallet-derived account (`PalletId`) |
| 10 | +- **pUSD is minted/burned**: Users receive pUSD when depositing external stablecoins, and burn pUSD when redeeming |
| 11 | +- **Circuit breaker provides emergency control**: Per-asset circuit breaker can disable minting or all swaps |
| 12 | + |
| 13 | +## Swap Lifecycle |
| 14 | + |
| 15 | +### 1. Mint (External -> pUSD) |
| 16 | +```rust |
| 17 | +mint(origin, asset_id, external_amount) |
| 18 | +``` |
| 19 | +- Deposits external stablecoin into the PSM account |
| 20 | +- Mints pUSD to the user (minus minting fee) |
| 21 | +- Fee is minted as pUSD to the Insurance Fund |
| 22 | +- Enforces three-tier debt ceiling: system-wide, aggregate PSM, and per-asset |
| 23 | +- Requires `external_amount >= MinSwapAmount` |
| 24 | + |
| 25 | +### 2. Redeem (pUSD -> External) |
| 26 | +```rust |
| 27 | +redeem(origin, asset_id, pusd_amount) |
| 28 | +``` |
| 29 | +- Burns pUSD from the user (minus redemption fee) |
| 30 | +- Transfers external stablecoin from PSM account to user |
| 31 | +- Fee is transferred as pUSD from user to Insurance Fund |
| 32 | +- Limited by tracked PSM debt (not raw reserve balance) |
| 33 | +- Requires `pusd_amount >= MinSwapAmount` |
| 34 | + |
| 35 | +## Debt Ceiling Architecture |
| 36 | + |
| 37 | +Before minting, the PSM checks three ceilings in order: |
| 38 | + |
| 39 | +1. **System-wide**: `total_issuance(pUSD) + amount <= MaximumIssuance` |
| 40 | +2. **Aggregate PSM**: `total_psm_debt + amount <= MaxPsmDebtOfTotal * MaximumIssuance` |
| 41 | +3. **Per-asset**: `asset_debt + amount <= normalized_asset_share_of_psm_ceiling` |
| 42 | + |
| 43 | +### PSM Reserved Capacity |
| 44 | + |
| 45 | +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. |
| 46 | + |
| 47 | +### Per-Asset Ceiling |
| 48 | + |
| 49 | +Per-asset ceilings use a weight-based system: |
| 50 | + |
| 51 | +``` |
| 52 | +max_asset_debt = (AssetCeilingWeight[asset_id] / sum_of_all_weights) * max_psm_debt |
| 53 | +``` |
| 54 | + |
| 55 | +Setting an asset's weight to 0% disables minting and redistributes its capacity to other assets. |
| 56 | + |
| 57 | +## Fee Structure |
| 58 | + |
| 59 | +Fees are calculated using `Permill::mul_ceil` (rounds up): |
| 60 | + |
| 61 | +- **Minting Fee**: `fee = MintingFee[asset_id].mul_ceil(external_amount)` -- deducted from pUSD output, minted to Insurance Fund |
| 62 | +- **Redemption Fee**: `fee = RedemptionFee[asset_id].mul_ceil(pusd_amount)` -- transferred as pUSD from user to Insurance Fund |
| 63 | + |
| 64 | +With 0.5% fees on both sides, arbitrage opportunities exist when pUSD trades outside $0.995-$1.005. |
| 65 | + |
| 66 | +## Circuit Breaker |
| 67 | + |
| 68 | +Each approved asset has an independent circuit breaker with three levels: |
| 69 | + |
| 70 | +| Level | Minting | Redemption | Use Case | |
| 71 | +| ----------------- | ------- | ---------- | --------------------------------- | |
| 72 | +| `AllEnabled` | Allowed | Allowed | Normal operation | |
| 73 | +| `MintingDisabled` | Blocked | Allowed | Drain debt from problematic asset | |
| 74 | +| `AllDisabled` | Blocked | Blocked | Full emergency halt | |
| 75 | + |
| 76 | +The `set_asset_status` extrinsic can be called by both `GeneralAdmin` and `EmergencyAction` origins. |
| 77 | + |
| 78 | +## Governance Operations |
| 79 | + |
| 80 | +| Extrinsic | Required Level | Description | |
| 81 | +| -------------------------------------------- | ----------------- | ------------------------------------------------- | |
| 82 | +| `set_minting_fee(asset_id, fee)` | Full | Update minting fee for an asset | |
| 83 | +| `set_redemption_fee(asset_id, fee)` | Full | Update redemption fee for an asset | |
| 84 | +| `set_max_psm_debt(ratio)` | Full | Update global PSM ceiling as % of MaximumIssuance | |
| 85 | +| `set_asset_ceiling_weight(asset_id, weight)` | Full | Update per-asset ceiling weight | |
| 86 | +| `set_asset_status(asset_id, status)` | Full or Emergency | Set per-asset circuit breaker level | |
| 87 | +| `add_external_asset(asset_id)` | Full | Add approved stablecoin (defaults to AllEnabled) | |
| 88 | +| `remove_external_asset(asset_id)` | Full | Remove approved stablecoin (requires zero debt) | |
| 89 | + |
| 90 | +### Privilege Levels |
| 91 | + |
| 92 | +The `ManagerOrigin` returns a privilege level: |
| 93 | +- **Full** (via GeneralAdmin): Can modify all parameters |
| 94 | +- **Emergency** (via EmergencyAction): Can only modify circuit breaker status |
| 95 | + |
| 96 | +### Asset Offboarding Workflow |
| 97 | + |
| 98 | +1. `set_asset_ceiling_weight(asset_id, 0%)` -- blocks minting, redistributes capacity |
| 99 | +2. Redemptions slowly drain remaining PSM debt |
| 100 | +3. Once `PsmDebt[asset_id]` reaches zero, call `remove_external_asset(asset_id)` |
| 101 | + |
| 102 | +## Configuration |
| 103 | + |
| 104 | +```rust |
| 105 | +impl pallet_psm::Config for Runtime { |
| 106 | + type Asset = Assets; // Fungibles impl for pUSD and external stablecoins |
| 107 | + type AssetId = u32; // Asset identifier type |
| 108 | + type VaultsInterface = Vaults; // Interface to query MaximumIssuance from Vaults |
| 109 | + type ManagerOrigin = EnsurePsmManager; // Governance origin (returns privilege level) |
| 110 | + type WeightInfo = weights::SubstrateWeight<Runtime>; |
| 111 | + type StablecoinAssetId = StablecoinAssetId; // Constant: pUSD asset ID |
| 112 | + type InsuranceFund = InsuranceFundAccount; // Account receiving fee revenue |
| 113 | + type PalletId = PsmPalletId; // For deriving PSM account address |
| 114 | + type MinSwapAmount = MinSwapAmount; // Minimum swap amount (prevents dust) |
| 115 | +} |
| 116 | +``` |
| 117 | + |
| 118 | +### Parameters (Set via Governance) |
| 119 | + |
| 120 | +| Parameter | Description | Suggested Value | |
| 121 | +| -------------------- | ------------------------------------ | --------------------- | |
| 122 | +| `MaxPsmDebtOfTotal` | PSM ceiling as % of MaximumIssuance | 10% | |
| 123 | +| `MintingFee` | Fee for external -> pUSD (per asset) | 0.5% | |
| 124 | +| `RedemptionFee` | Fee for pUSD -> external (per asset) | 0.5% | |
| 125 | +| `AssetCeilingWeight` | Per-asset share of PSM ceiling | 50% each (USDC, USDT) | |
| 126 | + |
| 127 | +### Required Constants |
| 128 | + |
| 129 | +- `StablecoinAssetId`: The asset ID for pUSD |
| 130 | +- `InsuranceFund`: Account that receives fee revenue (shared with pallet-vaults) |
| 131 | +- `PalletId`: Unique identifier for deriving the PSM account |
| 132 | +- `MinSwapAmount`: Minimum amount for any swap (default: 100 pUSD) |
| 133 | + |
| 134 | +## Events |
| 135 | + |
| 136 | +- `Minted { who, asset_id, external_amount, pusd_received, fee }`: User swapped external stablecoin for pUSD |
| 137 | +- `Redeemed { who, asset_id, pusd_paid, external_received, fee }`: User swapped pUSD for external stablecoin |
| 138 | +- `MintingFeeUpdated { asset_id, old_value, new_value }`: Minting fee changed |
| 139 | +- `RedemptionFeeUpdated { asset_id, old_value, new_value }`: Redemption fee changed |
| 140 | +- `MaxPsmDebtOfTotalUpdated { old_value, new_value }`: Global PSM ceiling changed |
| 141 | +- `AssetCeilingWeightUpdated { asset_id, old_value, new_value }`: Per-asset ceiling weight changed |
| 142 | +- `AssetStatusUpdated { asset_id, status }`: Circuit breaker level changed |
| 143 | +- `ExternalAssetAdded { asset_id }`: New external stablecoin approved |
| 144 | +- `ExternalAssetRemoved { asset_id }`: External stablecoin removed |
| 145 | + |
| 146 | +## Errors |
| 147 | + |
| 148 | +- `UnsupportedAsset`: Asset is not in the approved list |
| 149 | +- `InsufficientReserve`: PSM doesn't have enough external stablecoin for redemption |
| 150 | +- `ExceedsMaxIssuance`: Mint would exceed system-wide pUSD cap |
| 151 | +- `ExceedsMaxPsmDebt`: Mint would exceed aggregate PSM ceiling or per-asset ceiling |
| 152 | +- `BelowMinimumSwap`: Swap amount below MinSwapAmount |
| 153 | +- `MintingStopped`: Minting disabled by circuit breaker |
| 154 | +- `AllSwapsStopped`: All swaps disabled by circuit breaker |
| 155 | +- `AssetAlreadyApproved`: Asset already in approved list |
| 156 | +- `AssetNotApproved`: Asset not in approved list |
| 157 | +- `AssetHasDebt`: Cannot remove asset with outstanding debt |
| 158 | +- `InsufficientPrivilege`: Emergency origin tried a Full-only operation |
| 159 | + |
| 160 | +## Testing |
| 161 | + |
| 162 | +Run tests with: |
| 163 | +```bash |
| 164 | +SKIP_WASM_BUILD=1 cargo test -p pallet-psm |
| 165 | +``` |
0 commit comments