Core Contracts

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

Wrong calculation of totalSupply in DebtToken

Summary

The DebtToken.totalSupply() function is using rayDiv when it should be using rayMul to get the actual total supply.

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

Vulnerability Details

The DebtToken contract stores balances in scaled form (divided by the index), to get the actual total supply, we need to multiply the scaled supply by the current index, not divide by it. This is evidenced by how balanceOf() works, which correctly uses rayMul to get the actual balance:

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

Impact

  • Wrong value reported to users and external tools depending on totalSupply()

  • Wrong value returned from mint().

  • Wrong value returned from burn().

  • This will directly affect LendingPool.reserve.totalUsage showing lower values than actual usage.

  • And indirectly this will eventually cause the protocol charge less interest from borrowers as time goes by, see PoC.

  • The LendingPool.reserve.totalUsage is used in the calculation of LendingPool.rateData.currentUsageRate and LendingPool.rateData.currentLiquidityRate and LendingPool.reserve.liquidityIndex.

  • In LendingPool.borrow, the value from DebtToken.totalSupply is assigned to reserve.totalUsage.

// LendingPool.borrow()
// Mint DebtTokens to the user (scaled amount)
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress)
.mint(msg.sender, msg.sender, amount, reserve.usageIndex);
// ...
reserve.totalUsage = newTotalSupply; // << here, wrong value
  • Also in LendingPool._repay and LendingPool.finalizeLiquidation. The value from DebtToken.totalSupply is assigned to reserve.totalUsage.

Tools Used

  • Manual code review

  • Unit test

PoC

I have created two test cases where a user borrows 50% of the available liquidity and then I check the accumulated debt after 10 years. While in both cases no other varianrts is changed.

In the first case the user debt start at 500.0 and goes to 1025.9 after 10 years.

In the second case the user debt start at 500.0 and goes to 914.65 after 10 years, which is less compared to the first test case.

Test results

// case1
Current Block Number: 21914926
totalLiquidity::1000.0
totalUsage::0.0
liquidityIndex::1000000000000000000000000000
usageIndex::1000000001585489600445117959
userL borrows 50% of totalLiquidity
getUserDebt(userL) 500.0
debtToken.balanceOf(userL) 500.0
10 years later
Current Block Number: 21914931
totalLiquidity::500.0
totalUsage::499.999997621765606874
liquidityIndex::1359374998587923327048812920
usageIndex::2051864857711402869002414542
currentLiquidityRate 35937499858792332704881292
currentUsageRate 71874999888520262557093804
primeRate 100000000000000000000000000
baseRate 25000000000000000000000000
optimalRate 50000000000000000000000000
maxRate 400000000000000000000000000
optimalUtilizationRate 800000000000000000000000000
protocolFeeRate 0
getUserDebt(userL) 1025.932426415793644272
debtToken.balanceOf(userL) 1025.932426415793644272
✔ case1 (2376ms)
// case2
skip 10 years, this will increase the usageIndex
Current Block Number: 21914965
totalLiquidity::1000.0
totalUsage::0.0
liquidityIndex::1000000000000000000000000000
usageIndex::1284025419352231513141649859
userL borrows 50% of totalLiquidity
getUserDebt(userL) 500.000000957547527686
debtToken.balanceOf(userL) 500.000000957547527686
10 years later
Current Block Number: 21914970
totalLiquidity::500.0
totalUsage::303.265328597700745876
liquidityIndex::1228013563962445032068870943
usageIndex::2348892928655535385342462509
currentLiquidityRate 22801356307455897162837483
currentUsageRate 60394437608390291641566505
primeRate 100000000000000000000000000
baseRate 25000000000000000000000000
optimalRate 50000000000000000000000000
maxRate 400000000000000000000000000
optimalUtilizationRate 800000000000000000000000000
protocolFeeRate 0
getUserDebt(userL) 914.659824195891311077
debtToken.balanceOf(userL) 914.659824195891311077
✔ case2 (2338ms)

Put this code in test/unit/core/pools/LendingPool/LendingPool.test.js

describe("ReserveData.totalUsage", function () {
async function printReserveData() {
const reserve = await lendingPool.reserve();
const blockNumber = await ethers.provider.getBlockNumber();
console.log(`Current Block Number: ${blockNumber}`);
console.log(`totalLiquidity::${ethers.formatEther(reserve[3])}`);
console.log(`totalUsage::${ethers.formatEther(reserve[4])}`);
console.log(`liquidityIndex::${reserve[5]}`);
console.log(`usageIndex::${reserve[6]}`);
console.log("\n");
}
async function printRateData() {
const rates = await lendingPool.rateData();
console.log(`currentLiquidityRate ${rates[0]}`);
console.log(`currentUsageRate ${rates[1]}`);
console.log(`primeRate ${rates[2]}`);
console.log(`baseRate ${rates[3]}`);
console.log(`optimalRate ${rates[4]}`);
console.log(`maxRate ${rates[5]}`);
console.log(`optimalUtilizationRate ${rates[6]}`);
console.log(`protocolFeeRate ${rates[7]}`);
console.log("\n");
}
it("case1", async function() {
const [userL] = await ethers.getSigners();
const myNFT = 146;
const myNFTPrice = ethers.parseEther("1000000000");
await raacHousePrices.setHousePrice(myNFT, myNFTPrice);
await token.connect(owner).mint(userL.address, myNFTPrice);
await token.connect(userL).approve(raacNFT.target, myNFTPrice);
await raacNFT.connect(userL).mint(myNFT, myNFTPrice);
await raacNFT.connect(userL).approve(lendingPool.target, myNFT);
await lendingPool.connect(userL).depositNFT(myNFT);
await printReserveData();
let reserve = await lendingPool.reserve();
const totalLiquidity = reserve[3];
await lendingPool.connect(userL).borrow(totalLiquidity / BigInt(2));
await lendingPool.updateState();
console.log(`userL borrows 50% of totalLiquidity`);
console.log(`getUserDebt(userL) ${ethers.formatEther(await lendingPool.getUserDebt(userL))}`);
console.log(`debtToken.balanceOf(userL) ${ethers.formatEther(await debtToken.balanceOf(userL.address))}`);
await ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 365 * 10]); // 10 year
await ethers.provider.send("evm_mine", []);
await lendingPool.updateState();
console.log(`10 years later\n`);
await lendingPool.updateState();
await printReserveData();
await printRateData();
console.log(`getUserDebt(userL) ${ethers.formatEther(await lendingPool.getUserDebt(userL))}`);
console.log(`debtToken.balanceOf(userL) ${ethers.formatEther(await debtToken.balanceOf(userL.address))}`);
});
it("case2", async function() {
await ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 365 * 10]); // 10 year
await ethers.provider.send("evm_mine", []);
await lendingPool.updateState();
console.log(`skip 10 years, this will increase the usageIndex`);
const [userL] = await ethers.getSigners();
const myNFT = 146;
const myNFTPrice = ethers.parseEther("1000000000");
await raacHousePrices.setHousePrice(myNFT, myNFTPrice);
await token.connect(owner).mint(userL.address, myNFTPrice);
await token.connect(userL).approve(raacNFT.target, myNFTPrice);
await raacNFT.connect(userL).mint(myNFT, myNFTPrice);
await raacNFT.connect(userL).approve(lendingPool.target, myNFT);
await lendingPool.connect(userL).depositNFT(myNFT);
await printReserveData();
let reserve = await lendingPool.reserve();
const totalLiquidity = reserve[3];
await lendingPool.connect(userL).borrow(totalLiquidity / BigInt(2));
await lendingPool.updateState();
console.log(`userL borrows 50% of totalLiquidity`);
console.log(`getUserDebt(userL) ${ethers.formatEther(await lendingPool.getUserDebt(userL))}`);
console.log(`debtToken.balanceOf(userL) ${ethers.formatEther(await debtToken.balanceOf(userL.address))}`);
await ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 365 * 10]); // 10 year
await ethers.provider.send("evm_mine", []);
await lendingPool.updateState();
console.log(`10 years later\n`);
await lendingPool.updateState();
await printReserveData();
await printRateData();
console.log(`getUserDebt(userL) ${ethers.formatEther(await lendingPool.getUserDebt(userL))}`);
console.log(`debtToken.balanceOf(userL) ${ethers.formatEther(await debtToken.balanceOf(userL.address))}`);
});
});

Recommendations

This is the correct implementation

function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
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.