Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

Inadequate debt clearance during liquidation process

Summary

The liquidation process in the LendingPool contract may result in users retaining outstanding debt after liquidation. This occurs because the user's normalized debt is compared against their scaled debt balance, leading to users emerging from liquidation with unresolved debt.

Vulnerability Details

When a user intends to borrow, they specify the amount of reserve tokens they need. DebtToken.mint() is then invoked to mint this user debt tokens in exchange:

// @audit amount is scaled as done in _update
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
---SNIP---

As seen above, the amount is first scaled via the rayDiv(index) as done by the _update() and as such, when minting is done, the amount of debt tokens received by the user is amountScaled and they receive amount of reserve tokens and their scaledDebtBalance is updated as follows.

// Update user's scaled debt balance
>> uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
// Mint DebtTokens to the user (scaled amount)
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
// Transfer borrowed amount to user
>> IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
>> user.scaledDebtBalance += scaledAmount;

We can deduce that user.scaledDebtBalance is incremented by the number of debt tokens minted to them which is a scaled value of the amount they receive.

Now during liquidation, the following is done in finalizeLiquidation():

UserData storage user = userData[userAddress];
// @audit-info userDebt is normalized to underlying units
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// @audit-info User is removed from liquidation train
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
// @audit-issue Transfer all user's NFTs to Stability Pool
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
delete user.nftTokenIds;
// @audit-info userDebt is passed as amount in burn()
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) = IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
// @audit-info amountScaled returned from burn() is pulled from stability pool
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
// @audit-info Update user's scaled debt balance
user.scaledDebtBalance -= amountBurned;

Now lets see how DebtToken.burn() handles all this and the values it returns:

// @audit-info user's debt token balance retrieved
uint256 userBalance = balanceOf(from);
---SNIP---
// @audit-issue userDebt that was passed as amount is compared against userBalance which is a scaled value
if(amount > userBalance){
amount = userBalance;
}
// @audit-issue amountScaled calculated after
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
// @audit-issue Incorrect value of debt tokens burned
_burn(from, amount.toUint128());
emit Burn(from, amountScaled, index);
// @audit-issue first value returned is incorrect
return (amount, totalSupply(), amountScaled, balanceIncrease);

Issue:

  • In finalizeLiquidation(), userDebt is already normalized by rayMul(reserve.usageIndex) which means that it is in underlying units.

  • This userDebt is then passed as amount to burn().

  • The problem however is that a normalized value is compared againts a scaled value. Remember that user's debt balance (userBalance) was achieved by rayDiv() while userDebt is this balance normalized and as such, the check if(amount > userBalance) will always be true.

  • This results in amount being set to userBalance which is a smaller value. Then when burning is done, a very small portion of debt tokens will burned and finally the burn() function returns amount which has been updated back to finalizeLiquidation().
    Now since this first return value is what is pulled from the stability pool, the debt remains unsettled.

Scenario:

Lets say that with the current index, 600 units of reserve tokens clears 300 debt tokens

  • A user has 300 debt tokens which when normalized is 600 reserve tokens

  • As such, during liquidation, 600 units of reserve tokens, should burn the whole 300 debt tokens from the user

  • However, the 600 is compared against the user's 300 debt tokens and since this is greater, the amount is set to 300.

  • The burn() function then calls _burn(300) and since the _update() is invoked during burning, this only burns 150 debt tokens and the user is left with 150 more debt tokens.

  • The finalizeLiquidation() function pulls amountScaled i.e the amount that was updated based on user's debt token balance (300) and user's debt reduced by amountBurned which is 150.

Final state:

  • User's debt token balance => 150

  • user.scaledDebtBalance => 300-150 = 150

  • All user's NFTs taken

Impact

The issue here is that user's debt remain unsettled even after liquidation yet all their collateral is taken. This means that their chances of getting liquidated the next time is increased as they start out afresh will a pre-existing debt.

Tools Used

Manual Review

Recommendations

Compare user's debt token balance against amountScaled then update the amount to be supplied by the user:

// @audit Retrieve Borrower's debt token balance
uint256 userBalance = balanceOf(from);
---SNIP---
- if(amount > userBalance){
- amount = userBalance;
- }
// @audit scale the amount to be supplied
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
// @audit Check this aginst user's debt token balance and Update the amountScaled and amount to be supplied
+ if(amountScaled > userBalance){
+ amountScaled = userBalance;
+ amount = amountScaled.rayMul(index);
+ }
// @audit This will burn the correct amount
_burn(from, amount.toUint128());
return (amount, totalSupply(), amountScaled, balanceIncrease);
Updates

Lead Judging Commences

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::finalizeLiquidation passes normalized userDebt to DebtToken::burn which compares against scaled balance, causing incomplete debt clearance while taking all collateral

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::finalizeLiquidation passes normalized userDebt to DebtToken::burn which compares against scaled balance, causing incomplete debt clearance while taking all collateral

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.