Target
contracts/core/pools/LendingPool/LendingPool.sol
contracts/core/tokens/DebtToken.sol
Summary
The lending pool maintains its own internal accounting of users debt balances which is updated during borrows and repays, however due to the difference in debt token mint calculation logic and the accounting within the lending pool, these values can deviate leading to situations where the lenders debt position is unduly inflated or deflated causing loss to borrowers or the protocol depending on the state of the protocol.
Vulnerability Details
When the borrow function is called on the lending pool, the lending pool passes control to the debt token contract to mint debt tokens (relative to the amount argument) to the user, within the mint function of the DebtToken contract, a check is perform to account any balance increase between the last index and the current index, if there is any increase, the increment is added to the amount passed in by the user before the _mint function is executed using the sum as the argument.
function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
if (user == address(0) || onBehalfOf == address(0)) revert InvalidAddress();
if (amount == 0) {
return (false, 0, totalSupply());
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
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;
_mint(onBehalfOf, amountToMint.toUint128());
emit Transfer(address(0), onBehalfOf, amountToMint);
emit Mint(user, onBehalfOf, amountToMint, balanceIncrease, index);
return (scaledBalance == 0, amountToMint, totalSupply());
}
DebtToken.mint
on the other hand, within the lending pool contract, the internal accounting user.scaledDebtBalance is updated by adding the scaled output of the user input amount without considering any potential increments between the old and new indices.
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();
UserData storage user = userData[msg.sender];
uint256 collateralValue = getUserCollateralValue(msg.sender);
if (collateralValue == 0) revert NoCollateral();
ReserveLibrary.updateReserveState(reserve, rateData);
_ensureLiquidity(amount);
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
reserve.totalUsage = newTotalSupply;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);
_rebalanceLiquidity();
emit Borrow(msg.sender, amount);
}
LendingPool.borrow
Impact
This would introduce deviations introducing errors during interest rate, usage rate and repayment calculations causing users to overpay or underpay debts
Tools Used
Manual Review
Recommendations
Ensure that the internal debt accounting across the protocol is consistent and accurate, consider implementing fuzzing to verify this invariant.
POC
Add test script to test/unit/core/pools/LendingPool/LendingPool.test.js
describe("POC tests", function(){
it("should check accurate mint info", async function () {
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await lendingPool.connect(user2).deposit(depositAmount);
await raacHousePrices.setHousePrice(2, ethers.parseEther("100"));
await raacHousePrices.setHousePrice(3, ethers.parseEther("100"));
const amountToPay = ethers.parseEther("200");
await crvusd.mint(user1.address, amountToPay);
await crvusd.connect(user1).approve(raacNFT.target, amountToPay);
let allowance = await crvusd.allowance(user1.address, raacNFT.target);
console.log('allowance ' + allowance);
await raacNFT.connect(user1).mint(2, ethers.parseEther("100"));
await raacNFT.connect(user1).mint(3, ethers.parseEther("100"));
await raacNFT.connect(user1).approve(lendingPool.target, 2);
await raacNFT.connect(user1).approve(lendingPool.target, 3);
await lendingPool.connect(user1).depositNFT(2);
await lendingPool.connect(user1).depositNFT(3);
const borrowAmount = ethers.parseEther("50");
await ethers.provider.send("evm_increaseTime", [1 * 24 * 60 * 60]);
await lendingPool.connect(user1).borrow(borrowAmount);
await ethers.provider.send("evm_increaseTime", [1 * 24 * 60 * 60]);
await lendingPool.connect(user1).borrow(borrowAmount);
await ethers.provider.send("evm_increaseTime", [1 * 24 * 60 * 60]);
await lendingPool.connect(user1).borrow(borrowAmount);
let r = await lendingPool.reserve();
console.log('Usage index : ' + r.usageIndex);
let userdatabefore = await lendingPool.userData(user1.address);
let totalsupplybefore = await debtToken.totalSupply()
await lendingPool.connect(user1).borrow(borrowAmount);
let userdataafter = await lendingPool.userData(user1.address);
let totalsupplyafter = await debtToken.totalSupply()
console.log('user scaled balance change ' + (Number(userdataafter.scaledDebtBalance) - Number(userdatabefore.scaledDebtBalance)).toString());
console.log('total supply scaled change ' + (Number(totalsupplyafter) - Number(totalsupplybefore)).toString());
});
})