Core Contracts

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

Debt is incorrectly tracked in LendingPool contract

Summary

LendingPool contract tracks position debt incorrectly such that the accrued interest is not taken into account, so the tracked debt is less than the actual accumulated debt

Vulnerability Details

When an user calls LendingPool::borrow(), the function DebtToken::mint() is called to handles to interest accrual logic and also mint the DebtToken for the user. The mint amount takes into account the balanceIncrease which is the user position's interest accrued since last usage index.

The problem arises when the accrued interest is not taken into account in the function LendingPool::borrow(), such that user scaled debt is increased user.scaledDebtBalance += scaledAmount when scaledAmount = amount.rayDiv(reserve.usageIndex). Note that this scaledAmount does not include interest. As a result, the mint amount of DebtToken includes interest, but LendingPool's user.scaledDebtBalance does not include interest.

This can cause the value user.scaledDebtBalance less than expected. It also leads to scenario that borrowers can not repay total debt because arithmetic underflow happens in LendingPool::_repay().

// LendingPool
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
...
// Update user's scaled debt balance
@> uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
// Mint DebtTokens to the user (scaled amount)
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
// Transfer borrowed amount to user
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
@> user.scaledDebtBalance += scaledAmount;
// reserve.totalUsage += amount;
reserve.totalUsage = newTotalSupply;
...
}
// LendingPool
function _repay(uint256 amount, address onBehalfOf) internal {
...
// Burn DebtTokens from the user whose debt is being repaid (onBehalfOf)
// is not actualRepayAmount because we want to allow paying extra dust and we will then cap there
@> (uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// Transfer reserve assets from the caller (msg.sender) to the reserve
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
@> user.scaledDebtBalance -= amountBurned; // <--- underflow here
...
}
/// DebtToken
function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
...
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());
...
}

PoC

Add this test to test/unit/core/pools/LendingPool/LendingPool.test.js:

describe("Borrow and Repay", function () {
...
it.only("test debt tracked", async function(){
// @audit PoC debt is tracked less than actual
const collateralValue = await lendingPool.getUserCollateralValue(user1.address);
// total amount that user can borrow based on collateral value
const totalBorrowable = collateralValue * 100n / 80n;
const borrowAmount = totalBorrowable / 3n;
// make many borrows at different time to accrue debt
await lendingPool.connect(user1).borrow(borrowAmount);
await ethers.provider.send("evm_increaseTime", [90 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
await lendingPool.connect(user1).borrow(borrowAmount);
await ethers.provider.send("evm_increaseTime", [90 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
await lendingPool.connect(user1).updateState();
let userData = await lendingPool.userData(user1.address);
let reserveData = await lendingPool.reserve();
let ray = ethers.parseUnits("1", 27);
// total debt computed by LendingPool
let userTotalDebt = (userData[0] * reserveData[6] + (ray/2n)) / ray
// total debt tracked by DebtToken
let debt = await debtToken.balanceOf(user1.address);
expect(debt).to.not.eq(userTotalDebt); // PASS here
// borrow the remaining borrowable
await lendingPool.connect(user1).borrow(totalBorrowable - userTotalDebt);
// total debt tracked by DebtToken
debt = await debtToken.balanceOf(user1.address);
// total debt > total borrowable because debt accrued
expect(debt).to.gt(totalBorrowable) // PASS here
// can not repay total debt due to arithmetic underflow
await lendingPool.connect(user1).repay(debt);
})

Run the test and it failed:

0 passing (2s)
1 failing
1) LendingPool
Borrow and Repay
test debt tracked:
Error: VM Exception while processing transaction: reverted with panic code 0x11 (Arithmetic operation overflowed outside of an unchecked block)
at LendingPool._repay (contracts/core/pools/LendingPool/LendingPool.sol:458)
at LendingPool.repay (contracts/core/pools/LendingPool/LendingPool.sol:404)
...

Impact

  • Debt is wrongly tracked

  • Borrowers will be unable to repay position total debt (principal + interest)

Tools Used

Manual

Recommendations

- user.scaledDebtBalance += scaledAmount;
+ user.scaledDebtBalance += amountMinted;
Updates

Lead Judging Commences

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

LendingPool::borrow tracks debt as user.scaledDebtBalance += scaledAmount while DebtToken mints amount+interest, leading to accounting mismatch and preventing full debt repayment

Support

FAQs

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