Inconsistent Transferable Balance Calculation Logic
ahmadkaouk opened this issue ยท 18 comments
An inconsistency has been identified in the Polkadot SDK concerning the logic used for computing transferable balances. This divergence may mislead users about the actual spendable balance, potentially leading to transaction failures due to inadequate fee coverage.
The inconsistency arises from the utilization of different formulas:
- Old Formula:
transferable = free - max(frozen, reserved)
. - New Formula:
transferable = free - (frozen - reserved)
.
This originates from the CurrencyAdapter
, which relies on the outdated Currency
trait as illustrated here. The new transferable balance is computed utilizing the reducible_balances
function defined here.
The discrepancy is particularly noticeable in the eth.getBalance
method in Frontier, where the new formula is employed. Under certain circumstances, a positive balance may be displayed, indicating available funds. However, transactions may fail due to an insufficient balance to cover the fees, given that the old formula is utilized for fee calculations.
- Is the discrepancy in the logic for calculating "transferable balance" concerning fees a deliberate design or an oversight?
- Is there a rationale behind not extending the new logic to fee calculations, and if so, what is the reasoning behind this approach?
IN any case, an item missing (and rather high priority) in #226 is then migrate CurrencyAdapter
to also work with fungible
, I added it there.
@ahmadkaouk Just to point that if the account cannot be reaped (which should be the case when there's a hold and/or freeze) or we don't want it to be reaped, the transferable balance calculation is this:
transferable = free - max(frozen - reserved, ED)
since now the ED is a requirement over the free balance, not the total balance.
Spendable balance indeed seems like it should be transferable = free - max(frozen, reserved)
, not transferable = free - (frozen - reserved)
(@bkchr can you confirm?).
I will work on this.
@ahmadkaouk I have a couple of questions:
- Could you please clarify why you determined this regression to have originated in the
CurrencyAdaptor
?
After initial review, it seems was originated by the change in behavior of fn reducible_balance
introduced in paritytech/substrate#12951.
Prior implementation:
polkadot-sdk/substrate/frame/balances/src/lib.rs
Lines 1070 to 1083 in c699876
New implementation:
polkadot-sdk/substrate/frame/balances/src/impl_fungible.rs
Lines 44 to 70 in 495d24d
- Where did you get this formula?
Old Formula: transferable = free - max(frozen, reserved).
It seems only frozen and free are used in the prior implementation, I don't see usage of reserved anywhere.
Thanks.
#12951 did alter the functionality of existing reserves by design:
Alter reserve functionality in Balances so that it does not contribute to the account's existential deposit and does use a consumer reference. Remove the concept of withdraw reasons from locks. Migration is lazy: accounts about to be mutated will be migrated prior. New accounts will automatically be mutated. A permissionless upgrade dispatchable is provided to upgrade dormant accounts.
That Frontier is now showing some inconsistencies in its getBalance
(which presumably reports transferability rather than the real balance) indicates these repercussions have not yet been integrated. Introducing a test would probably help ensure this doesn't happen again.
@gavofyork I have a clarifying question about the Balances
implementation of Inspect::reducible_balance
.
Here's the doc comment describing the behavior of the method:
Here is the Balances
implementation:
polkadot-sdk/substrate/frame/balances/src/impl_fungible.rs
Lines 44 to 70 in 495d24d
Is line 54 (untouchable = a.frozen.saturating_sub(a.reserved);
) correct, or is it supposed to be untouchable = a.frozen.max(a.reserved);
?
Otherwise, even when Inspect::reducible_balance
is called with Polite
it seems it can return a value greater than a.reserved
and a.frozen
, which doesn't seem right given the behavior described in the doc comment.
They're both correct.
If Polite
, then we are unable to reduce the total balance to below the frozen balance. We work out what is "untouchable" from the free balance so that we understand how much is left which can be reduced. Since we can never touch the balance on hold anyway and since the balance on hold contributes to the total, then we can discount what is untouchable by this. This works because total == free + on_hold
.
Perhaps the confusion stems from the fact that we consider frozen in relation to the total, but end up calculating reducible by subtracting what is untouchable from what is free.
Ah, thank you for clarifying.
Visual aid:
|_______total___________________|
|_______on_hold____|____free____|
|_______frozen___________|
xxxxx
^
untouchable = frozen - on_hold
@ahmadkaouk could you please confirm if updating the CurrencyAdaptor
to use fungible
(therefore the new formula) instead Currency
should resolve this issue with Frontier?
Is there any test case I can use to reproduce this?
Visual aid:
Yes, and you can add the ED into that, too:
|__total__________________________________|
|__on_hold__|_____________free____________|
|__________frozen___________|
|__on_hold__|__ed__|
|__untouchable__|__spendable__|
untouchable = max(ed, frozen - on_hold)
so without anything frozen:
|__total__________________________________|
|__on_hold__|_____________free____________|
|__on_hold__|__ed__|______spendable_______|
|......| <- untouchable
(or if we don't care about ED because we have other providers or will reap and don't have any consumers, then it's just untouchable = frozen - on_hold
, as with your diagram above.)
@ahmadkaouk could you please confirm if updating the
CurrencyAdaptor
to usefungible
(therefore the new formula) insteadCurrency
should resolve this issue with Frontier?Is there any test case I can use to reproduce this?
@liamaharon, yes I believe that updating CurrencyAdapter
to use fungible
can solve the problem. I have created a test on the Moonbeam network that reproduces the issue here.
Here's a summary of the scenario that triggers the problem:
- A new account is created with an initial balance of 30 GLMR.
- The new account submits a proposal with a deposit of
minDepositAmount
(4 GLMR). (Total of 9 GLMR are reserved) - The new account delegates 20 GLMR.
- Then the account transfers 5 GLMR to another account.
- We verify that the account has a transferable balance of approximately 5 GLMR.
- A second transfer of 2 GLMR is made from the new account.
- The transfer fails with this error message "Invalid Transaction: Inability to pay some fees , e.g. account balance too low"
Balance info for the new account at the end:
data: {
free: 15,996,484,162,237,998,502
reserved: 9,002,400,000,000,000,000
frozen: 20,000,000,000,000,000,000
flags: 170,141,183,460,469,231,731,687,303,715,884,105,728
}
Note that ED is 0 in this scenario.
Hey @ahmadkaouk can try the FungibleAdapter
from #2292?
We kind of need a unit test here in the SDK to prevent this from happening again and checking that #2292 does solve the issue.
Ah, thank you for clarifying.
Visual aid:
|_______total___________________| |_______on_hold____|____free____| |_______frozen___________| xxxxx ^ untouchable = frozen - on_hold
What helped me understand this convo and digram(s) better is to specify that untouchable
is the portion of free
that is untouchable
, not total
. In other words I would call it free_untouchable
.
Hey @liamaharon, just wanted to let you know that I tested the FungibleAdapter
from #2292 and it does fix the issue.
Hey @liamaharon, just wanted to let you know that I tested the
FungibleAdapter
from #2292 and it does fix the issue.
@ahmadkaouk fyi this is finally merged and will be included in the next release.
As the fix is merged, we can close this?