High-fraction liquidations can cause the product P to become 0
Description
During liquidations, each depositor is rewarded with the collateral and some amount of DebtToken
s are burned from their deposits. As it would be impossible to update each user's balance during liquidation, the protocol uses a global running product P and sum S to derive the compounded DebtToken
deposit and corresponding collateral gain as a function of the initial deposit. Refer to the Appendix ref↗ for a list of terms and the derivation of P.
Continuous high-fraction liquidations can cause the value of the global-running product P to become 0, leading to potential disruptions in withdrawals and reward claims from the stability pool.
The function _updateRewardSumAndProduct
is responsible for updating the value of P when it falls below 1e9 by multiplying it by 1e9. However, certain liquidation scenarios can update P in a way that multiplying it by 1e9 is insufficient to bring its value above 1e9.
Refer to the Appendix ref↗ for the exploit of the vulnerability. Following is the output of the exploit:
Running 1 test for test/Exploit.t.sol:Exploit
[PASS] testPto0Exploit() (gas: 2053310)
Logs:
Value of P after first liquidation 1000000000
Value of P after second liquidation 2
Value of P after third liquidation 0
Alice's deposits in stability pool before the withdrawal 9999999999999999980000
Alice's balance of debttoken before the withdrawal 70000000000000000040000
Alice's deposits in stability pool after the withdrawal 0
Alice's balance of debttoken after the withdrawal 70000000000000000040000
Alice's deposits are now erased from the pool without being returned
First thing to note is that if newProductFactor
is 1, then multiplying by 1e9 is not enough to bring P back to the correct scale.
The value of newProductFactor
can be set to 1 by making _debtLossPerUnitStaked
equal to 1e18 - 1
. This requires calculating the _debtToOffset
value to pass to the offset function such that _debtLossPerUnitStaked
is 1e18 - 1
. The calculations for this are as follows:
_debtLossPerUnitStaked = ( _debtToOffset * 1e18 / _totalDebtTokenDeposits ) + 1
1e18 - 1 = ( _debtToOffset * 1e18 / _totalDebtTokenDeposits ) + 1
// (We need _debtLossPerUnitStaked to be 1e18 - 1)
1e18 - 2 = ( _debtToOffset * 1e18 / _totalDebtTokenDeposits )
Fixing _totalDebtTokenDeposits to 10000 * 1e18
_debtToOffset = 10000e18 * (1e18 - 2) / 1e18
_debtToOffset = 9999999999999999980000
Performing a liquidation with _debtToOffset
as 9999999999999999980000
can bring newP
from 1e18 to 1e9 in one liquidation, assuming currentP
is 1e18, due to the calculation in _updateRewardSumAndProduct
:
newP = (currentP * newProductFactor * 1e9) / 1e18; (we already know newProductFactor is 1 )
Now, by creating three troves with the required debt amount, each having _debtToOffset
as 9999999999999999980000
, and subsequently liquidating them while maintaining the deposits in the stability pool at exactly 10,000 * 1e18, P becomes 0. Consequently, users may face difficulties withdrawing from the stability pool.
As _debtToOffset
is the compositeDebt of the trove (the requested debt amount + debt borrowing fee + debt gas comp), we need to solve the following equation to calculate the _debtAmount
needed to open such trove:
x + (x * 5000000000000000 / (1e18) ) + (200 * (1e18)) = 9999999999999999980000
Here x comes out to be x = 9751243781094527343284.
Using this _debtAmount
, an attacker may open three troves, and when the ICR < MCR, they can liquidate the troves while maintaining the deposits in the SP to be exactly 10,000 * 1e18. After three liquidations, the value of P becomes 0.
Due to this, the function getCompoundedDebtDeposit
will return 0 for all the depositors, and thus users would not be able to make any withdrawals from the stability pool.
Impact
Withdrawals and claimable rewards for any new deposits will fail as P_Snapshot
stored for these deposits would be 0
.
Recommendations
Add an assertion as shown below in _updateRewardSumAndProduct
so that such high-fraction liquidations would be reverted.
+ assert(newP > 0);
P = newP;
emit P_Updated(newP);
Remediation
This issue has been acknowledged by Prisma Finance, and a fix was implemented in commit ecc58eb7↗.