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:
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]);
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:
getUserDebt(userJ) 1.0
DebtToken.balanceOf(userJ) 1.0
getUserDebt(userJ) 1.285229754556329459
DebtToken.balanceOf(userJ) 1.285229754556329459
getUserDebt(userJ) 2.285229754556329459
DebtToken.balanceOf(userJ) 2.651815522578140786
Recommendations
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());