Core Contracts

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

Lending Pool Transfers Fewer Tokens Than Approved During Liquidation

Summary

The StabilityPool::liquidateBorrower function in the Stability Pool contract approves a amount (scaledUserDebt - which is the amount of tokens the user had borrowed along with the interest oer the period) for transfer to the Lending Pool contract, but when LendingPool::finalizeLiquidation is called, the Lending Pool only transfers the principal debt amount (amount) to the reserve asset contract. This discrepancy means that the interest accrued on the borrowed amount is effectively ignored, potentially leading to incorrect fund movements.

Vulnerability Details

1.Liquidation Initiation in Stability Pool

  • A manager or owner calls liquidateBorrower() in the Stability Pool contract.

  • The function retrieves the user's debt from the Lending Pool using lendingPool.getUserDebt(userAddress).

  • The scaled debt amount is calculated as scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt())( which is the amount of tokens the user had borrowed along with the interest oer the period).

  • The Stability Pool approves scaledUserDebt (which includes both borrowed amount and interest) for the Lending Pool.

  • The function then calls lendingPool.finalizeLiquidation(userAddress).

StabilityPool::liquidateBorrower:

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
// Get the user's debt from the LendingPool.
@> uint256 userDebt = lendingPool.getUserDebt(userAddress);
@> uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
// Approve the LendingPool to transfer the debt amount
@> bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
// Call finalizeLiquidation on LendingPool
@> lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}

LendingPool::getUserDebt:

function getUserDebt(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
@> return user.scaledDebtBalance.rayMul(reserve.usageIndex);
}

LendingPool::getNormalizedDebt:

function getNormalizedDebt() external view returns (uint256) {
return reserve.usageIndex;
}

2.Finalization in Lending Pool

  • The Lending Pool verifies that the user is under liquidation and checks if the grace period has expired.

  • The user’s debt is retrieved again using:

uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
  • The contract then calls the burn() function of the debt token to remove the debt from the user’s balance.

  • The burn() function returns four values: (uint256 amount, uint256 totalSupply, uint256 amountScaled, uint256 balanceIncrease) where amount only represents the borrowed amount (not including interest).
    LendingPool::finalizeLiquidation:

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
UserData storage user = userData[userAddress];
@> uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
// Transfer 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;
// Burn DebtTokens from the user
@> (uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) = @>IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
// Transfer reserve assets from Stability Pool to cover the debt
@> IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
// Update user's scaled debt balance
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}

As you can see in the below function the amount returned is the amount which was transferred to the below function by the above finalizeLiquidation function which is just the user's debt and does not include any interest.
DebtToken::burn:

function burn(
address from,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
if (from == address(0)) revert InvalidAddress();
if (amount == 0) {
return (0, totalSupply(), 0, 0);
}
uint256 userBalance = balanceOf(from);
uint256 balanceIncrease = 0;
if (_userState[from].index != 0 && _userState[from].index < index) {
uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
balanceIncrease = userBalance.rayMul(borrowIndex) - userBalance.rayMul(_userState[from].index);
amount = amount;
}
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
_burn(from, amount.toUint128());
emit Burn(from, amountScaled, index);
return (amount, totalSupply(), amountScaled, balanceIncrease);
}

3.Incorrect Token Transfer from Stability Pool

  • The Lending Pool then attempts to transfer the reserve asset from the Stability Pool:

IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
  • Issue: The Stability Pool initially approved scaledUserDebt (borrowed amount + interest), but the Lending Pool only transfers amountScaled (which excludes interest) which was returned by the burn function in Debt Token contract which was the amount/users debt passed by the finalizeLiquidation function itself

  • This means that less than the expected amount of tokens are taken from the Stability Pool, leading to a discrepancy in the protocol's accounting.

  • As a result, the system fails to fully cover the liquidated user's debt, potentially leaving bad debt in the protocol.

Impact

  1. The Stability Pool over-approves tokens, but the Lending Pool does not use the full approved amount.

  2. Interest accrued on the borrowed amount is not correctly settled, leading to a mismatch in the system’s accounting.

  3. This could result in an underpayment of debt, allowing users to get liquidated without fully repaying their outstanding obligations, which could cause financial losses to the protocol.

Tools Used

Manual

Recommendations

Modify finalizeLiquidation() to correctly account for the full scaledUserDebt, including both principal and interest, before performing the transfer.

Updates

Lead Judging Commences

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

StabilityPool::liquidateBorrower double-scales debt by multiplying already-scaled userDebt with usage index again, causing liquidations to fail

Support

FAQs

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