Core Contracts

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

Mismatched `totalDebt` and `totalLiquidity` Precision Leads to Understated Utilization Rates and Protocol Imbalance.

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; // 100% utilization if no liquidity
}
@> uint256 utilizationRate = totalDebt.rayDiv(totalLiquidity + totalDebt).toUint128();
@> return utilizationRate; // @audit this should be in RAY but totalLiquidity & totalDebt take wad value
}
function calculateBorrowRate(
uint256 primeRate,
uint256 baseRate,
uint256 optimalRate,
uint256 maxRate,
uint256 optimalUtilizationRate,
uint256 utilizationRate // @ 0 if totalUsage(this is the debt) == 0
) internal pure returns (uint256) {
...
...
// @audit utilizationRate (WAD precision), optimalUtilizationRate (RAY precision)!
@> 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();
...
...
// 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; // @audit newTotalSupply is in wad precision
...
...
}
function updateInterestRatesAndLiquidity(ReserveData storage reserve,ReserveRateData storage rateData,uint256 liquidityAdded,uint256 liquidityTaken) internal {
// Update total liquidity
if (liquidityAdded > 0) {
// @audit liquidityAdded is in wad precision while totalLiquidity should hold RAY precision
@> reserve.totalLiquidity = reserve.totalLiquidity + liquidityAdded.toUint128();
}
if (liquidityTaken > 0) {
if (reserve.totalLiquidity < liquidityTaken) revert InsufficientLiquidity();
// @audit liquidityAdded is in wad precision while totalLiquidity should hold RAY precision
reserve.totalLiquidity = reserve.totalLiquidity - liquidityTaken.toUint128();
}
...
...
}

This precision mismatch will have following consequences,

  1. 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.

  2. 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.

  3. A slow growing usageIndex means borrowers owe less interest when repaying.

  4. 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,

// @ custom-added-function
function rayDivOperation(uint256 amount, uint256 _index) external pure returns (uint256) {
return amount.rayDiv(_index);
}
// @ custom-added-function
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); // 0.075 RAY
const optimalUtilizationRate = ethers.parseUnits("1", 27); // 1 RAY
const baseRate = ethers.parseUnits("0.025", 27); // 0.025 RAY (avg market rate)
// TEST1: Testing the impact of a RAY utilizationRate on the value returned.
const utilizationRateWad = ethers.parseUnits("0.3", 27); // 0.3 RAY
const utilizationRateTIMESslope0 = await debtToken.rayMulOperation(utilizationRateWad, slope);
const balanceIncrease0 = await debtToken.rayDivOperation(utilizationRateTIMESslope0, optimalUtilizationRate);
const expectedRate = baseRate + balanceIncrease0;
// this is the expected output of the calculateBorrowRate function.
console.log("expected Rate: ", expectedRate); // 0.047500000000000000000000000 ~ 0.0475e27
// TEST1: Testing the impact of a WAD utilizationRate on the value returned.
const utilizationRateRay = ethers.parseEther("0.3");
const utilizationRateTIMESslope = await debtToken.rayMulOperation(utilizationRateRay, slope);
const balanceIncrease = await debtToken.rayDivOperation(utilizationRateTIMESslope, optimalUtilizationRate);
const actualRate = baseRate + balanceIncrease;
// the actualRate is approx equal to baseRate because of wad utilizationRate
console.log("actual Rate: ", actualRate); // 25000000022500000000000000 ~ 0.025e27
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();
}
...
...
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

ReserveLibrary stores totalLiquidity and totalUsage in WAD precision instead of RAY, causing understated utilization rates and artificially low borrow rates even at high utilization

Informational, incorrect documentation, totalLiquidity and totalUsage are stored in WAD

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

ReserveLibrary stores totalLiquidity and totalUsage in WAD precision instead of RAY, causing understated utilization rates and artificially low borrow rates even at high utilization

Informational, incorrect documentation, totalLiquidity and totalUsage are stored in WAD

Support

FAQs

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