Vulnerability Details
The ReserveLibrary
calculates the utilizationRate
using reserve.totalLiquidity
and reserve.totalUsage
in calculateUtilizationRate
function which is expected to return a RAY
precision value since it gets compared with optimalUtilizationRate
in calculateBorrowRate
and optimalUtilizationRate
is a RAY-precision value.
function calculateUtilizationRate(uint256 totalLiquidity, uint256 totalDebt) internal pure returns (uint256) {
if (totalLiquidity < 1) {
@> return WadRayMath.RAY;
}
@> uint256 utilizationRate = totalDebt.rayDiv(totalLiquidity + totalDebt).toUint128();
@> return utilizationRate;
}
function calculateBorrowRate(
uint256 primeRate,
uint256 baseRate,
uint256 optimalRate,
uint256 maxRate,
uint256 optimalUtilizationRate,
uint256 utilizationRate
) internal pure returns (uint256) {
...
...
@> if (utilizationRate <= optimalUtilizationRate) {
...
} else {
...
}
...
}
They are also used to calculate currentLiquidityRate
via calculateLiquidityRate
which again returns a RAY
precision
value.
However, both the reserve.totalLiquidity
and reserve.totalUsage
are incremented and decremented with wad precision
values.
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();
...
...
(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;
...
...
}
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();
}
...
...
}
This precision mismatch will have following consequences,
The condition utilizationRate(wad) <= optimalUtilizationRate(ray)
would always hold true in calculateBorrowRate
;
The currentUsageRate
would always be approx equal to baseRate (avg market rate) due to utilizationRate
being
a wad
value even when utilization is high (test provided for this). A high utilization implies,
rate (currentUsageRate) should spike to reflect scarcity of liquidity that utilizationRate > optimalUtilizationRate
covers.
A lower currentUsageRate
would result in lower usageIndex value calculated by calculateUsageIndex
. This implies
usageIndex would grow much more slowly; a persistently low usageRate that's far below intended value during
high utilization.
A slow growing usageIndex means borrowers owe less interest when repaying.
The currentUsageRate
updates currentLiquidityRate
which is used to update liquidityIndex
. Lenders
earn interest based on borrow rates and a lower usageIndex would lead to a slower liquidityIndex
growth
for lenders (less interest distributed) shortfall for lenders.
As a result, the protocol would accrue less interest to distribute and low rates may encourage excessive borrowing,
risking liquidity shortages if utilization nears 100%.
Impact
The precision mismatch leads to undestated borrow rates, slower usageIndex
and liquidityIndex
growth leading
to reduced lender returns, excessive borrowing and overall systematic instability.
Tools Used
Manual Review + Hardhat Testing
Proof-Of-Code
Place the following test in LendingPool.test.js
. It make use of two custom functions added in DebtToken
just
for testing purpose,
function rayDivOperation(uint256 amount, uint256 _index) external pure returns (uint256) {
return amount.rayDiv(_index);
}
function rayMulOperation(uint256 amount, uint256 _index) external pure returns (uint256) {
return amount.rayMul(_index);
}
LendingPool.test.js
test:
it("test calculateBorrowRate output using wad and ray utilizationRate value", async() => {
const slope = ethers.parseUnits("0.075", 27);
const optimalUtilizationRate = ethers.parseUnits("1", 27);
const baseRate = ethers.parseUnits("0.025", 27);
const utilizationRateWad = ethers.parseUnits("0.3", 27);
const utilizationRateTIMESslope0 = await debtToken.rayMulOperation(utilizationRateWad, slope);
const balanceIncrease0 = await debtToken.rayDivOperation(utilizationRateTIMESslope0, optimalUtilizationRate);
const expectedRate = baseRate + balanceIncrease0;
console.log("expected Rate: ", expectedRate);
const utilizationRateRay = ethers.parseEther("0.3");
const utilizationRateTIMESslope = await debtToken.rayMulOperation(utilizationRateRay, slope);
const balanceIncrease = await debtToken.rayDivOperation(utilizationRateTIMESslope, optimalUtilizationRate);
const actualRate = baseRate + balanceIncrease;
console.log("actual Rate: ", actualRate);
expect(expectedRate).to.gt(actualRate);
})
Recommendations
Make changes to align with in-line doc which states that ReserveRate
and ReserveRateData
hold RAY
precision values,
LendingPool
:
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();
...
...
// 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 = newTotalSupply;
+ reserve.totalUsage = newTotalSupply.wadToRay();
...
...
}
function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
...
...
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// Transfer reserve assets from the caller (msg.sender) to the reserve
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
// review totalUsage should be scaled here
- reserve.totalUsage = newTotalSupply;
+ reserve.totalUsage = newTotalSupply.wadToRay();
...
...
}
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
...
...
(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;
+ reserve.totalUsage = newTotalSupply.wadToRay();
...
...
}
ReserveLibrary
:
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();
+ reserve.totalLiquidity = reserve.totalLiquidity + liquidityAdded.wadToRay().toUint128();
}
if (liquidityTaken > 0) {
- if (reserve.totalLiquidity < liquidityTaken) revert InsufficientLiquidity();
+ if (reserve.totalLiquidity < liquidityTaken.wadToRay()) revert InsufficientLiquidity();
- reserve.totalLiquidity = reserve.totalLiquidity - liquidityTaken.toUint128();
+ reserve.totalLiquidity = reserve.totalLiquidity - liquidityTaken.wadToRay().toUint128();
}
...
...
}