Skip to content

Commit 7374bad

Browse files
committed
feat: rebase onto master directly
1 parent 89669b3 commit 7374bad

17 files changed

Lines changed: 3919 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ members = [
413413
"substrate/frame/people",
414414
"substrate/frame/preimage",
415415
"substrate/frame/proxy",
416+
"substrate/frame/psm",
416417
"substrate/frame/ranked-collective",
417418
"substrate/frame/recovery",
418419
"substrate/frame/referenda",
@@ -1031,6 +1032,7 @@ pallet-parameters = { path = "substrate/frame/parameters", default-features = fa
10311032
pallet-people = { path = "substrate/frame/people", default-features = false }
10321033
pallet-preimage = { path = "substrate/frame/preimage", default-features = false }
10331034
pallet-proxy = { path = "substrate/frame/proxy", default-features = false }
1035+
pallet-psm = { path = "substrate/frame/psm", default-features = false }
10341036
pallet-ranked-collective = { path = "substrate/frame/ranked-collective", default-features = false }
10351037
pallet-recovery = { path = "substrate/frame/recovery", default-features = false }
10361038
pallet-referenda = { path = "substrate/frame/referenda", default-features = false }

substrate/bin/node/runtime/src/lib.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2865,6 +2865,9 @@ mod runtime {
28652865
#[runtime::pallet_index(85)]
28662866
pub type Oracle = pallet_oracle::Pallet<Runtime>;
28672867

2868+
#[runtime::pallet_index(86)]
2869+
pub type Psm = pallet_psm::Pallet<Runtime>;
2870+
28682871
#[runtime::pallet_index(89)]
28692872
pub type MetaTx = pallet_meta_tx::Pallet<Runtime>;
28702873

@@ -3040,6 +3043,67 @@ impl pallet_oracle::Config for Runtime {
30403043
type BenchmarkHelper = OracleBenchmarkingHelper;
30413044
}
30423045

3046+
parameter_types! {
3047+
/// The pUSD stablecoin asset ID.
3048+
pub const PsmStablecoinAssetId: u32 = 4242;
3049+
/// Minimum swap amount for PSM operations (100 pUSD = 100 * 10^6).
3050+
pub const PsmMinSwapAmount: Balance = 100_000_000;
3051+
/// PalletId for deriving the PSM system account.
3052+
pub const PsmPalletId: PalletId = PalletId(*b"py/pegsm");
3053+
/// Insurance fund account that receives PSM fee revenue.
3054+
pub PsmInsuranceFundAccount: AccountId =
3055+
sp_runtime::traits::AccountIdConversion::<AccountId>::into_account_truncating(
3056+
&PalletId(*b"py/insur"),
3057+
);
3058+
}
3059+
3060+
type PsmStableAsset = ItemOf<Assets, PsmStablecoinAssetId, AccountId>;
3061+
3062+
/// Stub VaultsInterface that imposes no debt ceiling.
3063+
pub struct NoVaultsCeiling;
3064+
impl frame_support::traits::VaultsInterface for NoVaultsCeiling {
3065+
type Balance = Balance;
3066+
fn get_maximum_issuance() -> Balance {
3067+
Balance::MAX
3068+
}
3069+
}
3070+
3071+
/// EnsureOrigin implementation for PSM management that supports privilege levels.
3072+
pub struct EnsurePsmManager;
3073+
impl frame_support::traits::EnsureOrigin<RuntimeOrigin> for EnsurePsmManager {
3074+
type Success = pallet_psm::PsmManagerLevel;
3075+
3076+
fn try_origin(o: RuntimeOrigin) -> Result<Self::Success, RuntimeOrigin> {
3077+
use frame_system::RawOrigin;
3078+
3079+
match o.clone().into() {
3080+
Ok(RawOrigin::Root) => Ok(pallet_psm::PsmManagerLevel::Full),
3081+
_ => Err(o),
3082+
}
3083+
}
3084+
3085+
#[cfg(feature = "runtime-benchmarks")]
3086+
fn try_successful_origin() -> Result<RuntimeOrigin, ()> {
3087+
Ok(RuntimeOrigin::root())
3088+
}
3089+
}
3090+
3091+
/// Configure the PSM (Peg Stability Module) pallet.
3092+
impl pallet_psm::Config for Runtime {
3093+
type Fungibles = Assets;
3094+
type AssetId = u32;
3095+
type VaultsInterface = NoVaultsCeiling;
3096+
type ManagerOrigin = EnsurePsmManager;
3097+
type WeightInfo = pallet_psm::weights::SubstrateWeight<Runtime>;
3098+
#[cfg(feature = "runtime-benchmarks")]
3099+
type StableAssetId = PsmStablecoinAssetId;
3100+
type StableAsset = PsmStableAsset;
3101+
type FeeHandler = ResolveTo<PsmInsuranceFundAccount, PsmStableAsset>;
3102+
type PalletId = PsmPalletId;
3103+
type MinSwapAmount = PsmMinSwapAmount;
3104+
type MaxExternalAssets = ConstU32<10>;
3105+
}
3106+
30433107
/// MMR helper types.
30443108
mod mmr {
30453109
use super::*;
@@ -3184,6 +3248,7 @@ mod benches {
31843248
[pallet_asset_conversion_ops, AssetConversionMigration]
31853249
[pallet_verify_signature, VerifySignature]
31863250
[pallet_meta_tx, MetaTx]
3251+
[pallet_psm, Psm]
31873252
);
31883253
}
31893254

substrate/frame/psm/Cargo.toml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
[package]
2+
name = "pallet-psm"
3+
version = "0.1.0"
4+
authors.workspace = true
5+
edition.workspace = true
6+
license = "Apache-2.0"
7+
homepage.workspace = true
8+
repository.workspace = true
9+
description = "FRAME pallet for the Peg Stability Module."
10+
readme = "README.md"
11+
include = ["README.md", "src/**/*"]
12+
13+
[lints]
14+
workspace = true
15+
16+
[package.metadata.docs.rs]
17+
targets = ["x86_64-unknown-linux-gnu"]
18+
19+
[dependencies]
20+
codec = { features = ["derive"], workspace = true }
21+
frame-benchmarking = { workspace = true, optional = true }
22+
frame-support = { workspace = true }
23+
frame-system = { workspace = true }
24+
log = { workspace = true }
25+
scale-info = { features = ["derive"], workspace = true }
26+
sp-runtime = { workspace = true }
27+
28+
[dev-dependencies]
29+
pallet-assets = { workspace = true, default-features = true }
30+
pallet-balances = { workspace = true, default-features = true }
31+
sp-io = { workspace = true, default-features = true }
32+
33+
[features]
34+
default = ["std"]
35+
std = [
36+
"codec/std",
37+
"frame-benchmarking?/std",
38+
"frame-support/std",
39+
"frame-system/std",
40+
"log/std",
41+
"scale-info/std",
42+
"sp-runtime/std",
43+
]
44+
runtime-benchmarks = [
45+
"frame-benchmarking/runtime-benchmarks",
46+
"frame-support/runtime-benchmarks",
47+
"frame-system/runtime-benchmarks",
48+
"pallet-assets/runtime-benchmarks",
49+
"pallet-balances/runtime-benchmarks",
50+
"sp-runtime/runtime-benchmarks",
51+
]
52+
try-runtime = [
53+
"frame-support/try-runtime",
54+
"frame-system/try-runtime",
55+
"pallet-assets/try-runtime",
56+
"pallet-balances/try-runtime",
57+
"sp-runtime/try-runtime",
58+
]

substrate/frame/psm/README.md

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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

Comments
 (0)