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
);
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
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
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
);
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", []);
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);
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());
}