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();
ReserveLibrary.updateReserveState(reserve, rateData);
_ensureLiquidity(amount);
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
reserve.totalUsage = newTotalSupply;
@>>> 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 {
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);
@>>> uint256 utilizationRate = calculateUtilizationRate(reserve.totalLiquidity, reserve.totalUsage);
rateData.currentUsageRate = calculateBorrowRate(
rateData.primeRate,
rateData.baseRate,
rateData.optimalRate,
rateData.maxRate,
rateData.optimalUtilizationRate,
utilizationRate
);
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);