Core Contracts

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

Health Factor Staleness in LendingPool::calculateHealthFactor()

Summary

The calculateHealthFactor() function uses an outdated reserve.usageIndex to compute users’ health factors, potentially leading to inaccurate assessments of their financial positions. This staleness arises because the function does not update the reserve state or account for accrued interest since the last state update. As a result, users may face incorrect liquidations or avoid necessary liquidations

Vulnerability Details

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L545

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L579

The calculateHealthFactor() function in LendingPool.sol is defined as:

function calculateHealthFactor(address userAddress) public view returns (uint256) {
uint256 collateralValue = getUserCollateralValue(userAddress);
uint256 userDebt = getUserDebt(userAddress);
if (userDebt < 1) return type(uint256).max;
uint256 collateralThreshold = collateralValue.percentMul(liquidationThreshold);
return (collateralThreshold * 1e18) / userDebt;
}

This function relies on getUserDebt(), which calculates user debt as:

function getUserDebt(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
return user.scaledDebtBalance.rayMul(reserve.usageIndex);
}

The reserve.usageIndex represents the compounded interest rate applied to variable debt balances. But, reserve.usageIndex is updated only when updateReserveState() (via ReserveLibrary) is called during state-modifying operations like (e.g., borrow(), repay(), initiateLiquidation()). As a view function, calculateHealthFactor() cannot modify state and thereby uses the usageIndex from the last state update, stored in reserve.lastUpdateTimestamp.

If significant time passes between state updates, usageIndex becomes stale, underestimating the user’s debt (userDebt) because it does not reflect accrued interest. This staleness can lead to:

  • An overestimated health factor (if debt is underestimated), delaying liquidation when needed

  • An underestimated health factor (if collateral is stale—see related on my report on getNFTPrice() ), triggering unnecessary liquidations.

Impact

  • Users with risky positions might avoid liquidation if their debt is underestimated due to a stale usageIndex

  • Users with healthy positions might be flagged for liquidation if their health factor is underestimated due to stale collateral values (from getUserCollateralValue()), combined with an accurate or underestimated debt. This could result in unfair liquidations and loss of user funds.

Tools Used

Manual Review

Recommendations

  • Modify getUserDebt() to compute the current usageIndex dynamically based on the time elapsed since reserve.lastUpdateTimestamp, without modifying state. This leverages existing logic in ReserveLibrary.getNormalizedDebt()

function getUserDebt(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
uint256 currentUsageIndex = ReserveLibrary.getNormalizedDebt(reserve, rateData).rayDiv(reserve.totalUsage);
return user.scaledDebtBalance.rayMul(currentUsageIndex);
}
  • Add a warning or fallback mechanism in calculateHealthFactor() to indicate potential staleness if reserve.lastUpdateTimestamp is too old, while keeping it view

function calculateHealthFactor(address userAddress) public view returns (uint256, bool) {
uint256 collateralValue = getUserCollateralValue(userAddress);
uint256 userDebt = getUserDebt(userAddress);
if (userDebt < 1) return (type(uint256).max, true);
uint256 collateralThreshold = collateralValue.percentMul(liquidationThreshold);
uint256 healthFactor = (collateralThreshold * 1e18) / userDebt;
bool isFresh = block.timestamp - reserve.lastUpdateTimestamp <= MAX_STATE_AGE;
return (healthFactor, isFresh);
}

Add a constant MAX_STATE_AGE (e.g., 1 hours) to define acceptable staleness. This allowscallers to assess freshness and trigger state updates if needed.

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

LendingPool::calculateHealthFactor uses stale usageIndex without accounting for accrued interest since last update, leading to inaccurate health factors and potentially unfair liquidations

Everywhere where the protocol consumes the return of this function the ReserveLibrary state is updated before the actual call.

Support

FAQs

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