Core Contracts

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

debtToken's totalSupply function should user rayMul instead of rayDiv.

Bug description

Both balanceOf() and totalSupply() functions of the DebtToken return amounts with interest accrued to it, contrary to the scaledBalanceOf() and scaledTotalSupply() functions that return amounts without interest.

If we look at the balanceOf() function, we can see that this is achieved by multiplying raw balance with current liquidity index.

DebtToken.sol#L223-L226

function balanceOf(
address account
) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return
scaledBalance.rayMul(
ILendingPool(_reservePool).getNormalizedDebt()
);
}

However, the totalSupply() function mistakenly divides the raw value by the liquidity index, which reduces the amount that already does not have interest accrued to it.

DebtToken.sol#L232-L235

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

This is problematic, as multiple functions depend on the value return from totalSupply. An example of such function would be borrow() function of the LendingPool. The value returned by totalSupply is used to update totalUsage variable of reserves, which then is used to calculate the utilization and by that influencing rate calculations. If the value returned by totalSupply is lower than it should be, then utilization will be lower than in reality, thus skewing interest rates calculations.

LendingPool.sol#L353-L360

(
bool isFirstMint,
uint256 amountMinted,
uint256 newTotalSupply <-------- here totalSupply is returned by the debtToken
) = 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; <------- here totalUsage is set to totalSupply

totalUsage is used to calculate utilization in updateInterestRatesAndLiquidity() function, where utilization is then used to calculate new rates.

ReserveLibrary.sol#L215-L233

// 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
);

Impact

Incorrect rate calculations, since value return by totalSupply is used to calculate utilization, which will affect interest rates calculations.

Proof of Concept

Please add this test to LendingPool.test.js and run it with npx hardhat test --grep "Total supply incorrect accounting".

describe("sl1", function () {
it("Total supply incorrect accounting", async () => {
await raacHousePrices.setHousePrice(1, ethers.parseEther("1000"));
await ethers.provider.send("evm_mine", []);
// user2 is lender
// there are 1250 assets available for borrow
const depositAmount = ethers.parseEther("1250");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await lendingPool.connect(user2).deposit(depositAmount);
await raacNFT.connect(user1).approve(lendingPool.target, 1);
await lendingPool.connect(user1).depositNFT(1);
// user borrows 800
await lendingPool.connect(user1).borrow(ethers.parseEther("800"));
await ethers.provider.send("evm_increaseTime", [200 * 60 * 60 + 1]);
await ethers.provider.send("evm_mine");
await lendingPool.updateState();
console.log(
"Total supply of debt token: ",
await debtToken.totalSupply()
);
console.log("User debt:", await lendingPool.getUserDebt(user1.address));
});
};

Console output of the test:

Total supply of debt token: 798935258685157733396n
User debt: 801066160295986493203n

We can see, that totalSupply of debt token is now even lesser than original amount borrowed (which does not have interest).

Now, if we were to change the totalSupply() function of the DebtToken in the following way.

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

And run the test again, the console output of the test will be.

Total supply of debt token: 801066160295986493203n
User debt: 801066160295986493203n

Recommended Mitigation

Multiply by liquidity index instead of dividing by it in the totalSupply() function of the DebToken.

DebtToken.sol#L232-L235

function totalSupply()
public
view
override(ERC20, IERC20)
returns (uint256)
{
uint256 scaledSupply = super.totalSupply();
// return scaledSupply;
return
- scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
+ 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.