referrerpolicy=no-referrer-when-downgrade

pallet_psm/migrations/
init.rs

1// This file is part of Substrate.
2
3// Copyright (C) Amforc AG.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Idempotent migration to initialize PSM parameters for post-genesis deployment.
19//!
20//! This migration sets initial values for all configurable PSM parameters when
21//! adding the pallet to an existing chain. Already-configured assets are skipped,
22//! making it safe to run multiple times.
23//!
24//! # Usage
25//!
26//! Include in your runtime migrations:
27//!
28//! ```ignore
29//! pub type Migrations = (
30//!     pallet_psm::migrations::init::InitializePsm<Runtime, PsmInitialConfig>,
31//!     // ... other migrations
32//! );
33//! ```
34//!
35//! Where `PsmInitialConfig` implements [`InitialPsmConfig`].
36
37use alloc::collections::btree_map::BTreeMap;
38#[cfg(feature = "try-runtime")]
39use alloc::vec::Vec;
40use frame_support::{
41	pallet_prelude::{Get, Weight},
42	traits::{
43		fungible::metadata::Inspect as FungibleMetadataInspect,
44		fungibles::metadata::Inspect as FungiblesMetadataInspect,
45	},
46};
47use sp_runtime::Permill;
48
49use crate::{
50	pallet::{
51		AssetCeilingWeight, CircuitBreakerLevel, ExternalAssets, ExternalDecimals,
52		InternalDecimals, MaxPsmDebtOfTotal, MintingFee, RedemptionFee, MAX_DECIMALS_DIFF,
53	},
54	Config, Pallet,
55};
56
57#[cfg(feature = "try-runtime")]
58use frame_support::ensure;
59#[cfg(feature = "try-runtime")]
60use sp_runtime::TryRuntimeError;
61
62const LOG_TARGET: &str = "runtime::psm::migration";
63
64/// Configuration trait for initial PSM parameters.
65///
66/// Implement this trait in your runtime to provide the initial values used by
67/// [`InitializePsm`].
68pub trait InitialPsmConfig<T: Config> {
69	/// Max PSM debt as a fraction of MaximumIssuance.
70	fn max_psm_debt_of_total() -> Permill;
71
72	/// Per-asset configuration:
73	/// - minting fee
74	/// - redemption fee
75	/// - asset ceiling weight
76	///
77	/// Keys also define the set of approved external assets.
78	fn asset_configs() -> BTreeMap<T::AssetId, (Permill, Permill, Permill)>;
79}
80
81/// Idempotent migration to initialize PSM pallet parameters.
82///
83/// This migration:
84/// 1. Sets `MaxPsmDebtOfTotal`
85/// 2. For each configured external asset, checks if it already exists. If not, adds it with
86///    `AllEnabled` status and the configured fees and ceiling weight.
87/// 3. Ensures the PSM and fee destination accounts exist
88///
89/// Safe to run multiple times — existing assets are not overwritten.
90pub struct InitializePsm<T, I>(core::marker::PhantomData<(T, I)>);
91
92impl<T: Config, I: InitialPsmConfig<T>> frame_support::traits::OnRuntimeUpgrade
93	for InitializePsm<T, I>
94{
95	fn on_runtime_upgrade() -> Weight {
96		log::info!(
97			target: LOG_TARGET,
98			"Running InitializePsm: initializing PSM pallet parameters"
99		);
100
101		let asset_configs = I::asset_configs();
102		let mut reads = 0u64;
103		let mut writes = 0u64;
104
105		reads += 1;
106		if !MaxPsmDebtOfTotal::<T>::exists() {
107			MaxPsmDebtOfTotal::<T>::put(I::max_psm_debt_of_total());
108			writes += 1;
109		}
110
111		// Internal decimals snapshot: populate from live metadata if not yet set.
112		// Per-asset snapshots for pre-existing approved assets are owned by
113		// `super::decimals::PopulateDecimals` — this migration only touches `ExternalDecimals` for
114		// assets it adds as new below.
115		let internal_decimals = T::InternalAsset::decimals();
116		reads += 1;
117		if !InternalDecimals::<T>::exists() {
118			InternalDecimals::<T>::put(internal_decimals);
119			writes += 1;
120		}
121		for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in &asset_configs {
122			reads += 1;
123			// Skip assets that are already configured.
124			if ExternalAssets::<T>::contains_key(asset_id) {
125				log::info!(
126					target: LOG_TARGET,
127					"Asset {:?} already configured, skipping",
128					asset_id,
129				);
130				continue;
131			}
132
133			let asset_decimals = T::Fungibles::decimals(asset_id.clone());
134			let diff = asset_decimals.abs_diff(internal_decimals) as u32;
135			if diff > MAX_DECIMALS_DIFF {
136				log::error!(
137					target: LOG_TARGET,
138					"Asset {:?} decimals diff ({}) exceeds MAX_DECIMALS_DIFF ({}), skipping",
139					asset_id,
140					diff,
141					MAX_DECIMALS_DIFF,
142				);
143				continue;
144			}
145
146			ExternalAssets::<T>::insert(asset_id, CircuitBreakerLevel::AllEnabled);
147			ExternalDecimals::<T>::insert(asset_id, asset_decimals);
148			MintingFee::<T>::insert(asset_id, minting_fee);
149			RedemptionFee::<T>::insert(asset_id, redemption_fee);
150			AssetCeilingWeight::<T>::insert(asset_id, ceiling_weight);
151			writes += 5;
152
153			log::info!(
154				target: LOG_TARGET,
155				"Configured external asset {:?} (decimals={})",
156				asset_id,
157				asset_decimals,
158			);
159		}
160
161		Pallet::<T>::ensure_account_exists(&Pallet::<T>::account_id());
162		Pallet::<T>::ensure_account_exists(&T::FeeDestination::get());
163		writes += 2;
164
165		log::info!(
166			target: LOG_TARGET,
167			"InitializePsm complete"
168		);
169
170		T::DbWeight::get().reads_writes(reads, writes)
171	}
172
173	#[cfg(feature = "try-runtime")]
174	fn pre_upgrade() -> Result<Vec<u8>, TryRuntimeError> {
175		Ok(Vec::new())
176	}
177
178	#[cfg(feature = "try-runtime")]
179	fn post_upgrade(_state: Vec<u8>) -> Result<(), TryRuntimeError> {
180		ensure!(
181			MaxPsmDebtOfTotal::<T>::get() == I::max_psm_debt_of_total(),
182			"MaxPsmDebtOfTotal mismatch after migration"
183		);
184
185		for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in I::asset_configs() {
186			ensure!(
187				ExternalAssets::<T>::get(&asset_id) == Some(CircuitBreakerLevel::AllEnabled),
188				"External asset missing or not AllEnabled after migration"
189			);
190			ensure!(
191				MintingFee::<T>::get(&asset_id) == minting_fee,
192				"MintingFee mismatch after migration"
193			);
194			ensure!(
195				RedemptionFee::<T>::get(&asset_id) == redemption_fee,
196				"RedemptionFee mismatch after migration"
197			);
198			ensure!(
199				AssetCeilingWeight::<T>::get(&asset_id) == ceiling_weight,
200				"AssetCeilingWeight mismatch after migration"
201			);
202		}
203
204		let psm_account = Pallet::<T>::account_id();
205		ensure!(
206			frame_system::Pallet::<T>::account_exists(&psm_account),
207			"PSM account does not exist after migration"
208		);
209
210		Ok(())
211	}
212}
213
214#[cfg(test)]
215mod tests {
216	use super::*;
217	use crate::mock::{new_test_ext, Assets, Test, ALICE, USDC_ASSET_ID, USDT_ASSET_ID};
218	use frame_support::assert_ok;
219
220	struct TestPsmConfig;
221
222	impl InitialPsmConfig<Test> for TestPsmConfig {
223		fn max_psm_debt_of_total() -> Permill {
224			Permill::from_percent(25)
225		}
226
227		fn asset_configs() -> BTreeMap<u32, (Permill, Permill, Permill)> {
228			[
229				(
230					USDC_ASSET_ID,
231					(
232						Permill::from_parts(5_000),
233						Permill::from_parts(5_000),
234						Permill::from_percent(50),
235					),
236				),
237				(
238					USDT_ASSET_ID,
239					(
240						Permill::from_parts(3_000),
241						Permill::from_parts(7_000),
242						Permill::from_percent(50),
243					),
244				),
245			]
246			.into_iter()
247			.collect()
248		}
249	}
250
251	fn clear_all_psm_state() {
252		MaxPsmDebtOfTotal::<Test>::kill();
253		InternalDecimals::<Test>::kill();
254		ExternalAssets::<Test>::remove(USDC_ASSET_ID);
255		ExternalAssets::<Test>::remove(USDT_ASSET_ID);
256		MintingFee::<Test>::remove(USDC_ASSET_ID);
257		MintingFee::<Test>::remove(USDT_ASSET_ID);
258		RedemptionFee::<Test>::remove(USDC_ASSET_ID);
259		RedemptionFee::<Test>::remove(USDT_ASSET_ID);
260		AssetCeilingWeight::<Test>::remove(USDC_ASSET_ID);
261		AssetCeilingWeight::<Test>::remove(USDT_ASSET_ID);
262		ExternalDecimals::<Test>::remove(USDC_ASSET_ID);
263		ExternalDecimals::<Test>::remove(USDT_ASSET_ID);
264	}
265
266	#[test]
267	fn initialize_psm_configures_new_assets() {
268		use frame_support::traits::{
269			fungible::metadata::Inspect as _, fungibles::metadata::Inspect as _, OnRuntimeUpgrade,
270		};
271
272		new_test_ext().execute_with(|| {
273			clear_all_psm_state();
274
275			InitializePsm::<Test, TestPsmConfig>::on_runtime_upgrade();
276
277			assert_eq!(MaxPsmDebtOfTotal::<Test>::get(), TestPsmConfig::max_psm_debt_of_total());
278			assert_eq!(
279				InternalDecimals::<Test>::get(),
280				Some(<Test as Config>::InternalAsset::decimals())
281			);
282
283			for (asset_id, (minting_fee, redemption_fee, ceiling_weight)) in
284				TestPsmConfig::asset_configs()
285			{
286				assert_eq!(
287					ExternalAssets::<Test>::get(asset_id),
288					Some(CircuitBreakerLevel::AllEnabled)
289				);
290				// New assets get their decimals snapshot.
291				assert_eq!(
292					ExternalDecimals::<Test>::get(asset_id),
293					Some(<Test as Config>::Fungibles::decimals(asset_id))
294				);
295				assert_eq!(MintingFee::<Test>::get(asset_id), minting_fee);
296				assert_eq!(RedemptionFee::<Test>::get(asset_id), redemption_fee);
297				assert_eq!(AssetCeilingWeight::<Test>::get(asset_id), ceiling_weight);
298			}
299		});
300	}
301
302	#[test]
303	fn initialize_psm_populates_internal_decimals_when_missing() {
304		use frame_support::traits::{fungible::metadata::Inspect as _, OnRuntimeUpgrade};
305
306		new_test_ext().execute_with(|| {
307			// InternalDecimals was populated by genesis; clear it to simulate a
308			// pre-decimal-snapshot deployment where the migration must seed it.
309			InternalDecimals::<Test>::kill();
310
311			InitializePsm::<Test, TestPsmConfig>::on_runtime_upgrade();
312
313			assert_eq!(
314				InternalDecimals::<Test>::get(),
315				Some(<Test as Config>::InternalAsset::decimals())
316			);
317		});
318	}
319
320	#[test]
321	fn initialize_psm_preserves_existing_internal_decimals() {
322		use frame_support::traits::OnRuntimeUpgrade;
323
324		new_test_ext().execute_with(|| {
325			// Plant a sentinel (non-live) value. The migration must not overwrite.
326			InternalDecimals::<Test>::put(42u8);
327
328			InitializePsm::<Test, TestPsmConfig>::on_runtime_upgrade();
329
330			assert_eq!(InternalDecimals::<Test>::get(), Some(42));
331		});
332	}
333
334	#[test]
335	fn initialize_psm_skips_existing_assets() {
336		use frame_support::traits::OnRuntimeUpgrade;
337
338		new_test_ext().execute_with(|| {
339			// Pre-configure USDC with custom values; drop its decimals snapshot to simulate a
340			// pre-migration partial state. This migration must not touch USDC's snapshot (that is
341			// `PopulateDecimals`'s job).
342			ExternalAssets::<Test>::insert(USDC_ASSET_ID, CircuitBreakerLevel::MintingDisabled);
343			MintingFee::<Test>::insert(USDC_ASSET_ID, Permill::from_percent(10));
344			ExternalDecimals::<Test>::remove(USDC_ASSET_ID);
345
346			// Remove USDT so it gets configured.
347			ExternalAssets::<Test>::remove(USDT_ASSET_ID);
348			MintingFee::<Test>::remove(USDT_ASSET_ID);
349			RedemptionFee::<Test>::remove(USDT_ASSET_ID);
350			AssetCeilingWeight::<Test>::remove(USDT_ASSET_ID);
351			ExternalDecimals::<Test>::remove(USDT_ASSET_ID);
352
353			InitializePsm::<Test, TestPsmConfig>::on_runtime_upgrade();
354
355			// USDC was not overwritten — including its missing decimals snapshot.
356			assert_eq!(
357				ExternalAssets::<Test>::get(USDC_ASSET_ID),
358				Some(CircuitBreakerLevel::MintingDisabled)
359			);
360			assert_eq!(MintingFee::<Test>::get(USDC_ASSET_ID), Permill::from_percent(10));
361			assert_eq!(ExternalDecimals::<Test>::get(USDC_ASSET_ID), None);
362
363			// USDT was newly configured; its decimals snapshot is populated.
364			let (_, (minting_fee, redemption_fee, ceiling_weight)) = TestPsmConfig::asset_configs()
365				.into_iter()
366				.find(|(id, _)| *id == USDT_ASSET_ID)
367				.unwrap();
368			assert_eq!(
369				ExternalAssets::<Test>::get(USDT_ASSET_ID),
370				Some(CircuitBreakerLevel::AllEnabled)
371			);
372			assert!(ExternalDecimals::<Test>::get(USDT_ASSET_ID).is_some());
373			assert_eq!(MintingFee::<Test>::get(USDT_ASSET_ID), minting_fee);
374			assert_eq!(RedemptionFee::<Test>::get(USDT_ASSET_ID), redemption_fee);
375			assert_eq!(AssetCeilingWeight::<Test>::get(USDT_ASSET_ID), ceiling_weight);
376		});
377	}
378
379	#[test]
380	fn initialize_psm_skips_assets_with_wrong_decimals() {
381		use frame_support::traits::{
382			fungibles::{metadata::Mutate as MetadataMutate, Create as FungiblesCreate},
383			OnRuntimeUpgrade,
384		};
385
386		const WRONG_DECIMALS_ID: u32 = 99;
387
388		new_test_ext().execute_with(|| {
389			// Create an asset with 8 decimals (internal asset has 6).
390			assert_ok!(<Assets as FungiblesCreate<u64>>::create(WRONG_DECIMALS_ID, ALICE, true, 1));
391			assert_ok!(<Assets as MetadataMutate<u64>>::set(
392				WRONG_DECIMALS_ID,
393				&ALICE,
394				b"Wrong".to_vec(),
395				b"WRG".to_vec(),
396				(MAX_DECIMALS_DIFF + 6 + 1).try_into().unwrap(), // exceeds MAX_DECIMALS_DIFF
397			));
398
399			struct MixedDecimalsConfig;
400			impl InitialPsmConfig<Test> for MixedDecimalsConfig {
401				fn max_psm_debt_of_total() -> Permill {
402					Permill::from_percent(50)
403				}
404				fn asset_configs() -> BTreeMap<u32, (Permill, Permill, Permill)> {
405					[
406						(
407							WRONG_DECIMALS_ID,
408							(Permill::zero(), Permill::zero(), Permill::from_percent(50)),
409						),
410						(
411							USDC_ASSET_ID, // 6 decimals — matches internal asset
412							(Permill::zero(), Permill::zero(), Permill::from_percent(50)),
413						),
414					]
415					.into_iter()
416					.collect()
417				}
418			}
419
420			ExternalAssets::<Test>::remove(WRONG_DECIMALS_ID);
421			ExternalAssets::<Test>::remove(USDC_ASSET_ID);
422
423			InitializePsm::<Test, MixedDecimalsConfig>::on_runtime_upgrade();
424
425			// Wrong decimals asset was skipped.
426			assert_eq!(ExternalAssets::<Test>::get(WRONG_DECIMALS_ID), None);
427
428			// Matching decimals asset was configured.
429			assert_eq!(
430				ExternalAssets::<Test>::get(USDC_ASSET_ID),
431				Some(CircuitBreakerLevel::AllEnabled)
432			);
433		});
434	}
435
436	#[test]
437	fn initialize_psm_is_idempotent() {
438		use frame_support::traits::OnRuntimeUpgrade;
439
440		new_test_ext().execute_with(|| {
441			clear_all_psm_state();
442
443			// Run twice.
444			InitializePsm::<Test, TestPsmConfig>::on_runtime_upgrade();
445			InitializePsm::<Test, TestPsmConfig>::on_runtime_upgrade();
446
447			// Same result as running once.
448			assert_eq!(MaxPsmDebtOfTotal::<Test>::get(), TestPsmConfig::max_psm_debt_of_total());
449			for (asset_id, _) in TestPsmConfig::asset_configs() {
450				assert_eq!(
451					ExternalAssets::<Test>::get(asset_id),
452					Some(CircuitBreakerLevel::AllEnabled)
453				);
454				assert!(ExternalDecimals::<Test>::get(asset_id).is_some());
455			}
456		});
457	}
458}