Skip to content

Commit e311a94

Browse files
RomarQkianenigma
authored andcommitted
Use total balance (free + reserved) when performing liquidity checks for a new reserve (#8108)
# Description Solves: #8099 Based on the documentation and existing code, the usable balance is computed with the following formula: ```rs // If Fortitude == Polite let usable_balance = free - max(frozen - reserved, existential balance) ``` ### The problem: If an account's `free balance` is lower than `frozen balance`, no reserves will be allowed even though the `usable balance` is enough to cover the reserve, resulting in a `LiquidityRestrictions` error, which should not happen. ### Visual example of how `usable/spendable` balance works: ```bash |__total__________________________________| |__on_hold__|_____________free____________| |__________frozen___________| |__on_hold__|__ed__| |__untouchable__|__spendable__| ``` ## Integration No action is required, the changes only change existing code, it does not add or change any API. ## Review Notes From my understanding, the function `ensure_can_withdraw` is incorrect, and instead of checking that the new `free` balance is higher or equal to the `frozen` balance, it should make sure the `new free` balance is higher or equal to the `usable` balance. --------- Co-authored-by: Kian Paimani <[email protected]> (cherry picked from commit 7c2642d)
1 parent 2158a11 commit e311a94

File tree

5 files changed

+516
-8
lines changed

5 files changed

+516
-8
lines changed

prdoc/pr_8108.prdoc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
title: "Fix `reserve` and `can_reserve` methods in `pallet-balances` to properly handle reserved balances"
2+
3+
doc:
4+
- audience: Runtime Dev
5+
description: |-
6+
Fixes a bug in `pallet-balances` where the `reserve` and `can_reserve` methods were incorrectly
7+
preventing new reserves when an account's free balance was lower than its frozen balance,
8+
even if the usable balance was sufficient after accounting for the reserved balance.
9+
- audience: Runtime User
10+
description: |-
11+
Fixes a bug that was causing unexpected `LiquidityRestrictions` errors when attempting to
12+
reserve funds.
13+
14+
crates:
15+
- name: pallet-balances
16+
bump: patch

substrate/frame/balances/src/impl_currency.rs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,29 @@ where
535535
}
536536
}
537537

538+
/// Validates whether an account can create a reserve without violating
539+
/// liquidity constraints.
540+
///
541+
/// This method performs liquidity checks without modifying the account state.
542+
fn ensure_can_reserve<T: Config<I>, I: 'static>(
543+
who: &T::AccountId,
544+
value: T::Balance,
545+
check_existential_deposit: bool,
546+
) -> DispatchResult {
547+
let AccountData { free, .. } = Pallet::<T, I>::account(who);
548+
549+
// Early validation: Check sufficient free balance
550+
let new_free_balance = free.checked_sub(&value).ok_or(Error::<T, I>::InsufficientBalance)?;
551+
552+
// Conditionally validate existential deposit preservation
553+
if check_existential_deposit {
554+
let existential_deposit = T::ExistentialDeposit::get();
555+
ensure!(new_free_balance >= existential_deposit, Error::<T, I>::Expendability);
556+
}
557+
558+
Ok(())
559+
}
560+
538561
impl<T: Config<I>, I: 'static> ReservableCurrency<T::AccountId> for Pallet<T, I>
539562
where
540563
T::Balance: MaybeSerializeDeserialize + Debug,
@@ -546,11 +569,7 @@ where
546569
if value.is_zero() {
547570
return true
548571
}
549-
Self::account(who).free.checked_sub(&value).map_or(false, |new_balance| {
550-
new_balance >= T::ExistentialDeposit::get() &&
551-
Self::ensure_can_withdraw(who, value, WithdrawReasons::RESERVE, new_balance)
552-
.is_ok()
553-
})
572+
ensure_can_reserve::<T, I>(who, value, true).is_ok()
554573
}
555574

556575
fn reserved_balance(who: &T::AccountId) -> Self::Balance {
@@ -570,7 +589,9 @@ where
570589
account.free.checked_sub(&value).ok_or(Error::<T, I>::InsufficientBalance)?;
571590
account.reserved =
572591
account.reserved.checked_add(&value).ok_or(ArithmeticError::Overflow)?;
573-
Self::ensure_can_withdraw(&who, value, WithdrawReasons::RESERVE, account.free)
592+
593+
// Check if it is possible to reserve before trying to mutate the account
594+
ensure_can_reserve::<T, I>(who, value, false)
574595
})?;
575596

576597
Self::deposit_event(Event::Reserved { who: who.clone(), amount: value });

substrate/frame/balances/src/tests/currency_tests.rs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,9 @@ fn lock_should_work_reserve() {
258258
<Balances as Currency<_>>::transfer(&1, &2, 1, AllowDeath),
259259
TokenError::Frozen
260260
);
261-
assert_noop!(Balances::reserve(&1, 1), Error::<Test>::LiquidityRestrictions,);
261+
// We can use frozen balance for reserves
262+
assert_ok!(Balances::reserve(&1, 9));
263+
assert_noop!(Balances::reserve(&1, 1), DispatchError::ConsumerRemaining,);
262264
assert!(ChargeTransactionPayment::<Test>::validate_and_prepare(
263265
ChargeTransactionPayment::from(1),
264266
Some(1).into(),
@@ -280,6 +282,47 @@ fn lock_should_work_reserve() {
280282
});
281283
}
282284

285+
#[test]
286+
fn reserve_should_work_for_frozen_balance() {
287+
ExtBuilder::default()
288+
.existential_deposit(1)
289+
.monied(true)
290+
.build_and_execute_with(|| {
291+
// Check balances
292+
let account = Balances::account(&1);
293+
assert_eq!(account.free, 10);
294+
assert_eq!(account.frozen, 0);
295+
assert_eq!(account.reserved, 0);
296+
297+
Balances::set_lock(ID_1, &1, 9, WithdrawReasons::RESERVE);
298+
299+
let account = Balances::account(&1);
300+
assert_eq!(account.free, 10);
301+
assert_eq!(account.frozen, 9);
302+
assert_eq!(account.reserved, 0);
303+
304+
assert_ok!(Balances::reserve(&1, 5));
305+
306+
let account = Balances::account(&1);
307+
assert_eq!(account.free, 5);
308+
assert_eq!(account.frozen, 9);
309+
assert_eq!(account.reserved, 5);
310+
311+
assert_ok!(Balances::reserve(&1, 4));
312+
313+
let account = Balances::account(&1);
314+
assert_eq!(account.free, 1);
315+
assert_eq!(account.frozen, 9);
316+
assert_eq!(account.reserved, 9);
317+
318+
// Check usable balance
319+
// usable_balance = free - max(frozen - reserved, ExistentialDeposit)
320+
// 0 = 1 - max(9 - 9, 1)
321+
let usable_balance = Balances::usable_balance(&1);
322+
assert_eq!(usable_balance, 0);
323+
});
324+
}
325+
283326
#[test]
284327
fn lock_should_work_tx_fee() {
285328
ExtBuilder::default()
@@ -291,7 +334,9 @@ fn lock_should_work_tx_fee() {
291334
<Balances as Currency<_>>::transfer(&1, &2, 1, AllowDeath),
292335
TokenError::Frozen
293336
);
294-
assert_noop!(Balances::reserve(&1, 1), Error::<Test>::LiquidityRestrictions,);
337+
// We can use frozen balance for reserves
338+
assert_ok!(Balances::reserve(&1, 9));
339+
assert_noop!(Balances::reserve(&1, 1), DispatchError::ConsumerRemaining,);
295340
assert!(ChargeTransactionPayment::<Test>::validate_and_prepare(
296341
ChargeTransactionPayment::from(1),
297342
Some(1).into(),
@@ -1273,8 +1318,14 @@ fn reserve_must_succeed_if_can_reserve_does() {
12731318
ExtBuilder::default().build_and_execute_with(|| {
12741319
let _ = Balances::deposit_creating(&1, 1);
12751320
let _ = Balances::deposit_creating(&2, 2);
1321+
let _ = Balances::deposit_creating(&3, 3);
12761322
assert!(Balances::can_reserve(&1, 1) == Balances::reserve(&1, 1).is_ok());
12771323
assert!(Balances::can_reserve(&2, 1) == Balances::reserve(&2, 1).is_ok());
1324+
1325+
// Ensure that we can reserve as long (free + reserved) remains above
1326+
// the maximum of frozen balance.
1327+
Balances::set_lock(ID_1, &3, 2, WithdrawReasons::RESERVE);
1328+
assert_eq!(Balances::can_reserve(&3, 2), Balances::reserve(&3, 2).is_ok());
12781329
});
12791330
}
12801331

0 commit comments

Comments
 (0)