Core Contracts

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

Interest rates will be incorrectly updated, which will result in higher borrowing costs for borrowers

Summary

Everytime a user borrows, repay or it's getting liquidated the interest rates are updated accordingly, which means the utilization rate also, which is used for the calculation of the borrow rate and the liquidity rate. However when a user borrows assets the rates are updated with the raw amount, but when he repays or getting liquidated, the rates are updated with the scaled amount. The scaled amount represents the borrower's debt + accrued interest on it.

Vulnerability Details

The rates update is done via ReserveLibrary::updateInterestRatesAndLiquidity, which is used when in LendingPool::borrow(), _repay(), finalizeLiquidation():

  • Borrow -

  • Repay -

  • Liquidate -

function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();
UserData storage user = userData[msg.sender];
uint256 collateralValue = getUserCollateralValue(msg.sender);
if (collateralValue == 0) revert NoCollateral();
// Update reserve state before borrowing
ReserveLibrary.updateReserveState(reserve, rateData);
// Ensure sufficient liquidity is available
_ensureLiquidity(amount);
// Fetch user's total debt after borrowing
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
// Ensure the user has enough collateral to cover the new debt
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
// 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;
// reserve.totalUsage += amount;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
@>>> ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);

As can be seen from the code above when a user borrows for the first time, the raw amount (input amount) is used for updating the interest rates, i assume this is done, because a borrower can't have accrued interest instantly when he borrows assets. However if the same user decides to borrow again after some time he will have accrued interest on his debt (can be seen in the DebtToken::mint() below):

function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
if (user == address(0) || onBehalfOf == address(0)) revert InvalidAddress();
if (amount == 0) {
return (false, 0, totalSupply());
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
@>>> if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
@>>> balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
@>>> uint256 amountToMint = amount + balanceIncrease;
@>>> _mint(onBehalfOf, amountToMint.toUint128());
emit Transfer(address(0), onBehalfOf, amountToMint);
emit Mint(user, onBehalfOf, amountToMint, balanceIncrease, index);
@>>> return (scaledBalance == 0, amountToMint, totalSupply());
}

Now if we check the borrow function again we can see that there will be an issue this time, because the rates will still update with the input amount instead of the scaled amount. This amount is used to adjust the totalLiquidity in updateInterestRatesAndLiquidity() function, which is used to determine the utilization ratio:

function updateInterestRatesAndLiquidity(ReserveData storage reserve,ReserveRateData storage rateData,uint256 liquidityAdded,uint256 liquidityTaken) internal {
// Update total liquidity
if (liquidityAdded > 0) {
reserve.totalLiquidity = reserve.totalLiquidity + liquidityAdded.toUint128();
}
if (liquidityTaken > 0) {
if (reserve.totalLiquidity < liquidityTaken) revert InsufficientLiquidity();
@>>> reserve.totalLiquidity = reserve.totalLiquidity - liquidityTaken.toUint128();
}
uint256 totalLiquidity = reserve.totalLiquidity;
uint256 totalDebt = reserve.totalUsage;
uint256 computedDebt = getNormalizedDebt(reserve, rateData);
uint256 computedLiquidity = getNormalizedIncome(reserve, rateData);
// Calculate utilization rate
@>>> uint256 utilizationRate = calculateUtilizationRate(reserve.totalLiquidity, reserve.totalUsage);
// Update current usage rate (borrow rate)
rateData.currentUsageRate = calculateBorrowRate(
rateData.primeRate,
rateData.baseRate,
rateData.optimalRate,
rateData.maxRate,
rateData.optimalUtilizationRate,
utilizationRate
);
// Update current liquidity rate
rateData.currentLiquidityRate = calculateLiquidityRate(
utilizationRate,
rateData.currentUsageRate,
rateData.protocolFeeRate,
totalDebt
);

This will make the utilization ratio higher than it should be: higher utilization ratio -> higher borrow rates -> higher borrowing costs.

Impact

Borrowers will be unfairly charged higher interest rates, which will increase their costs.

Tools Used

Manual Review

Recommendations

LendingPool:
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
...
// 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;
// reserve.totalUsage += amount;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
- ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);
+ ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amountMinted);
Updates

Lead Judging Commences

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

LendingPool::borrow updates interest rates using raw amounts while repay/liquidation use scaled amounts with interest, creating accounting inconsistencies that distort utilization rates

DebtToken::mint returns totalSupply() instead of scaledTotalSupply(), causing incorrect updates to reserve.totalUsage in LendingPool.borrow()

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

LendingPool::borrow updates interest rates using raw amounts while repay/liquidation use scaled amounts with interest, creating accounting inconsistencies that distort utilization rates

DebtToken::mint returns totalSupply() instead of scaledTotalSupply(), causing incorrect updates to reserve.totalUsage in LendingPool.borrow()

Support

FAQs

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

Give us feedback!