referrerpolicy=no-referrer-when-downgrade

pallet_psm/migrations/
decimals.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//! One-shot migration that populates decimal snapshots for a pre-existing PSM
19//! deployment.
20//!
21//! Purpose: chains that approved external assets before the multi-decimal upgrade
22//! have entries in `ExternalAssets` but no `ExternalDecimals` snapshots, and no
23//! `InternalDecimals` either. Mint and redeem both require these snapshots and
24//! will fail closed (`Error::DecimalsMismatch` / `Error::Unexpected`) until they
25//! are populated. This migration reads live metadata and writes the snapshots.
26//!
27//! Out-of-range assets are handled gracefully: if an existing asset's decimals
28//! differ from the internal asset's decimals by more than [`MAX_DECIMALS_DIFF`],
29//! the migration still writes its decimals snapshot but flips its circuit
30//! breaker to [`CircuitBreakerLevel::AllDisabled`]. The chain keeps upgrading;
31//! governance can remove or re-enable the asset later once the off-chain
32//! situation is resolved. The `try-runtime` post-upgrade hook verifies this
33//! invariant — any out-of-range asset must end up disabled.
34//!
35//! Safe to run multiple times — already-populated snapshots are not overwritten.
36//!
37//! # Usage
38//!
39//! ```ignore
40//! pub type Migrations = (
41//!     pallet_psm::migrations::decimals::PopulateDecimals<Runtime>,
42//!     // ... other migrations
43//! );
44//! ```
45
46#[cfg(feature = "try-runtime")]
47use alloc::vec::Vec;
48use frame_support::{
49	migrations::VersionedMigration,
50	pallet_prelude::Weight,
51	traits::{
52		fungible::metadata::Inspect as FungibleMetadataInspect,
53		fungibles::metadata::Inspect as FungiblesMetadataInspect, Get, UncheckedOnRuntimeUpgrade,
54	},
55};
56
57use crate::{
58	pallet::{
59		CircuitBreakerLevel, ExternalAssets, ExternalDecimals, InternalDecimals, MAX_DECIMALS_DIFF,
60	},
61	Config, Pallet,
62};
63
64#[cfg(feature = "try-runtime")]
65use frame_support::ensure;
66#[cfg(feature = "try-runtime")]
67use sp_runtime::TryRuntimeError;
68
69const LOG_TARGET: &str = "runtime::psm::migration::populate_decimals";
70
71/// Version-gated v1 -> v2 migration that fills in decimal snapshots for all
72/// pre-existing external assets and the internal asset, and bumps the pallet
73/// on-chain storage version from 1 to 2.
74pub type PopulateDecimals<T> = VersionedMigration<
75	1,
76	2,
77	InnerPopulateDecimals<T>,
78	Pallet<T>,
79	<T as frame_system::Config>::DbWeight,
80>;
81
82/// Version-unchecked migration logic. Exposed only for use by [`PopulateDecimals`].
83///
84/// Should never be placed directly into a runtime's migrations tuple — use the
85/// versioned alias [`PopulateDecimals`] so the on-chain storage version is
86/// checked and bumped.
87pub struct InnerPopulateDecimals<T>(core::marker::PhantomData<T>);
88
89impl<T: Config> UncheckedOnRuntimeUpgrade for InnerPopulateDecimals<T> {
90	fn on_runtime_upgrade() -> Weight {
91		log::info!(
92			target: LOG_TARGET,
93			"Running PopulateDecimals: backfilling decimal snapshots"
94		);
95
96		let mut reads = 0u64;
97		let mut writes = 0u64;
98
99		// Internal asset snapshot — only write if missing.
100		reads += 2;
101		let internal_decimals = T::InternalAsset::decimals();
102		if !InternalDecimals::<T>::exists() {
103			InternalDecimals::<T>::put(internal_decimals);
104			writes += 1;
105		}
106
107		// Per-asset snapshots. Walk every approved external asset.
108		for (asset_id, status) in ExternalAssets::<T>::iter() {
109			reads += 3; // ExternalAssets iter item + ExternalDecimals + Fungibles::decimals reads below
110			if ExternalDecimals::<T>::contains_key(&asset_id) {
111				log::info!(
112					target: LOG_TARGET,
113					"Asset {:?} already has a decimals snapshot, skipping",
114					asset_id,
115				);
116				continue;
117			}
118
119			let asset_decimals = T::Fungibles::decimals(asset_id.clone());
120			ExternalDecimals::<T>::insert(&asset_id, asset_decimals);
121			writes += 1;
122
123			let diff = asset_decimals.abs_diff(internal_decimals) as u32;
124			if diff > MAX_DECIMALS_DIFF {
125				// Do not fail the migration. Disable swaps for this asset so
126				// mint/redeem cannot operate on an unsupported decimal gap. The
127				// snapshot is still written — it preserves the observed state
128				// and lets the runtime guard surface the divergence clearly.
129				if status != CircuitBreakerLevel::AllDisabled {
130					ExternalAssets::<T>::insert(&asset_id, CircuitBreakerLevel::AllDisabled);
131					writes += 1;
132				}
133				log::warn!(
134					target: LOG_TARGET,
135					"Asset {:?} decimals diff ({}) exceeds MAX_DECIMALS_DIFF ({}); disabling",
136					asset_id,
137					diff,
138					MAX_DECIMALS_DIFF,
139				);
140			} else {
141				log::info!(
142					target: LOG_TARGET,
143					"Populated decimals snapshot for asset {:?} (decimals={})",
144					asset_id,
145					asset_decimals,
146				);
147			}
148		}
149
150		log::info!(
151			target: LOG_TARGET,
152			"PopulateDecimals complete"
153		);
154
155		T::DbWeight::get().reads_writes(reads, writes)
156	}
157
158	#[cfg(feature = "try-runtime")]
159	fn pre_upgrade() -> Result<Vec<u8>, TryRuntimeError> {
160		Ok(Vec::new())
161	}
162
163	#[cfg(feature = "try-runtime")]
164	fn post_upgrade(_state: Vec<u8>) -> Result<(), TryRuntimeError> {
165		// Internal asset snapshot present and consistent with live metadata.
166		ensure!(
167			InternalDecimals::<T>::get() == Some(T::InternalAsset::decimals()),
168			"InternalDecimals snapshot missing or stale after migration"
169		);
170
171		let internal_decimals = T::InternalAsset::decimals();
172		for (asset_id, status) in ExternalAssets::<T>::iter() {
173			let snapshot = ExternalDecimals::<T>::get(&asset_id)
174				.ok_or("Approved external asset missing decimals snapshot after migration")?;
175			ensure!(
176				snapshot == T::Fungibles::decimals(asset_id),
177				"ExternalDecimals snapshot differs from live metadata after migration"
178			);
179			let diff = snapshot.abs_diff(internal_decimals) as u32;
180			if diff > MAX_DECIMALS_DIFF {
181				ensure!(
182					status == CircuitBreakerLevel::AllDisabled,
183					"Out-of-range external asset was not disabled by migration"
184				);
185			}
186		}
187
188		Ok(())
189	}
190}
191
192#[cfg(test)]
193mod tests {
194	use super::*;
195	use crate::{
196		mock::{
197			new_test_ext, RuntimeOrigin, Test, ALICE, DAI_MOCK_ASSET_ID, USDC_ASSET_ID,
198			USDT_ASSET_ID,
199		},
200		Pallet,
201	};
202	use frame_support::{
203		assert_ok,
204		traits::{GetStorageVersion, OnRuntimeUpgrade, StorageVersion},
205	};
206
207	/// The wrapper only runs when on-chain version is 1. Genesis sets it to 2,
208	/// so tests must roll it back to simulate a v1 chain.
209	fn prepare_v1() {
210		StorageVersion::new(1).put::<Pallet<Test>>();
211	}
212
213	#[test]
214	fn populate_decimals_backfills_existing_assets() {
215		new_test_ext().execute_with(|| {
216			// Simulate a pre-migration state: existing assets have ExternalAssets
217			// entries but no decimals snapshots (and no InternalDecimals).
218			prepare_v1();
219			InternalDecimals::<Test>::kill();
220			ExternalDecimals::<Test>::remove(USDC_ASSET_ID);
221			ExternalDecimals::<Test>::remove(USDT_ASSET_ID);
222
223			PopulateDecimals::<Test>::on_runtime_upgrade();
224
225			assert_eq!(InternalDecimals::<Test>::get(), Some(6));
226			assert_eq!(ExternalDecimals::<Test>::get(USDC_ASSET_ID), Some(6));
227			assert_eq!(ExternalDecimals::<Test>::get(USDT_ASSET_ID), Some(6));
228			// Normal status preserved since decimals are in range.
229			assert_eq!(
230				ExternalAssets::<Test>::get(USDC_ASSET_ID),
231				Some(CircuitBreakerLevel::AllEnabled)
232			);
233			assert_eq!(
234				ExternalAssets::<Test>::get(USDT_ASSET_ID),
235				Some(CircuitBreakerLevel::AllEnabled)
236			);
237		});
238	}
239
240	#[test]
241	fn populate_decimals_does_not_overwrite_existing_snapshots() {
242		new_test_ext().execute_with(|| {
243			prepare_v1();
244			// Genesis already wrote snapshots. Plant a sentinel to verify the
245			// migration does not overwrite it.
246			ExternalDecimals::<Test>::insert(USDC_ASSET_ID, 42u8);
247
248			PopulateDecimals::<Test>::on_runtime_upgrade();
249
250			assert_eq!(ExternalDecimals::<Test>::get(USDC_ASSET_ID), Some(42));
251		});
252	}
253
254	#[test]
255	fn populate_decimals_disables_out_of_range_assets() {
256		new_test_ext().execute_with(|| {
257			// Simulate: DAI_MOCK (18 decimals) was approved under a prior internal
258			// configuration; then internal metadata was changed to something exotic
259			// that makes the diff exceed MAX_DECIMALS_DIFF. We fake this by
260			// approving DAI and then shifting the internal asset's live decimals
261			// through metadata update.
262			use crate::mock::{Assets, INTERNAL_ASSET_ID};
263			use frame_support::traits::fungibles::metadata::Mutate as MetadataMutate;
264
265			assert_ok!(Pallet::<Test>::add_external_asset(
266				RuntimeOrigin::root(),
267				DAI_MOCK_ASSET_ID
268			));
269			// DAI has 18 decimals; internal currently 6; diff = 12 (in range).
270			// Shift internal to 40 decimals so the diff becomes 22 — still in range
271			// (MAX_DECIMALS_DIFF is 24). Push further to make diff too large:
272			// setting internal to the extreme (say, 45) would push diff = 27, > 24.
273			assert_ok!(<Assets as MetadataMutate<u64>>::set(
274				INTERNAL_ASSET_ID,
275				&ALICE,
276				b"Internal Asset".to_vec(),
277				b"INTERNAL".to_vec(),
278				45,
279			));
280
281			// Wipe DAI's snapshot and InternalDecimals to force repopulation, then
282			// roll back to v1 so the versioned wrapper actually runs.
283			ExternalDecimals::<Test>::remove(DAI_MOCK_ASSET_ID);
284			InternalDecimals::<Test>::kill();
285			prepare_v1();
286
287			PopulateDecimals::<Test>::on_runtime_upgrade();
288
289			// Snapshot was written regardless.
290			assert_eq!(InternalDecimals::<Test>::get(), Some(45));
291			assert_eq!(ExternalDecimals::<Test>::get(DAI_MOCK_ASSET_ID), Some(18));
292			// DAI is disabled because 45 - 18 = 27 > MAX_DECIMALS_DIFF (24).
293			assert_eq!(
294				ExternalAssets::<Test>::get(DAI_MOCK_ASSET_ID),
295				Some(CircuitBreakerLevel::AllDisabled)
296			);
297			// In-range assets stay enabled.
298			assert_eq!(
299				ExternalAssets::<Test>::get(USDC_ASSET_ID),
300				Some(CircuitBreakerLevel::AllEnabled)
301			);
302		});
303	}
304
305	#[test]
306	fn populate_decimals_runs_once_then_skips() {
307		new_test_ext().execute_with(|| {
308			prepare_v1();
309			InternalDecimals::<Test>::kill();
310			ExternalDecimals::<Test>::remove(USDC_ASSET_ID);
311
312			// First run: on-chain version is 1, migration executes and bumps to 2.
313			PopulateDecimals::<Test>::on_runtime_upgrade();
314			assert_eq!(Pallet::<Test>::on_chain_storage_version(), StorageVersion::new(2));
315			let stable1 = InternalDecimals::<Test>::get();
316			let usdc1 = ExternalDecimals::<Test>::get(USDC_ASSET_ID);
317
318			// Second run: on-chain version is 2, versioned wrapper skips — state
319			// is unchanged.
320			PopulateDecimals::<Test>::on_runtime_upgrade();
321			assert_eq!(InternalDecimals::<Test>::get(), stable1);
322			assert_eq!(ExternalDecimals::<Test>::get(USDC_ASSET_ID), usdc1);
323			assert_eq!(Pallet::<Test>::on_chain_storage_version(), StorageVersion::new(2));
324		});
325	}
326
327	#[test]
328	fn populate_decimals_skips_when_not_on_version_one() {
329		new_test_ext().execute_with(|| {
330			// Simulate an already-upgraded chain at v2. Wrapper must skip.
331			StorageVersion::new(2).put::<Pallet<Test>>();
332
333			ExternalDecimals::<Test>::remove(USDC_ASSET_ID);
334			PopulateDecimals::<Test>::on_runtime_upgrade();
335
336			// Snapshot not repopulated — migration was skipped.
337			assert_eq!(ExternalDecimals::<Test>::get(USDC_ASSET_ID), None);
338			// Version unchanged.
339			assert_eq!(Pallet::<Test>::on_chain_storage_version(), StorageVersion::new(2));
340		});
341	}
342}