Core Contracts

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

Incorrect Debt Scaling Leads to Miscalculated Interest Rates

Summary

The protocol miscalculates the utilization rate due to an incorrect scaling of totalDebt, leading to artificially lower utilization rates. This affects both borrow and supply rate calculations, potentially destabilizing the lending market by setting inaccurate interest rates.

Vulnerability Details

Incorrect Scaling of totalDebt

The utilization rate is calculated as follows:

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;
}

However, totalDebt is derived from DebtToken::totalSupply(), which is incorrectly scaled:

function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
// @audit-issue Should use rayMul instead of rayDiv
return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
}

Since scaledSupply is total supply of debt tokens that are already scaled (divided) by the usageIndex, dividing it again results in an underestimated totalDebt, leading to an artificially low utilization rate.

Incorrect Scaling Propagates to Interest Rate Calculations

The flawed newTotalSupply is used in the _repay() function to update reserve.totalUsage, which is expected to be in real asset values but instead holds token shares:

function _repay(uint256 amount, address onBehalfOf) internal {
...
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
(uint256 amountScaled, uint256 newTotalSupply, ... ) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
...
// @audit-issue Should be assets but stores shares instead
@> reserve.totalUsage = newTotalSupply;
...
}

Since totalUsage is stored in shares instead of actual assets, the utilization rate is lower than expected. This, in turn, affects the borrow rate and supply rate calculations:

function calculateBorrowRate(
uint256 primeRate,
uint256 baseRate,
uint256 optimalRate,
uint256 maxRate,
uint256 optimalUtilizationRate,
uint256 utilizationRate
) internal pure returns (uint256) {
...
uint256 rateIncrease = utilizationRate.rayMul(rateSlope).rayDiv(optimalUtilizationRate);
...
}
function calculateLiquidityRate(uint256 utilizationRate, uint256 usageRate, uint256 protocolFeeRate, uint256 totalDebt) internal pure returns (uint256) {
if (totalDebt < 1) {
return 0;
}
@> uint256 grossLiquidityRate = utilizationRate.rayMul(usageRate);
@> uint256 protocolFeeAmount = grossLiquidityRate.rayMul(protocolFeeRate);
@> uint256 netLiquidityRate = grossLiquidityRate - protocolFeeAmount;
return netLiquidityRate;
}

Lower utilization rates lead to lower borrow and supply interest.

The issue appears in LendingPool::_repay as well as in LendingPool::borrow

PoC

import { expect } from 'chai';
import hre from "hardhat";
import pkg from "hardhat";
const { network } = pkg;
const { ethers } = hre;
describe("RAAC PoC", function() {
let owner, user1, user2, user3;
let crvusd, raacNFT, raacHousePrices, stabilityPool
let lendingPool, rToken, debtToken;
let token;
beforeEach(async function () {
[owner, user1, user2, user3] = await ethers.getSigners();
const CrvUSDToken = await ethers.getContractFactory("crvUSDToken");
crvusd = await CrvUSDToken.deploy(owner.address);
await crvusd.setMinter(owner.address);
token = crvusd;
const RAACHousePrices = await ethers.getContractFactory("RAACHousePrices");
raacHousePrices = await RAACHousePrices.deploy(owner.address);
const RAACNFT = await ethers.getContractFactory("RAACNFT");
raacNFT = await RAACNFT.deploy(crvusd.target, raacHousePrices.target, owner.address);
stabilityPool = { target: owner.address };
const RToken = await ethers.getContractFactory("RToken");
rToken = await RToken.deploy("RToken", "RToken", owner.address, crvusd.target);
const DebtToken = await ethers.getContractFactory("DebtToken");
debtToken = await DebtToken.deploy("DebtToken", "DT", owner.address);
const RAACToken = await ethers.getContractFactory("RAACToken");
const raacToken = await RAACToken.deploy(owner.address, 100, 50);
const DEToken = await ethers.getContractFactory("DEToken");
const deToken = await DEToken.deploy("DEToken", "DEToken", owner.address, rToken.target);
const initialPrimeRate = ethers.parseUnits("0.1", 27);
const LendingPool = await ethers.getContractFactory("LendingPool");
lendingPool = await LendingPool.deploy(
crvusd.target,
rToken.target,
debtToken.target,
raacNFT.target,
raacHousePrices.target,
initialPrimeRate
);
const StabilityPool = await ethers.getContractFactory("StabilityPool");
stabilityPool = await StabilityPool.deploy(owner.address);
const RAACMinter = await ethers.getContractFactory("RAACMinter");
const raacMinter = await RAACMinter.deploy(
raacToken.target,
stabilityPool.target,
lendingPool.target,
owner.address
);
await stabilityPool.initialize(
rToken.target,
deToken.target,
raacToken.target,
raacMinter.target,
crvusd.target,
lendingPool.target
)
await rToken.setReservePool(lendingPool.target);
await debtToken.setReservePool(lendingPool.target);
await rToken.transferOwnership(lendingPool.target);
await debtToken.transferOwnership(lendingPool.target);
const mintAmount = ethers.parseEther("1000");
await crvusd.mint(user1.address, mintAmount);
await crvusd.mint(user3.address, mintAmount);
const mintAmount2 = ethers.parseEther("10000");
await crvusd.mint(user2.address, mintAmount2);
await crvusd.connect(user1).approve(lendingPool.target, mintAmount);
await crvusd.connect(user2).approve(lendingPool.target, mintAmount);
await crvusd.connect(user3).approve(lendingPool.target, mintAmount);
await raacHousePrices.setOracle(owner.address);
await raacHousePrices.setHousePrice(1, ethers.parseEther("100"));
await ethers.provider.send("evm_mine", []);
const housePrice = await raacHousePrices.tokenToHousePrice(1);
const raacHpAddress = await raacNFT.raac_hp();
const priceFromNFT = await raacNFT.getHousePrice(1);
const tokenId = 1;
const amountToPay = ethers.parseEther("100");
await token.mint(user1.address, amountToPay);
await token.connect(user1).approve(raacNFT.target, amountToPay);
await raacNFT.connect(user1).mint(tokenId, amountToPay);
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await lendingPool.connect(user2).deposit(depositAmount);
await ethers.provider.send("evm_mine", []);
expect(await crvusd.balanceOf(rToken.target)).to.equal(ethers.parseEther("1000"));
});
it.only("incorrectly scales Debt leading to miscalculated interest rates", async () => {
// supplier deposits assets into the lending pool
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await lendingPool.connect(user2).deposit(depositAmount);
// borrower provides collateral
const tokenId = 1;
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await lendingPool.connect(user1).depositNFT(tokenId);
// borrower borrows against the provided collateral
const borrowAmount = ethers.parseEther("50");
await expect(lendingPool.connect(user1).borrow(borrowAmount))
.to.not.be.revertedWithCustomError(lendingPool, "NotEnoughCollateralToBorrow");
// logging the interest rates values
const reserves = await lendingPool.reserve();
const rateData = await lendingPool.rateData();
console.log("Borrow interest rate: ", rateData.currentUsageRate);
console.log("Supply interest rate: ", rateData.currentLiquidityRate);
console.log("Usage index: ", reserves.usageIndex);
console.log("Liquidity index: ", reserves.liquidityIndex);
})
});

Output

With the incorrect scaling of the debt

Borrow interest rate: 27343749989130725623855563n
Supply interest rate: 683593746558063115481100n
Usage index: 1000000002378234401610343417n
Liquidity index: 1000000000000000000000000000n

With the correct scaling of the debt:

Borrow interest rate: 27343750000000000000000000n
Supply interest rate: 683593750000000000000000n
Usage index: 1000000002378234401610343416n
Liquidity index: 1000000000000000000000000000n

Clearly, when the debt is scaled incorrectly, the interest rates are lower.

Impact

The miscalculated utilization rate results in:

  • Lower-than-expected borrow rates, reducing lender incentives.

  • Lower-than-expected supply rates, discouraging liquidity provision.

  • Potential instability in interest rate mechanisms, which could harm protocol sustainability.

Tools Used

Manual Research, VSCode

Recommendations

Correct the scaling issue in DebtToken::totalSupply:

function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
- return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
+ return scaledSupply.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}
Updates

Lead Judging Commences

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

DebtToken::totalSupply incorrectly uses rayDiv instead of rayMul, severely under-reporting total debt and causing lending protocol accounting errors

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

DebtToken::totalSupply incorrectly uses rayDiv instead of rayMul, severely under-reporting total debt and causing lending protocol accounting errors

Support

FAQs

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