Core Contracts

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

Incorrect Debt Token Minting Logic Leading to Mismatch Between Minted and Stored Debt

Summary

The DebtToken::mint function, which includes both the borrowed amount and any accrued interest (balanceIncrease). However, the LendingPool only stores the scaled borrowed amount (amount.rayDiv(reserve.usageIndex)) in the user's scaledDebtBalance, ignoring the balanceIncrease. This mismatch can lead to incorrect debt calculations, undercollateralized positions, and potential protocol insolvency.

Vulnerability Details

The issue arises in the borrow function of the LendingPool contract. When a user borrows funds, the DebtToken::mint function is called in the DebtToken contract, which calculates the total amount to mint as amount + balanceIncrease. However, the LendingPool only stores the scaled borrowed amount (amount.rayDiv(reserve.usageIndex)) in the user's scaledDebtBalance. This discrepancy means that the scaledDebtBalance does not account for the balanceIncrease (accrued interest), leading to an underestimation of the user's debt.

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();
// Update reserve state before borrowing
ReserveLibrary.updateReserveState(reserve, rateData);
// Ensure sufficient liquidity is available
_ensureLiquidity(amount);
// Fetch user's total debt after borrowing
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
// Ensure the user has enough collateral to cover the new debt
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
// 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;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);
// Rebalance liquidity after borrowing
_rebalanceLiquidity();
emit Borrow(msg.sender, amount);
}

DebtToken::mint

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());
}
  1. DebtToken.mint:

    • Mints amount + balanceIncrease as debt tokens.

    • balanceIncrease represents accrued interest due to index updates.

  2. LendingPool.borrow:

    • Stores only amount.rayDiv(reserve.usageIndex) in user.scaledDebtBalance.

    • Ignores the balanceIncrease component.

This inconsistency can cause the protocol to miscalculate user debt, leading to:

  • Undercollateralized positions.

  • Incorrect health factor calculations.

POC (Proof of Concept)

  1. Initial Conditions:

    • reserve.usageIndex = 1e27 (RAY).

    • User has no existing debt.

    • User borrows 100 units of the reserve asset.

  2. DebtToken.mint:

    • amount = 100.

    • balanceIncrease = 0 (since it's the first borrow).

    • amountToMint = 100 + 0 = 100.

    • Debt tokens minted: 100.

  3. LendingPool.borrow:

    • scaledAmount = 100.rayDiv(1e27) = 100e18.

    • user.scaledDebtBalance updated to 100e18.

  4. Interest Accrual:

    • Over time, reserve.usageIndex increases to 2e27. @>>> just for better explanation

    • User borrows another 100 units.

  5. DebtToken.mint:

    • amount = 100.

    • balanceIncrease = 100e18 * (2e27 - 1e27) / 1e27 = 100e18.

    • amountToMint = 100 + 100 = 200.

    • Debt tokens minted: 200.

  6. LendingPool.borrow:

    • scaledAmount = 100.rayDiv(2e27) = 50e18.

    • user.scaledDebtBalance updated to 100e18 + 50e18 = 150e18.

  7. Result:

    • Actual debt: 150e18 * 2e27 / 1e27 = 300.

    • Debt tokens minted: 200 (from first borrow) + 200 (from second borrow) = 400.

    • Mismatch between actual debt (300) and debt tokens minted (400).

Impact

The total debt tracked by the protocol (reserve.totalUsage) becomes inaccurate. Unrecorded interest creates a deficit between actual debt (accrued in DebtToken) and recorded debt (in LendingPool). This mismatch can lead to systemic insolvency if liquidations fail to cover the gap.

Health factor calculations rely on user.scaledDebtBalance, which excludes accrued interest. This results in false-positive safe positions, delaying liquidations until losses are irrecoverable.

Example:

  • User borrows 100 CRV when usageIndex = 1e27 (no interest).

  • DebtToken mints 100 debt tokens.

  • Later, interest accrues (usageIndex = 1.1e27).

  • User borrows again:

    • balanceIncrease = 10 CRV (accrued interest).

    • DebtToken mints 110 CRV debt.

    • But LendingPool only records 100 CRV in scaledDebtBalance.

  • Collateral checks use 100 CRV instead of 110 CRV, enabling over-leverage.

Result:

  • Positions are undercollateralized by 10%.

  • Protocol’s actual debt exceeds recorded debt, risking insolvency.

  • Liquidations trigger too late, amplifying losses.

Tools Used

Manual Review

Recommendations

  1. Update LendingPool.borrow:

    • Store the total scaled debt (amountToMint.rayDiv(reserve.usageIndex)) in user.scaledDebtBalance.

    • Ensure the scaledDebtBalance accounts for both the borrowed amount and the balanceIncrease.

  2. Refactor Debt Calculation:

    • Use the amountToMint value returned by DebtToken.mint to update the user's debt balance.

  3. Add Validation:

    • Implement checks to ensure the scaledDebtBalance matches the actual debt calculated from the DebtToken contract.

Updates

Lead Judging Commences

inallhonesty Lead Judge 6 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.