Core Contracts

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

Double Interest Accrual in DebtToken Mint Operation

Summary

The DebtToken contract incorrectly adds accrued interest to the mint amount during subsequent borrows, while the same interest is already accounted for through usage index scaling in the balanceOf function. This results in inflated debt tokens being minted to borrowers on their second and subsequent borrows.

Vulnerability Details

The vulnerability exists in the DebtToken.mint function where it:

Calculates a balanceIncrease based on index difference
Adds this increase to the mint amount: amountToMint = amount + balanceIncrease
However, interest is already accounted for through index scaling in balanceOf:

// DebtToken.mint
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
function balanceOf(address account) public view returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}

There are two ways to query user's debt, LendingPool.getUserDebt and DebtToken.balanceOf(address), the first is using internal variable in the lending pool and is used to calculate the users's helth factor. While the second is used when user wants to repay debt or user is getting liquidated.

Impact

HIGH. The double-counting of interest leads to:

  • Users receiving more debt tokens than they should on subsequent borrows

  • Protocol's accounting of total debt being inflated

  • Discrepancies in liquidation and repayment calculations in LendingPool

Tools Used

  • Manual code review

  • Unit test

PoC

Add this test in test/unit/core/pools/LendingPool/LendingPool.test.js

describe("audit", function() {
let userJ;
let userK;
const tokenId1 = 144;
const tokenId2 = 145;
const tokenPrice = ethers.parseEther("100");
beforeEach(async function () {
[userJ, userK] = await ethers.getSigners();
await raacHousePrices.setHousePrice(tokenId1, tokenPrice);
await raacHousePrices.setHousePrice(tokenId2, tokenPrice);
await token.connect(owner).mint(userJ.address, ethers.parseEther("1000"));
await token.connect(owner).mint(userK.address, ethers.parseEther("1000"));
await token.connect(userJ).approve(raacNFT.target, ethers.parseEther("1000"));
await raacNFT.connect(userJ).mint(tokenId1, tokenPrice);
await token.connect(userK).approve(raacNFT.target, ethers.parseEther("1000"));
await raacNFT.connect(userK).mint(tokenId2, tokenPrice);
await token.connect(userJ).approve(lendingPool.target, ethers.parseEther("1000"));
await token.connect(userK).approve(lendingPool.target, ethers.parseEther("1000"));
});
it("Extra DebtToken minted to borrower on second borrow", async function () {
console.log(`userJ::${userJ.address}\n`);
await raacNFT.connect(userJ).approve(lendingPool.target, tokenId1);
await lendingPool.connect(userJ).depositNFT(tokenId1);
const borrowAmount = ethers.parseEther("1");
console.log("Borrow 1 token");
await lendingPool.connect(userJ).borrow(borrowAmount);
console.log(`getUserDebt(userJ) ${ethers.formatEther(await lendingPool.getUserDebt(userJ))}`);
console.log(`DebtToken.balanceOf(userJ) ${ethers.formatEther(await debtToken.balanceOf(userJ.address))}`);
await ethers.provider.send("evm_increaseTime", [60 * 60 * 24 * 365 * 10]); // 1 year
await ethers.provider.send("evm_mine", []);
await lendingPool.updateState();
console.log("1 year later");
console.log(`getUserDebt(userJ) ${ethers.formatEther(await lendingPool.getUserDebt(userJ))}`);
console.log(`DebtToken.balanceOf(userJ) ${ethers.formatEther(await debtToken.balanceOf(userJ.address))}`);
console.log("Borrow 1 token");
await lendingPool.connect(userJ).borrow(borrowAmount);
await lendingPool.updateState();
console.log(`getUserDebt(userJ) ${ethers.formatEther(await lendingPool.getUserDebt(userJ))}`);
console.log(`DebtToken.balanceOf(userJ) ${ethers.formatEther(await debtToken.balanceOf(userJ.address))}`);
});
});

Output

Borrow 1 token
getUserDebt(userJ) 1.0
DebtToken.balanceOf(userJ) 1.0
1 year later
getUserDebt(userJ) 1.285229754556329459
DebtToken.balanceOf(userJ) 1.285229754556329459
Borrow 1 token
getUserDebt(userJ) 2.285229754556329459
DebtToken.balanceOf(userJ) 2.651815522578140786

The result demonstrate the issue:

// Initial borrow of 1 token
getUserDebt(userJ) 1.0
DebtToken.balanceOf(userJ) 1.0
// After 1 year
getUserDebt(userJ) 1.285229754556329459
DebtToken.balanceOf(userJ) 1.285229754556329459
// Second borrow of 1 token
getUserDebt(userJ) 2.285229754556329459 // correct balance reported from LendingPool.getUserDebt
DebtToken.balanceOf(userJ) 2.651815522578140786 // this shows the extra tokens minted

Recommendations

  • Update DebtToken.mint to mint the correct amount and return the correct amount

function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
// ...
_mint(onBehalfOf, amount.toUint128());
// ..
return (scaledBalance == 0, amount, totalSupply());
Updates

Lead Judging Commences

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

DebtToken::mint miscalculates debt by applying interest twice, inflating borrow amounts and risking premature liquidations

Support

FAQs

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

Give us feedback!