1
// Copyright 2025 Moonbeam foundation
2
// This file is part of Moonbeam.
3

            
4
// Moonbeam is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8

            
9
// Moonbeam is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13

            
14
// You should have received a copy of the GNU General Public License
15
// along with Moonbeam.  If not, see <http://www.gnu.org/licenses/>.
16

            
17
//! # Lazy Migration Tests
18
//! Tests for the migration from Currency (locks) to Fungible (freezes) traits.
19
//! This module focuses specifically on testing the lazy migration functionality
20
//! that automatically converts accounts from the old lock-based system to the
21
//! new freeze-based system when they interact with staking operations.
22

            
23
use crate::mock::{
24
	query_freeze_amount, AccountId, Balances, ExtBuilder, ParachainStaking, RuntimeOrigin, Test,
25
};
26
use crate::set::OrderedSet;
27
use crate::{
28
	CandidateInfo, FreezeReason, MigratedCandidates, MigratedDelegators, COLLATOR_LOCK_ID,
29
	DELEGATOR_LOCK_ID,
30
};
31
use frame_support::assert_ok;
32
use frame_support::traits::LockableCurrency;
33
use frame_support::traits::WithdrawReasons;
34

            
35
// Helper function to create a collator account with old-style locks
36
24
fn setup_collator_with_lock(account: AccountId, bond: u128) {
37
24
	// Set the lock directly using the old system
38
24
	Balances::set_lock(COLLATOR_LOCK_ID, &account, bond, WithdrawReasons::all());
39
24

            
40
24
	// Manually insert candidate info (simulating pre-migration state)
41
24
	let candidate = crate::types::CandidateMetadata::new(bond);
42
24
	CandidateInfo::<Test>::insert(&account, candidate);
43
24

            
44
24
	// Add to candidate pool
45
24
	let mut pool = crate::CandidatePool::<Test>::get();
46
24
	let _ = pool.try_insert(crate::Bond {
47
24
		owner: account,
48
24
		amount: bond,
49
24
	});
50
24
	crate::CandidatePool::<Test>::put(pool);
51
24
}
52

            
53
// Helper function to create a delegator account with old-style locks
54
5
fn setup_delegator_with_lock(account: AccountId, collator: AccountId, amount: u128) {
55
5
	// Set the lock directly using the old system
56
5
	Balances::set_lock(DELEGATOR_LOCK_ID, &account, amount, WithdrawReasons::all());
57
5

            
58
5
	// Set up delegator state for migration to work
59
5
	let delegator = crate::Delegator {
60
5
		id: account,
61
5
		delegations: OrderedSet::from(vec![crate::Bond {
62
5
			owner: collator,
63
5
			amount,
64
5
		}]),
65
5
		total: amount,
66
5
		less_total: 0,
67
5
		status: crate::DelegatorStatus::Active,
68
5
	};
69
5
	crate::DelegatorState::<Test>::insert(&account, delegator);
70
5
}
71

            
72
// Helper function to verify an account has NOT been migrated
73
26
fn assert_not_migrated(account: AccountId, is_collator: bool) {
74
26
	if is_collator {
75
19
		assert!(!MigratedCandidates::<Test>::contains_key(&account));
76
	} else {
77
7
		assert!(!MigratedDelegators::<Test>::contains_key(&account));
78
	}
79
26
}
80

            
81
// Helper function to verify an account HAS been migrated
82
31
fn assert_migrated(account: AccountId, is_collator: bool) {
83
31
	if is_collator {
84
26
		assert!(MigratedCandidates::<Test>::contains_key(&account));
85
	} else {
86
5
		assert!(MigratedDelegators::<Test>::contains_key(&account));
87
	}
88
31
}
89

            
90
// Helper function to get the appropriate freeze reason
91
26
fn get_freeze_reason(is_collator: bool) -> crate::mock::RuntimeFreezeReason {
92
26
	if is_collator {
93
21
		FreezeReason::StakingCollator.into()
94
	} else {
95
5
		FreezeReason::StakingDelegator.into()
96
	}
97
26
}
98

            
99
// Helper function to verify freeze amount and ensure no corresponding lock exists
100
26
fn assert_freeze_amount_and_no_lock(account: AccountId, expected_amount: u128, is_collator: bool) {
101
26
	let freeze_reason = get_freeze_reason(is_collator);
102
26
	assert_eq!(
103
26
		query_freeze_amount(account, &freeze_reason),
104
26
		expected_amount
105
26
	);
106

            
107
	// Verify no corresponding lock remains
108
26
	let lock_id = if is_collator {
109
21
		COLLATOR_LOCK_ID
110
	} else {
111
5
		DELEGATOR_LOCK_ID
112
	};
113
26
	assert!(!Balances::locks(&account)
114
26
		.iter()
115
26
		.any(|lock| lock.id == lock_id));
116
26
}
117

            
118
#[test]
119
1
fn collator_bond_more_triggers_migration() {
120
1
	ExtBuilder::default()
121
1
		.with_balances(vec![(1, 1000)])
122
1
		.build()
123
1
		.execute_with(|| {
124
1
			let initial_bond = 500;
125
1

            
126
1
			// Setup collator with old-style lock
127
1
			setup_collator_with_lock(1, initial_bond);
128
1

            
129
1
			// Verify initial state - not migrated, has lock
130
1
			assert_not_migrated(1, true);
131
1

            
132
1
			// Call candidate_bond_more which should trigger migration via bond_more
133
1
			assert_ok!(ParachainStaking::candidate_bond_more(
134
1
				RuntimeOrigin::signed(1),
135
1
				100
136
1
			));
137

            
138
			// Should be migrated now
139
1
			assert_migrated(1, true);
140
1

            
141
1
			// Verify freeze amount is updated to new total and no lock remains
142
1
			assert_freeze_amount_and_no_lock(1, 600, true); // 500 + 100
143
1
		});
144
1
}
145

            
146
#[test]
147
1
fn delegator_operations_trigger_migration() {
148
1
	ExtBuilder::default()
149
1
		.with_balances(vec![(1, 1000), (2, 1000)])
150
1
		.with_candidates(vec![(1, 500)])
151
1
		.build()
152
1
		.execute_with(|| {
153
1
			// Setup a delegator with an old lock
154
1
			setup_delegator_with_lock(2, 1, 200);
155
1

            
156
1
			// The batch migration should work
157
1
			assert_ok!(ParachainStaking::migrate_locks_to_freezes_batch(
158
1
				RuntimeOrigin::signed(1),
159
1
				vec![(2, false)].try_into().unwrap(),
160
1
			));
161

            
162
			// Should be migrated
163
1
			assert_migrated(2, false);
164
1

            
165
1
			// Verify freeze amount and no lock remains
166
1
			assert_freeze_amount_and_no_lock(2, 200, false);
167
1
		});
168
1
}
169

            
170
#[test]
171
1
fn migrate_locks_to_freezes_batch_basic() {
172
1
	ExtBuilder::default()
173
1
		.with_balances(vec![(1, 1000), (2, 1000), (3, 1000)])
174
1
		.build()
175
1
		.execute_with(|| {
176
1
			// Setup multiple collators with old-style locks
177
1
			setup_collator_with_lock(1, 500);
178
1
			setup_collator_with_lock(2, 400);
179
1
			setup_collator_with_lock(3, 300);
180
1

            
181
1
			// Verify none are migrated initially
182
1
			assert_not_migrated(1, true);
183
1
			assert_not_migrated(2, true);
184
1
			assert_not_migrated(3, true);
185
1

            
186
1
			// Batch migrate
187
1
			assert_ok!(ParachainStaking::migrate_locks_to_freezes_batch(
188
1
				RuntimeOrigin::signed(1),
189
1
				vec![(1, true), (2, true), (3, true)].try_into().unwrap(),
190
1
			));
191

            
192
			// Verify all are migrated
193
1
			assert_migrated(1, true);
194
1
			assert_migrated(2, true);
195
1
			assert_migrated(3, true);
196
1

            
197
1
			// Verify freeze amounts and no locks remain
198
1
			assert_freeze_amount_and_no_lock(1, 500, true);
199
1
			assert_freeze_amount_and_no_lock(2, 400, true);
200
1
			assert_freeze_amount_and_no_lock(3, 300, true);
201
1
		});
202
1
}
203

            
204
#[test]
205
1
fn migrate_locks_to_freezes_batch_partial_already_migrated() {
206
1
	ExtBuilder::default()
207
1
		.with_balances(vec![(1, 1000), (2, 1000), (3, 1000)])
208
1
		.build()
209
1
		.execute_with(|| {
210
1
			// Setup collators with old-style locks
211
1
			setup_collator_with_lock(1, 500);
212
1
			setup_collator_with_lock(2, 400);
213
1
			setup_collator_with_lock(3, 300);
214
1

            
215
1
			// Migrate account 2 individually first via batch call
216
1
			assert_ok!(ParachainStaking::migrate_locks_to_freezes_batch(
217
1
				RuntimeOrigin::signed(1),
218
1
				vec![(2, true)].try_into().unwrap(),
219
1
			));
220
1
			assert_migrated(2, true);
221
1

            
222
1
			// Now batch migrate all three (including already migrated account 2)
223
1
			assert_ok!(ParachainStaking::migrate_locks_to_freezes_batch(
224
1
				RuntimeOrigin::signed(1),
225
1
				vec![(1, true), (2, true), (3, true)].try_into().unwrap(),
226
1
			));
227

            
228
			// All should be migrated
229
1
			assert_migrated(1, true);
230
1
			assert_migrated(2, true);
231
1
			assert_migrated(3, true);
232
1

            
233
1
			// Verify freeze amounts are correct and no locks remain
234
1
			assert_freeze_amount_and_no_lock(1, 500, true);
235
1
			assert_freeze_amount_and_no_lock(2, 400, true);
236
1
			assert_freeze_amount_and_no_lock(3, 300, true);
237
1
		});
238
1
}
239

            
240
#[test]
241
1
fn execute_leave_candidates_removes_lock() {
242
1
	ExtBuilder::default()
243
1
		.with_balances(vec![(1, 1000)])
244
1
		.build()
245
1
		.execute_with(|| {
246
1
			let bond_amount = 500;
247
1

            
248
1
			// Setup collator with old-style lock
249
1
			setup_collator_with_lock(1, bond_amount);
250
1
			assert_not_migrated(1, true);
251
1

            
252
1
			// Add required empty delegations for execute_leave_candidates
253
1
			let empty_delegations: crate::types::Delegations<AccountId, u128> = Default::default();
254
1
			crate::TopDelegations::<Test>::insert(&1, empty_delegations.clone());
255
1
			crate::BottomDelegations::<Test>::insert(&1, empty_delegations);
256
1

            
257
1
			// Schedule leave first
258
1
			assert_ok!(ParachainStaking::schedule_leave_candidates(
259
1
				RuntimeOrigin::signed(1),
260
1
				1 // candidate_count
261
1
			));
262

            
263
			// Fast forward to when we can execute
264
1
			crate::mock::roll_to(10);
265
1

            
266
1
			// Before executing, verify we have the old lock
267
1
			assert!(Balances::locks(&1)
268
1
				.iter()
269
1
				.any(|lock| lock.id == COLLATOR_LOCK_ID));
270

            
271
			// Execute leave should remove both lock and freeze via thaw_extended
272
1
			assert_ok!(ParachainStaking::execute_leave_candidates(
273
1
				RuntimeOrigin::signed(1),
274
1
				1, // candidate account
275
1
				0  // delegation_count
276
1
			));
277

            
278
			// After leaving, both lock and freeze should be removed
279
1
			assert_freeze_amount_and_no_lock(1, 0, true);
280
1

            
281
1
			// The account is now completely unstaked
282
1
			assert!(!CandidateInfo::<Test>::contains_key(&1));
283
1
		});
284
1
}
285

            
286
#[test]
287
1
fn get_collator_stakable_free_balance_triggers_migration() {
288
1
	ExtBuilder::default()
289
1
		.with_balances(vec![(1, 1000)])
290
1
		.build()
291
1
		.execute_with(|| {
292
1
			let bond_amount = 500;
293
1

            
294
1
			// Setup collator with old-style lock
295
1
			setup_collator_with_lock(1, bond_amount);
296
1
			assert_not_migrated(1, true);
297
1

            
298
1
			// Query stakable balance should trigger migration
299
1
			let stakable = ParachainStaking::get_collator_stakable_free_balance(&1);
300
1

            
301
1
			// Should be migrated now
302
1
			assert_migrated(1, true);
303
1

            
304
1
			// Should return correct stakable amount (total - frozen)
305
1
			assert_eq!(stakable, 500); // 1000 - 500
306

            
307
			// Verify the freeze was set and no lock remains
308
1
			assert_freeze_amount_and_no_lock(1, bond_amount, true);
309
1
		});
310
1
}
311

            
312
#[test]
313
1
fn schedule_candidate_bond_less_does_not_trigger_migration() {
314
1
	ExtBuilder::default()
315
1
		.with_balances(vec![(1, 1000)])
316
1
		.build()
317
1
		.execute_with(|| {
318
1
			let bond_amount = 500;
319
1

            
320
1
			// Setup collator with old-style lock
321
1
			setup_collator_with_lock(1, bond_amount);
322
1

            
323
1
			// Add required empty delegations
324
1
			let empty_delegations: crate::types::Delegations<AccountId, u128> = Default::default();
325
1
			crate::TopDelegations::<Test>::insert(&1, empty_delegations.clone());
326
1
			crate::BottomDelegations::<Test>::insert(&1, empty_delegations);
327
1

            
328
1
			assert_not_migrated(1, true);
329
1

            
330
1
			// Schedule bond less should work with unmigrated account
331
1
			assert_ok!(ParachainStaking::schedule_candidate_bond_less(
332
1
				RuntimeOrigin::signed(1),
333
1
				100
334
1
			));
335

            
336
			// Should NOT be migrated after just scheduling
337
1
			assert_not_migrated(1, true);
338
1

            
339
1
			// Fast forward to execute delay - need to wait 2 rounds
340
1
			// Use the round helper to properly advance rounds
341
1
			crate::mock::roll_to_round_begin(3);
342
1

            
343
1
			// Execute should trigger migration
344
1
			assert_ok!(ParachainStaking::execute_candidate_bond_less(
345
1
				RuntimeOrigin::signed(1),
346
1
				1
347
1
			));
348

            
349
			// Now should be migrated
350
1
			assert_migrated(1, true);
351
1

            
352
1
			// Freeze should be reduced after execution and no lock remains
353
1
			assert_freeze_amount_and_no_lock(1, bond_amount - 100, true);
354
1
		});
355
1
}
356

            
357
#[test]
358
1
fn mixed_migrated_and_unmigrated_accounts() {
359
1
	ExtBuilder::default()
360
1
		.with_balances(vec![(1, 1000), (2, 1000), (3, 1000)])
361
1
		.build()
362
1
		.execute_with(|| {
363
1
			// Setup two collators with old locks
364
1
			setup_collator_with_lock(1, 500);
365
1
			setup_collator_with_lock(2, 400);
366
1

            
367
1
			// Migrate only account 1
368
1
			assert_ok!(ParachainStaking::migrate_locks_to_freezes_batch(
369
1
				RuntimeOrigin::signed(1),
370
1
				vec![(1, true)].try_into().unwrap(),
371
1
			));
372

            
373
			// Account 1 should be migrated, 2 should not
374
1
			assert_migrated(1, true);
375
1
			assert_not_migrated(2, true);
376
1

            
377
1
			// Both should have correct balances
378
1
			assert_freeze_amount_and_no_lock(1, 500, true);
379
1

            
380
1
			// Account 2 interacting should trigger its own migration
381
1
			assert_ok!(ParachainStaking::candidate_bond_more(
382
1
				RuntimeOrigin::signed(2),
383
1
				50
384
1
			));
385

            
386
			// Now both should be migrated
387
1
			assert_migrated(2, true);
388
1
			assert_freeze_amount_and_no_lock(2, 450, true); // 400 + 50
389
1
		});
390
1
}
391

            
392
#[test]
393
1
fn zero_balance_migration() {
394
1
	ExtBuilder::default()
395
1
		.with_balances(vec![(1, 1000)])
396
1
		.build()
397
1
		.execute_with(|| {
398
1
			// Create a candidate with zero bond (edge case)
399
1
			let candidate = crate::types::CandidateMetadata::new(0);
400
1
			CandidateInfo::<Test>::insert(&1, candidate);
401
1

            
402
1
			// No lock needed for zero amount
403
1

            
404
1
			// Batch migrate
405
1
			assert_ok!(ParachainStaking::migrate_locks_to_freezes_batch(
406
1
				RuntimeOrigin::signed(1),
407
1
				vec![(1, true)].try_into().unwrap(),
408
1
			));
409

            
410
			// Should be marked as migrated
411
1
			assert_migrated(1, true);
412
1

            
413
1
			// Should have no freeze and no lock
414
1
			assert_freeze_amount_and_no_lock(1, 0, true);
415
1
		});
416
1
}
417

            
418
#[test]
419
1
fn migration_preserves_candidate_state() {
420
1
	ExtBuilder::default()
421
1
		.with_balances(vec![(1, 1000)])
422
1
		.build()
423
1
		.execute_with(|| {
424
1
			let bond_amount = 500;
425
1

            
426
1
			// Setup collator with specific state
427
1
			setup_collator_with_lock(1, bond_amount);
428
1

            
429
1
			// Add some metadata to ensure it's preserved
430
1
			let mut candidate = CandidateInfo::<Test>::get(&1).unwrap();
431
1
			candidate.status = crate::CollatorStatus::Leaving(5);
432
1
			CandidateInfo::<Test>::insert(&1, candidate);
433
1

            
434
1
			// Migrate
435
1
			assert_ok!(ParachainStaking::migrate_locks_to_freezes_batch(
436
1
				RuntimeOrigin::signed(1),
437
1
				vec![(1, true)].try_into().unwrap(),
438
1
			));
439

            
440
			// Verify candidate state is preserved
441
1
			let migrated_candidate = CandidateInfo::<Test>::get(&1).unwrap();
442
1
			assert_eq!(migrated_candidate.status, crate::CollatorStatus::Leaving(5));
443
1
			assert_eq!(migrated_candidate.bond, bond_amount);
444

            
445
			// Verify freeze was set correctly and no lock remains
446
1
			assert_freeze_amount_and_no_lock(1, bond_amount, true);
447
1
		});
448
1
}
449

            
450
#[test]
451
1
fn migrate_locks_to_freezes_batch_mixed_collators_and_delegators() {
452
1
	ExtBuilder::default()
453
1
		.with_balances(vec![(1, 1000), (2, 1000), (3, 1000), (4, 1000)])
454
1
		.build()
455
1
		.execute_with(|| {
456
1
			// Setup mixed accounts: 2 collators and 2 delegators
457
1
			setup_collator_with_lock(1, 500);
458
1
			setup_collator_with_lock(2, 400);
459
1
			setup_delegator_with_lock(3, 1, 300);
460
1
			setup_delegator_with_lock(4, 2, 200);
461
1

            
462
1
			// Verify none are migrated initially
463
1
			assert_not_migrated(1, true);
464
1
			assert_not_migrated(2, true);
465
1
			assert_not_migrated(3, false);
466
1
			assert_not_migrated(4, false);
467
1

            
468
1
			// Batch migrate mixed accounts
469
1
			assert_ok!(ParachainStaking::migrate_locks_to_freezes_batch(
470
1
				RuntimeOrigin::signed(1),
471
1
				vec![(1, true), (2, true), (3, false), (4, false),]
472
1
					.try_into()
473
1
					.unwrap(),
474
1
			));
475

            
476
			// Verify all are migrated
477
1
			assert_migrated(1, true);
478
1
			assert_migrated(2, true);
479
1
			assert_migrated(3, false);
480
1
			assert_migrated(4, false);
481
1

            
482
1
			// Verify freeze amounts and no locks remain
483
1
			assert_freeze_amount_and_no_lock(1, 500, true);
484
1
			assert_freeze_amount_and_no_lock(2, 400, true);
485
1
			assert_freeze_amount_and_no_lock(3, 300, false);
486
1
			assert_freeze_amount_and_no_lock(4, 200, false);
487
1
		});
488
1
}
489

            
490
#[test]
491
1
fn migrate_batch_fee_refund_when_100_percent_succeed() {
492
1
	ExtBuilder::default()
493
1
		.with_balances(vec![(1, 1000), (2, 1000), (3, 1000), (4, 1000)])
494
1
		.build()
495
1
		.execute_with(|| {
496
1
			// Setup 4 accounts with old-style locks - 2 collators, 2 delegators
497
1
			setup_collator_with_lock(1, 500);
498
1
			setup_collator_with_lock(2, 400);
499
1
			setup_delegator_with_lock(3, 1, 300);
500
1
			setup_delegator_with_lock(4, 2, 200);
501
1

            
502
1
			// All accounts should be unmigrated initially
503
1
			assert_not_migrated(1, true);
504
1
			assert_not_migrated(2, true);
505
1
			assert_not_migrated(3, false);
506
1
			assert_not_migrated(4, false);
507
1

            
508
1
			// Migrate all accounts - 100% success rate should trigger refund
509
1
			let result = ParachainStaking::migrate_locks_to_freezes_batch(
510
1
				RuntimeOrigin::signed(1),
511
1
				vec![(1, true), (2, true), (3, false), (4, false)]
512
1
					.try_into()
513
1
					.unwrap(),
514
1
			);
515
1

            
516
1
			// Should succeed with fee refund
517
1
			assert_ok!(result);
518
1
			let post_info = result.unwrap();
519
1
			assert_eq!(post_info.pays_fee, frame_support::dispatch::Pays::No);
520

            
521
			// Verify all accounts were migrated
522
1
			assert_migrated(1, true);
523
1
			assert_migrated(2, true);
524
1
			assert_migrated(3, false);
525
1
			assert_migrated(4, false);
526
1

            
527
1
			// Verify freeze amounts
528
1
			assert_freeze_amount_and_no_lock(1, 500, true);
529
1
			assert_freeze_amount_and_no_lock(2, 400, true);
530
1
			assert_freeze_amount_and_no_lock(3, 300, false);
531
1
			assert_freeze_amount_and_no_lock(4, 200, false);
532
1
		});
533
1
}
534

            
535
#[test]
536
1
fn migrate_batch_fee_refund_when_exactly_50_percent_succeed() {
537
1
	ExtBuilder::default()
538
1
		.with_balances(vec![(1, 1000), (2, 1000), (3, 1000), (4, 1000)])
539
1
		.build()
540
1
		.execute_with(|| {
541
1
			// Setup 4 accounts - only 2 will actually migrate
542
1
			setup_collator_with_lock(1, 500);
543
1
			setup_collator_with_lock(2, 400);
544
1
			// Account 3 and 4 will not have valid staking state, so won't migrate
545
1

            
546
1
			// Verify initial state
547
1
			assert_not_migrated(1, true);
548
1
			assert_not_migrated(2, true);
549
1

            
550
1
			// Try to migrate 4 accounts, but only 2 will succeed (50% success rate)
551
1
			let result = ParachainStaking::migrate_locks_to_freezes_batch(
552
1
				RuntimeOrigin::signed(1),
553
1
				vec![(1, true), (2, true), (3, true), (4, false)]
554
1
					.try_into()
555
1
					.unwrap(),
556
1
			);
557
1

            
558
1
			// Should succeed with fee refund (exactly 50% = refund)
559
1
			assert_ok!(result);
560
1
			let post_info = result.unwrap();
561
1
			assert_eq!(post_info.pays_fee, frame_support::dispatch::Pays::No);
562

            
563
			// Verify only accounts with valid state were migrated
564
1
			assert_migrated(1, true);
565
1
			assert_migrated(2, true);
566
1
			assert_not_migrated(3, true);
567
1
			assert_not_migrated(4, false);
568
1

            
569
1
			// Verify freeze amounts for successful migrations
570
1
			assert_freeze_amount_and_no_lock(1, 500, true);
571
1
			assert_freeze_amount_and_no_lock(2, 400, true);
572
1
		});
573
1
}
574

            
575
#[test]
576
1
fn migrate_batch_no_fee_refund_when_less_than_50_percent_succeed() {
577
1
	ExtBuilder::default()
578
1
		.with_balances(vec![(1, 1000), (2, 1000), (3, 1000), (4, 1000)])
579
1
		.build()
580
1
		.execute_with(|| {
581
1
			// Setup only 1 account with valid staking state out of 4
582
1
			setup_collator_with_lock(1, 500);
583
1
			// Accounts 2, 3, 4 have no valid staking state
584
1

            
585
1
			// Verify initial state
586
1
			assert_not_migrated(1, true);
587
1

            
588
1
			// Try to migrate 4 accounts, but only 1 will succeed (25% success rate)
589
1
			let result = ParachainStaking::migrate_locks_to_freezes_batch(
590
1
				RuntimeOrigin::signed(1),
591
1
				vec![(1, true), (2, true), (3, false), (4, false)]
592
1
					.try_into()
593
1
					.unwrap(),
594
1
			);
595
1

            
596
1
			// Should succeed but charge normal fee (< 50% success)
597
1
			assert_ok!(result);
598
1
			let post_info = result.unwrap();
599
1
			assert_eq!(post_info.pays_fee, frame_support::dispatch::Pays::Yes);
600

            
601
			// Verify only the valid account was migrated
602
1
			assert_migrated(1, true);
603
1
			assert_not_migrated(2, true);
604
1
			assert_not_migrated(3, false);
605
1
			assert_not_migrated(4, false);
606
1

            
607
1
			// Verify freeze amount for successful migration
608
1
			assert_freeze_amount_and_no_lock(1, 500, true);
609
1
		});
610
1
}
611

            
612
#[test]
613
1
fn migrate_batch_no_fee_refund_when_all_already_migrated() {
614
1
	ExtBuilder::default()
615
1
		.with_balances(vec![(1, 1000), (2, 1000)])
616
1
		.build()
617
1
		.execute_with(|| {
618
1
			// Setup accounts and migrate them first
619
1
			setup_collator_with_lock(1, 500);
620
1
			setup_collator_with_lock(2, 400);
621
1

            
622
1
			// Pre-migrate both accounts
623
1
			assert_ok!(ParachainStaking::migrate_locks_to_freezes_batch(
624
1
				RuntimeOrigin::signed(1),
625
1
				vec![(1, true), (2, true)].try_into().unwrap(),
626
1
			));
627

            
628
			// Verify they are migrated
629
1
			assert_migrated(1, true);
630
1
			assert_migrated(2, true);
631
1

            
632
1
			// Try to migrate them again - 0% new migrations
633
1
			let result = ParachainStaking::migrate_locks_to_freezes_batch(
634
1
				RuntimeOrigin::signed(1),
635
1
				vec![(1, true), (2, true)].try_into().unwrap(),
636
1
			);
637
1

            
638
1
			// Should succeed but charge normal fee (0% new migrations)
639
1
			assert_ok!(result);
640
1
			let post_info = result.unwrap();
641
1
			assert_eq!(post_info.pays_fee, frame_support::dispatch::Pays::Yes);
642

            
643
			// Accounts should still be migrated
644
1
			assert_migrated(1, true);
645
1
			assert_migrated(2, true);
646
1
		});
647
1
}
648

            
649
#[test]
650
1
fn migrate_batch_empty_batch_returns_error() {
651
1
	ExtBuilder::default()
652
1
		.with_balances(vec![(1, 1000)])
653
1
		.build()
654
1
		.execute_with(|| {
655
1
			// Try to migrate empty batch
656
1
			let result = ParachainStaking::migrate_locks_to_freezes_batch(
657
1
				RuntimeOrigin::signed(1),
658
1
				vec![].try_into().unwrap(),
659
1
			);
660
1

            
661
1
			// Should fail with EmptyMigrationBatch error
662
1
			assert_eq!(
663
1
				result,
664
1
				Err(crate::Error::<crate::mock::Test>::EmptyMigrationBatch.into())
665
1
			);
666
1
		});
667
1
}
668

            
669
#[test]
670
1
fn migrate_batch_mixed_success_rates_test_boundaries() {
671
1
	ExtBuilder::default()
672
1
		.with_balances(vec![(1, 1000), (2, 1000), (3, 1000)])
673
1
		.build()
674
1
		.execute_with(|| {
675
1
			// Test with 3 accounts - need 2 to succeed for >= 50%
676
1
			setup_collator_with_lock(1, 500);
677
1
			setup_collator_with_lock(2, 400);
678
1
			// Account 3 has no valid staking state
679
1

            
680
1
			// Try to migrate 3 accounts, 2 will succeed (66.6% success rate)
681
1
			let result = ParachainStaking::migrate_locks_to_freezes_batch(
682
1
				RuntimeOrigin::signed(1),
683
1
				vec![(1, true), (2, true), (3, true)].try_into().unwrap(),
684
1
			);
685
1

            
686
1
			// Should succeed with fee refund (> 50% success)
687
1
			assert_ok!(result);
688
1
			let post_info = result.unwrap();
689
1
			assert_eq!(post_info.pays_fee, frame_support::dispatch::Pays::No); // Complete refund
690

            
691
			// Verify successful migrations
692
1
			assert_migrated(1, true);
693
1
			assert_migrated(2, true);
694
1
			assert_not_migrated(3, true); // No valid candidate state
695
1
		});
696
1
}