referrerpolicy=no-referrer-when-downgrade

pallet_recovery/migrations/
v1.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
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//! Multi-block migration from v0 to v1 for the recovery pallet.
19
20extern crate alloc;
21
22use super::{v0, PALLET_MIGRATIONS_ID};
23use crate::{pallet, Pallet};
24#[cfg(feature = "try-runtime")]
25use alloc::vec::Vec;
26use codec::{Decode, Encode, MaxEncodedLen};
27use frame_support::{
28	migrations::{MigrationId, SteppedMigration, SteppedMigrationError},
29	pallet_prelude::PhantomData,
30	traits::{
31		fungible::MutateHold, Consideration, Get, GetStorageVersion, ReservableCurrency,
32		StorageVersion,
33	},
34	weights::WeightMeter,
35	BoundedVec,
36};
37
38/// Cursor for tracking migration progress across the three old storage items.
39#[derive(Encode, Decode, MaxEncodedLen, Clone, PartialEq, Eq, Debug)]
40pub enum MigrationCursor<AccountId: MaxEncodedLen> {
41	/// Migrating `Recoverable` storage to `FriendGroups`.
42	Recoverable(Option<AccountId>),
43	/// Migrating `ActiveRecoveries` storage to `Attempt`.
44	ActiveRecoveries(Option<(AccountId, AccountId)>),
45	/// Migrating `Proxy` storage to `Inheritor`.
46	Proxy(Option<AccountId>),
47}
48
49/// Multi-block migration from v0 to v1.
50///
51/// This migration:
52/// 1. Converts `Recoverable` entries to `FriendGroups` with inheritor set to the multisig account
53///    derived from friends + threshold (so friends can collectively control inherited accounts via
54///    the multisig pallet)
55/// 2. Converts `ActiveRecoveries` to `Attempt` entries, preserving approval state
56/// 3. Converts `Proxy` entries to `Inheritor` (inverts the mapping)
57///
58/// All old deposits are unreserved and new consideration tickets are created.
59/// Entries that fail to migrate (e.g., due to insufficient funds for new tickets)
60/// are logged and skipped - the old deposit is still returned to the user.
61pub struct MigrateV0ToV1<T: v0::MigrationConfig>(PhantomData<T>);
62
63impl<T: v0::MigrationConfig> SteppedMigration for MigrateV0ToV1<T> {
64	type Cursor = MigrationCursor<T::AccountId>;
65	type Identifier = MigrationId<18>;
66
67	fn id() -> Self::Identifier {
68		MigrationId { pallet_id: *PALLET_MIGRATIONS_ID, version_from: 0, version_to: 1 }
69	}
70
71	fn step(
72		cursor: Option<Self::Cursor>,
73		meter: &mut WeightMeter,
74	) -> Result<Option<Self::Cursor>, SteppedMigrationError> {
75		if Pallet::<T>::on_chain_storage_version() != Self::id().version_from as u16 {
76			return Ok(None);
77		}
78
79		let required = T::DbWeight::get().reads_writes(2, 2);
80
81		if meter.remaining().any_lt(required) {
82			return Err(SteppedMigrationError::InsufficientWeight { required });
83		}
84
85		let mut cursor = cursor.unwrap_or(MigrationCursor::Recoverable(None));
86
87		loop {
88			if meter.try_consume(required).is_err() {
89				break;
90			}
91
92			match cursor {
93				MigrationCursor::Recoverable(last_key) => {
94					let mut iter = if let Some(ref key) = last_key {
95						v0::Recoverable::<T>::iter_from_key(key)
96					} else {
97						v0::Recoverable::<T>::iter()
98					};
99
100					if let Some((account, config)) = iter.next() {
101						// Unreserve the old deposit, we ignore the case that this could reap
102						let _ = <T as v0::MigrationConfig>::Currency::unreserve(
103							&account,
104							config.deposit,
105						);
106
107						// We calculate a multisig and use it as inheritor since the old logic did
108						// not have a dedicated inheritor.
109						let mut sorted_friends = config.friends.to_vec();
110						sorted_friends.sort();
111						let inheritor =
112							v0::multi_account_id::<T::AccountId>(&sorted_friends, config.threshold);
113						let friend_group = config.into_v1_friend_group(inheritor);
114
115						let friend_groups = BoundedVec::try_from(alloc::vec![friend_group])
116							.expect("ensured by integrity_test; qed");
117						let footprint = Pallet::<T>::friend_group_footprint(&friend_groups);
118
119						match T::FriendGroupsConsideration::new(&account, footprint) {
120							Ok(ticket) => {
121								pallet::FriendGroups::<T>::insert(
122									&account,
123									(friend_groups, ticket),
124								);
125							},
126							Err(_) => {
127								frame_support::defensive!(
128									"MigrateV0ToV1: Failed to create FriendGroups ticket, skipping"
129								);
130							},
131						}
132
133						v0::Recoverable::<T>::remove(&account);
134						cursor = MigrationCursor::Recoverable(Some(account));
135					} else {
136						cursor = MigrationCursor::ActiveRecoveries(None);
137					}
138				},
139				MigrationCursor::ActiveRecoveries(last_key) => {
140					let mut iter = if let Some((ref lost, ref rescuer)) = last_key {
141						v0::ActiveRecoveries::<T>::iter_from(
142							v0::ActiveRecoveries::<T>::hashed_key_for(lost, rescuer),
143						)
144					} else {
145						v0::ActiveRecoveries::<T>::iter()
146					};
147
148					let Some((lost, rescuer, recovery)) = iter.next() else {
149						cursor = MigrationCursor::Proxy(None);
150						continue;
151					};
152
153					cursor =
154						MigrationCursor::ActiveRecoveries(Some((lost.clone(), rescuer.clone())));
155					v0::ActiveRecoveries::<T>::remove(&lost, &rescuer);
156
157					// Unreserve the old deposit
158					let _ =
159						<T as v0::MigrationConfig>::Currency::unreserve(&rescuer, recovery.deposit);
160
161					// Try to find the friend group for this recovery that we already migrated
162					let Some((friend_groups, _)) = pallet::FriendGroups::<T>::get(&lost) else {
163						frame_support::defensive!(
164							"MigrateV0ToV1: Failed to find FriendGroups for lost account"
165						);
166						continue;
167					};
168
169					if friend_groups.len() != 1 {
170						frame_support::defensive!(
171							"MigrateV0ToV1: Expected exactly one friend group for lost account"
172						);
173						continue;
174					}
175
176					let Some(fg) = friend_groups.first() else {
177						frame_support::defensive!(
178							"MigrateV0ToV1: Failed to find friend group for lost account"
179						);
180						continue;
181					};
182
183					// Convert vouched friends list to approval bitfield
184					let mut approvals = crate::ApprovalBitfieldOf::<T>::default();
185					for voucher in recovery.friends.iter() {
186						if let Some(index) = fg.friends.iter().position(|f| f == voucher) {
187							let _ = approvals.set_if_not_set(index);
188						} else {
189							frame_support::defensive!(
190								"MigrateV0ToV1: Voucher not found in friend group"
191							);
192							continue;
193						}
194					}
195
196					let attempt = crate::AttemptOf::<T> {
197						friend_group_index: 0, // 0 since there is only one friend group
198						initiator: rescuer.clone(),
199						init_block: recovery.created,
200						last_approval_block: recovery.created,
201						approvals,
202					};
203
204					let security_deposit = T::SecurityDeposit::get();
205					let ticket = match crate::AttemptTicketOf::<T>::new(
206						&rescuer,
207						Pallet::<T>::attempt_footprint(),
208					) {
209						Ok(ticket) => ticket,
210						Err(e) => {
211							log::error!(
212								"MigrateV0ToV1: Failed to create Attempt ticket for rescuer {:?}: {:?}",
213								rescuer,
214								e,
215							);
216							crate::IdentifiedConsideration {
217								depositor: rescuer.clone(),
218								ticket: None,
219								_phantom: Default::default(),
220							}
221						},
222					};
223
224					let held_deposit = if <T as pallet::Config>::Currency::hold(
225						&crate::HoldReason::SecurityDeposit.into(),
226						&rescuer,
227						security_deposit,
228					)
229					.is_ok()
230					{
231						security_deposit
232					} else {
233						log::warn!(
234							"MigrateV0ToV1: Failed to hold security deposit for rescuer; \
235							 inserting Attempt with zero deposit"
236						);
237						Default::default()
238					};
239
240					pallet::Attempt::<T>::insert(
241						&lost,
242						0u32, // group index 0
243						(attempt, ticket, held_deposit),
244					);
245				},
246				MigrationCursor::Proxy(last_key) => {
247					let mut iter = if let Some(ref key) = last_key {
248						v0::Proxy::<T>::iter_from_key(key)
249					} else {
250						v0::Proxy::<T>::iter()
251					};
252
253					let Some((rescuer, lost)) = iter.next() else {
254						// only exit return
255						StorageVersion::new(Self::id().version_to as u16).put::<Pallet<T>>();
256						return Ok(None);
257					};
258					cursor = MigrationCursor::Proxy(Some(rescuer.clone()));
259					v0::Proxy::<T>::remove(&rescuer);
260
261					// All ongoing rescuers got a consumer ref... bad old code
262					let _ = frame_system::Pallet::<T>::dec_consumers(&rescuer);
263
264					let inheritor = rescuer.clone();
265					let inheritance_priority = 0u32;
266
267					// Create inheritor ticket
268					let ticket = match Pallet::<T>::inheritor_ticket(&inheritor) {
269						Ok(ticket) => ticket,
270						Err(e) => {
271							log::error!("MigrateV0ToV1: Failed to create Inheritor ticket for rescuer {:?}: {:?}", inheritor, e);
272							crate::IdentifiedConsideration {
273								depositor: rescuer.clone(),
274								ticket: None,
275								_phantom: Default::default(),
276							}
277						},
278					};
279
280					pallet::Inheritor::<T>::insert(
281						&lost,
282						(inheritance_priority, inheritor, ticket),
283					);
284				},
285			}
286		}
287
288		Ok(Some(cursor))
289	}
290
291	#[cfg(feature = "try-runtime")]
292	fn pre_upgrade() -> Result<Vec<u8>, frame_support::sp_runtime::TryRuntimeError> {
293		use codec::Encode;
294
295		let recoverable_count = v0::Recoverable::<T>::iter().count() as u32;
296		let active_recoveries_count = v0::ActiveRecoveries::<T>::iter().count() as u32;
297		let proxy_count = v0::Proxy::<T>::iter().count() as u32;
298
299		log::info!(
300			target: "runtime::recovery",
301			"MigrateV0ToV1: pre_upgrade - Recoverable: {}, ActiveRecoveries: {}, Proxy: {}",
302			recoverable_count,
303			active_recoveries_count,
304			proxy_count,
305		);
306
307		Ok((recoverable_count, active_recoveries_count, proxy_count).encode())
308	}
309
310	#[cfg(feature = "try-runtime")]
311	fn post_upgrade(state: Vec<u8>) -> Result<(), frame_support::sp_runtime::TryRuntimeError> {
312		use codec::Decode;
313
314		let (recoverable_count, active_recoveries_count, proxy_count) =
315			<(u32, u32, u32)>::decode(&mut &state[..]).expect("Failed to decode pre_upgrade state");
316
317		// All old storage should be cleared
318		assert_eq!(v0::Recoverable::<T>::iter().count(), 0);
319		assert_eq!(v0::ActiveRecoveries::<T>::iter().count(), 0);
320		assert_eq!(v0::Proxy::<T>::iter().count(), 0);
321
322		// New storage should be populated
323		let friend_groups_count = pallet::FriendGroups::<T>::iter().count() as u32;
324		let attempt_count = pallet::Attempt::<T>::iter().count() as u32;
325		let inheritor_count = pallet::Inheritor::<T>::iter().count() as u32;
326
327		log::info!(
328			target: "runtime::recovery",
329			"MigrateV0ToV1: post_upgrade - FriendGroups: {}, Attempt: {}, Inheritor: {}",
330			friend_groups_count,
331			attempt_count,
332			inheritor_count,
333		);
334
335		// These can fail for Kusama AH because of buggy accounts...
336		if friend_groups_count != recoverable_count {
337			log::error!(
338				"MigrateV0ToV1: FriendGroups count mismatch: {} != {}",
339				friend_groups_count,
340				recoverable_count
341			);
342		}
343		if attempt_count != active_recoveries_count {
344			log::error!(
345				"MigrateV0ToV1: Attempt count mismatch: {} != {}",
346				attempt_count,
347				active_recoveries_count
348			);
349		}
350		if inheritor_count != proxy_count {
351			log::error!(
352				"MigrateV0ToV1: Inheritor count mismatch: {} != {}",
353				inheritor_count,
354				proxy_count
355			);
356		}
357
358		Ok(())
359	}
360}
361
362#[cfg(test)]
363mod tests {
364	use super::{v0, MigrateV0ToV1};
365	use crate::{
366		mock::{new_test_ext, Balances, Test, ALICE, BOB, CHARLIE, DAVE, EVE},
367		pallet,
368	};
369	use frame_support::{
370		migrations::SteppedMigration,
371		traits::{GetStorageVersion, ReservableCurrency, StorageVersion},
372		weights::WeightMeter,
373		BoundedVec,
374	};
375
376	type T = Test;
377
378	fn friends(accounts: &[u64]) -> v0::FriendsOf<T> {
379		let mut f: Vec<u64> = accounts.to_vec();
380		f.sort();
381		BoundedVec::try_from(f).unwrap()
382	}
383
384	fn run_migration() {
385		let mut cursor = None;
386
387		#[cfg(feature = "try-runtime")]
388		let data = MigrateV0ToV1::<T>::pre_upgrade().unwrap();
389
390		loop {
391			let mut meter = WeightMeter::new();
392			match MigrateV0ToV1::<T>::step(cursor, &mut meter) {
393				Ok(None) => break,
394				Ok(Some(c)) => cursor = Some(c),
395				Err(e) => panic!("Migration failed: {:?}", e),
396			}
397		}
398
399		#[cfg(feature = "try-runtime")]
400		MigrateV0ToV1::<T>::post_upgrade(data).unwrap();
401	}
402
403	#[test]
404	fn migration_works() {
405		new_test_ext().execute_with(|| {
406			let config_deposit = 50u128;
407			let recovery_deposit = 100u128;
408
409			// === Setup v0 storage ===
410
411			// 1. Recoverable configs for ALICE and BOB
412			v0::Recoverable::<T>::insert(
413				ALICE,
414				v0::RecoveryConfig {
415					delay_period: 10u64,
416					deposit: config_deposit,
417					friends: friends(&[BOB, CHARLIE]),
418					threshold: 2,
419				},
420			);
421			Balances::reserve(&ALICE, config_deposit).unwrap();
422
423			v0::Recoverable::<T>::insert(
424				BOB,
425				v0::RecoveryConfig {
426					delay_period: 5u64,
427					deposit: config_deposit,
428					friends: friends(&[ALICE, CHARLIE]),
429					threshold: 1,
430				},
431			);
432			Balances::reserve(&BOB, config_deposit).unwrap();
433
434			// EVE has a zero delay period - should be clamped to 1
435			v0::Recoverable::<T>::insert(
436				EVE,
437				v0::RecoveryConfig {
438					delay_period: 0u64,
439					deposit: config_deposit,
440					friends: friends(&[ALICE, BOB]),
441					threshold: 1,
442				},
443			);
444			Balances::reserve(&EVE, config_deposit).unwrap();
445
446			// 2. Active recovery: CHARLIE trying to recover ALICE
447			v0::ActiveRecoveries::<T>::insert(
448				ALICE,
449				CHARLIE,
450				v0::ActiveRecovery {
451					created: 1u64,
452					deposit: recovery_deposit,
453					friends: friends(&[BOB]),
454				},
455			);
456			Balances::reserve(&CHARLIE, recovery_deposit).unwrap();
457
458			// 3. Proxy: DAVE has recovered EVE's account
459			v0::Proxy::<T>::insert(DAVE, EVE);
460			frame_system::Pallet::<T>::inc_consumers(&DAVE).unwrap();
461
462			// === Verify v0 state before migration ===
463			assert_eq!(v0::Recoverable::<T>::iter().count(), 3);
464			assert_eq!(v0::ActiveRecoveries::<T>::iter().count(), 1);
465			assert_eq!(v0::Proxy::<T>::iter().count(), 1);
466			assert_eq!(Balances::reserved_balance(ALICE), config_deposit);
467			assert_eq!(Balances::reserved_balance(BOB), config_deposit);
468			assert_eq!(Balances::reserved_balance(CHARLIE), recovery_deposit);
469			assert_eq!(Balances::reserved_balance(EVE), config_deposit);
470			assert_eq!(frame_system::Pallet::<T>::consumers(&DAVE), 1);
471
472			// === Run migration ===
473			run_migration();
474
475			// === Verify v0 storage is cleared ===
476			assert_eq!(v0::Recoverable::<T>::iter().count(), 0);
477			assert_eq!(v0::ActiveRecoveries::<T>::iter().count(), 0);
478			assert_eq!(v0::Proxy::<T>::iter().count(), 0);
479
480			// === Verify v1 storage is populated ===
481
482			// FriendGroups should have entries for ALICE, BOB, and EVE
483			assert_eq!(pallet::FriendGroups::<T>::iter().count(), 3);
484
485			// Check ALICE's migrated FriendGroups
486			let (alice_groups, _ticket) = pallet::FriendGroups::<T>::get(ALICE).unwrap();
487			assert_eq!(alice_groups.len(), 1);
488			let alice_fg = &alice_groups[0];
489			assert_eq!(alice_fg.friends.len(), 2);
490			assert!(alice_fg.friends.contains(&BOB));
491			assert!(alice_fg.friends.contains(&CHARLIE));
492			assert_eq!(alice_fg.friends_needed, 2);
493			assert_eq!(alice_fg.inheritance_delay, 10);
494			// Inheritor should be the multisig account derived from friends + threshold
495			let expected_inheritor = v0::multi_account_id::<u64>(&[BOB, CHARLIE], 2);
496			assert_eq!(alice_fg.inheritor, expected_inheritor);
497			assert_eq!(alice_fg.inheritance_priority, 0);
498
499			// Check BOB's migrated FriendGroups
500			let (bob_groups, _ticket) = pallet::FriendGroups::<T>::get(BOB).unwrap();
501			assert_eq!(bob_groups.len(), 1);
502			let bob_fg = &bob_groups[0];
503			assert_eq!(bob_fg.friends_needed, 1);
504			assert_eq!(bob_fg.inheritance_delay, 5);
505
506			// Check EVE's migrated FriendGroups - cancel_delay clamped from 0 to 1
507			let (eve_groups, _ticket) = pallet::FriendGroups::<T>::get(EVE).unwrap();
508			assert_eq!(eve_groups.len(), 1);
509			let eve_fg = &eve_groups[0];
510			assert_eq!(eve_fg.inheritance_delay, 0);
511			assert_eq!(eve_fg.cancel_delay, 1);
512
513			// Inheritor should have entry for EVE (lost) -> DAVE (inheritor)
514			assert_eq!(pallet::Inheritor::<T>::iter().count(), 1);
515			let (order, inheritor, _ticket) = pallet::Inheritor::<T>::get(EVE).unwrap();
516			assert_eq!(inheritor, DAVE);
517			assert_eq!(order, 0);
518
519			// === Verify Attempt migration ===
520			// ActiveRecovery for (ALICE, CHARLIE) should be migrated to Attempt
521			assert_eq!(pallet::Attempt::<T>::iter().count(), 1);
522			let (attempt, _ticket, deposit) = pallet::Attempt::<T>::get(ALICE, 0u32).unwrap();
523			assert_eq!(attempt.initiator, CHARLIE);
524			assert_eq!(attempt.friend_group_index, 0);
525			assert_eq!(deposit, crate::mock::SECURITY_DEPOSIT);
526		});
527	}
528
529	#[test]
530	fn migration_inserts_attempt_when_security_deposit_hold_fails() {
531		new_test_ext().execute_with(|| {
532			let config_deposit = 50u128;
533			// Old recovery deposit is much smaller than new SECURITY_DEPOSIT (100)
534			let old_recovery_deposit = 10u128;
535
536			// Use a fresh account (99) as the rescuer with a very tight balance.
537			// They need enough for: attempt ticket + security deposit (100).
538			// We give them just enough for the ticket but NOT for the security deposit.
539			let rescuer: u64 = 99;
540			let lost = ALICE;
541
542			// Give rescuer a small balance: old_recovery_deposit (reserved) + a bit of free
543			// balance. After unreserve they'll have ~60 free, which covers the attempt ticket
544			// but not SECURITY_DEPOSIT (100).
545			let rescuer_free = 50u128;
546			pallet_balances::Pallet::<Test>::force_set_balance(
547				frame_system::RawOrigin::Root.into(),
548				rescuer,
549				rescuer_free + old_recovery_deposit,
550			)
551			.unwrap();
552			Balances::reserve(&rescuer, old_recovery_deposit).unwrap();
553
554			// Setup v0 Recoverable for the lost account (required for migration to find friend
555			// group)
556			v0::Recoverable::<T>::insert(
557				lost,
558				v0::RecoveryConfig {
559					delay_period: 10u64,
560					deposit: config_deposit,
561					friends: friends(&[BOB, CHARLIE]),
562					threshold: 2,
563				},
564			);
565			Balances::reserve(&lost, config_deposit).unwrap();
566
567			// Setup v0 ActiveRecovery: rescuer trying to recover lost's account
568			v0::ActiveRecoveries::<T>::insert(
569				lost,
570				rescuer,
571				v0::ActiveRecovery {
572					created: 1u64,
573					deposit: old_recovery_deposit,
574					friends: BoundedVec::default(), // no vouchers yet
575				},
576			);
577
578			assert_eq!(v0::ActiveRecoveries::<T>::iter().count(), 1);
579
580			// Run migration
581			run_migration();
582
583			// The old storage should be cleared
584			assert_eq!(v0::ActiveRecoveries::<T>::iter().count(), 0);
585
586			assert_eq!(
587				pallet::Attempt::<T>::iter().count(),
588				1,
589				"Attempt entry was not inserted during migration — active recovery lost!"
590			);
591		});
592	}
593
594	#[test]
595	fn migrated_recovery_can_be_completed() {
596		use crate::mock::{signed, Recovery};
597		use frame_support::assert_ok;
598
599		new_test_ext().execute_with(|| {
600			let config_deposit = 50u128;
601			let recovery_deposit = 100u128;
602
603			// === Setup v0 storage ===
604			// ALICE has a recovery config with BOB, CHARLIE, DAVE as friends, threshold 2
605			v0::Recoverable::<T>::insert(
606				ALICE,
607				v0::RecoveryConfig {
608					delay_period: 10u64,
609					deposit: config_deposit,
610					friends: friends(&[BOB, CHARLIE, DAVE]),
611					threshold: 2,
612				},
613			);
614			Balances::reserve(&ALICE, config_deposit).unwrap();
615
616			// BOB started a recovery attempt for ALICE, CHARLIE has already vouched
617			v0::ActiveRecoveries::<T>::insert(
618				ALICE,
619				BOB,
620				v0::ActiveRecovery {
621					created: 1u64,
622					deposit: recovery_deposit,
623					friends: friends(&[CHARLIE]), // CHARLIE already vouched
624				},
625			);
626			Balances::reserve(&BOB, recovery_deposit).unwrap();
627
628			// === Run migration ===
629			run_migration();
630
631			// === Verify migration worked ===
632			assert_eq!(pallet::FriendGroups::<T>::iter().count(), 1);
633			assert_eq!(pallet::Attempt::<T>::iter().count(), 1);
634
635			// Compute the expected multisig inheritor (doesn't need to exist as an account)
636			let multisig_inheritor = v0::multi_account_id::<u64>(&[BOB, CHARLIE, DAVE], 2);
637			// Verify the inheritor is correctly set to the multisig account
638			let (groups, _) = pallet::FriendGroups::<T>::get(ALICE).unwrap();
639			assert_eq!(groups[0].inheritor, multisig_inheritor);
640
641			// Check the attempt state
642			let (attempt, _, _) = pallet::Attempt::<T>::get(ALICE, 0u32).unwrap();
643			assert_eq!(attempt.initiator, BOB);
644			// CHARLIE's approval should be preserved (index 1 in sorted [BOB, CHARLIE, DAVE])
645			assert_eq!(attempt.approvals.count_ones(), 1);
646
647			// === Now complete the recovery using the new pallet ===
648
649			// DAVE approves (this should be the 2nd approval, meeting threshold)
650			assert_ok!(Recovery::approve_attempt(signed(DAVE), ALICE, 0));
651
652			// Check we now have 2 approvals
653			let (attempt, _, _) = pallet::Attempt::<T>::get(ALICE, 0u32).unwrap();
654			assert_eq!(attempt.approvals.count_ones(), 2);
655
656			// Advance blocks past the inheritance_delay (10 blocks)
657			frame_system::Pallet::<T>::set_block_number(15);
658
659			// Anyone can finish the attempt now that requirements are met
660			assert_ok!(Recovery::finish_attempt(signed(BOB), ALICE, 0));
661
662			// Verify the multisig is now the inheritor of ALICE's account
663			let (order, inheritor, _) = pallet::Inheritor::<T>::get(ALICE).unwrap();
664			assert_eq!(inheritor, multisig_inheritor);
665			assert_eq!(order, 0);
666
667			// Attempt should be removed after finish
668			assert!(pallet::Attempt::<T>::get(ALICE, 0u32).is_none());
669		});
670	}
671
672	#[test]
673	fn migration_bumps_on_chain_storage_version() {
674		new_test_ext().execute_with(|| {
675			StorageVersion::new(0).put::<pallet::Pallet<T>>();
676			assert_eq!(pallet::Pallet::<T>::on_chain_storage_version(), 0);
677
678			v0::Recoverable::<T>::insert(
679				ALICE,
680				v0::RecoveryConfig {
681					delay_period: 10u64,
682					deposit: 50u128,
683					friends: friends(&[BOB, CHARLIE]),
684					threshold: 2,
685				},
686			);
687			Balances::reserve(&ALICE, 50u128).unwrap();
688
689			run_migration();
690
691			assert_eq!(pallet::Pallet::<T>::on_chain_storage_version(), 1);
692		});
693	}
694
695	#[test]
696	fn migration_is_idempotent_after_completion() {
697		new_test_ext().execute_with(|| {
698			StorageVersion::new(0).put::<pallet::Pallet<T>>();
699
700			v0::Recoverable::<T>::insert(
701				ALICE,
702				v0::RecoveryConfig {
703					delay_period: 10u64,
704					deposit: 50u128,
705					friends: friends(&[BOB, CHARLIE]),
706					threshold: 2,
707				},
708			);
709			Balances::reserve(&ALICE, 50u128).unwrap();
710
711			run_migration();
712			assert_eq!(pallet::Pallet::<T>::on_chain_storage_version(), 1);
713
714			let _guard = frame_support::StorageNoopGuard::new();
715			let mut meter = WeightMeter::new();
716			assert!(matches!(MigrateV0ToV1::<T>::step(None, &mut meter), Ok(None)));
717		});
718	}
719
720	#[test]
721	fn migration_inserts_attempt_when_storage_ticket_fails() {
722		new_test_ext().execute_with(|| {
723			let config_deposit = 50u128;
724			let old_recovery_deposit = 10u128;
725			let rescuer: u64 = 99;
726			let lost = ALICE;
727
728			pallet_balances::Pallet::<Test>::force_set_balance(
729				frame_system::RawOrigin::Root.into(),
730				rescuer,
731				crate::mock::ExistentialDeposit::get() as u128 + old_recovery_deposit,
732			)
733			.unwrap();
734			Balances::reserve(&rescuer, old_recovery_deposit).unwrap();
735
736			v0::Recoverable::<T>::insert(
737				lost,
738				v0::RecoveryConfig {
739					delay_period: 10u64,
740					deposit: config_deposit,
741					friends: friends(&[BOB, CHARLIE]),
742					threshold: 2,
743				},
744			);
745			Balances::reserve(&lost, config_deposit).unwrap();
746
747			v0::ActiveRecoveries::<T>::insert(
748				lost,
749				rescuer,
750				v0::ActiveRecovery {
751					created: 1u64,
752					deposit: old_recovery_deposit,
753					friends: BoundedVec::default(),
754				},
755			);
756
757			run_migration();
758
759			assert_eq!(v0::ActiveRecoveries::<T>::iter().count(), 0);
760			assert_eq!(
761				pallet::Attempt::<T>::iter().count(),
762				1,
763				"Attempt entry must survive even when storage ticket creation fails",
764			);
765
766			let (attempt, ticket, held_deposit) = pallet::Attempt::<T>::get(lost, 0u32).unwrap();
767			assert_eq!(attempt.initiator, rescuer);
768			assert!(ticket.ticket.is_none(), "Inner ticket must be None when storage hold failed");
769			assert_eq!(ticket.depositor, rescuer);
770			assert_eq!(held_deposit, 0, "Security deposit must be zero when hold failed");
771
772			use frame::traits::fungible::InspectHold;
773			assert_eq!(
774				Balances::balance_on_hold(&crate::HoldReason::AttemptStorage.into(), &rescuer),
775				0,
776			);
777			assert_eq!(
778				Balances::balance_on_hold(&crate::HoldReason::SecurityDeposit.into(), &rescuer),
779				0,
780			);
781		});
782	}
783
784	#[test]
785	fn migration_inserts_inheritor_when_ticket_fails() {
786		use frame::traits::fungible::InspectHold;
787
788		new_test_ext().execute_with(|| {
789			let rescuer: u64 = 99;
790			let lost = ALICE;
791
792			pallet_balances::Pallet::<Test>::force_set_balance(
793				frame_system::RawOrigin::Root.into(),
794				rescuer,
795				crate::mock::ExistentialDeposit::get() as u128,
796			)
797			.unwrap();
798			frame_system::Pallet::<T>::inc_consumers(&rescuer).unwrap();
799
800			v0::Proxy::<T>::insert(rescuer, lost);
801			assert_eq!(frame_system::Pallet::<T>::consumers(&rescuer), 1);
802
803			run_migration();
804
805			assert_eq!(v0::Proxy::<T>::iter().count(), 0);
806			assert_eq!(frame_system::Pallet::<T>::consumers(&rescuer), 0);
807
808			assert_eq!(
809				pallet::Inheritor::<T>::iter().count(),
810				1,
811				"Inheritor entry must survive even when ticket creation fails",
812			);
813			let (priority, inheritor, ticket) = pallet::Inheritor::<T>::get(lost).unwrap();
814			assert_eq!(inheritor, rescuer);
815			assert_eq!(priority, 0);
816			assert!(ticket.ticket.is_none(), "Inner ticket must be None when hold failed");
817			assert_eq!(ticket.depositor, rescuer);
818
819			assert_eq!(
820				Balances::balance_on_hold(&crate::HoldReason::InheritorStorage.into(), &rescuer),
821				0,
822			);
823		});
824	}
825}