Core Contracts

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

Stale Interest Rate Usage Due to Incorrect Order of Operations Causes Unreliable Liquidations

Summary

A critical ordering flaw exists in the liquidation process where interest rate updates occur after debt calculations. The StabilityPool.liquidateBorrower function retrieves borrower debt using stale interest indices from LendingPool.getUserDebt before updating state via LendingPool.updateState. This mismatch between debt calculations and actual accrued interest allows liquidations to operate on outdated financial data, risking failed transactions and protocol insolvency during market volatility. The core issue violates fundamental interest accrual sequencing requirements in lending protocols.

Vulnerability Details

The vulnerability arises from incorrect ordering of state updates and debt calculations in the liquidation process StabilityPool.liquidateBorrower (StabilityPool.sol#L464). The root cause is that interest accrual updates are performed after retrieving the borrower's debt amount, leading to calculations based on stale interest rates.

Key problematic flow:

  1. In StabilityPool.liquidateBorrower:

  • Retrieves debt amount using lendingPool.getUserDebt() before updating state

  • Calls lendingPool.updateState() after debt calculation

  1. LendingPool.getUserDebt uses outdated reserve.usageIndex

  2. ReserveLibrary.updateReserveInterests updates critical interest indices that should precede debt queries

contract StabilityPool is IStabilityPool, Initializable, ReentrancyGuard, OwnableUpgradeable, PausableUpgradeable {
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
// Get the user's debt from the LendingPool.
@> uint256 userDebt = lendingPool.getUserDebt(userAddress);
@> uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
// Approve the LendingPool to transfer the debt amount
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
// Update lending pool state before liquidation
@> lendingPool.updateState();
// Call finalizeLiquidation on LendingPool
lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}
}
contract LendingPool is ILendingPool, Ownable, ReentrancyGuard, ERC721Holder, Pausable {
function updateState() external {
@> ReserveLibrary.updateReserveState(reserve, rateData);
}
function getUserDebt(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
@> return user.scaledDebtBalance.rayMul(reserve.usageIndex);
}
function getNormalizedDebt() external view returns (uint256) {
@> return reserve.usageIndex;
}
}
library ReserveLibrary {
function updateReserveState(ReserveData storage reserve,ReserveRateData storage rateData) internal {
updateReserveInterests(reserve, rateData);
}
function updateReserveInterests(ReserveData storage reserve,ReserveRateData storage rateData) internal {
// ...
// Update liquidity index using linear interest
reserve.liquidityIndex = calculateLiquidityIndex(
rateData.currentLiquidityRate,
timeDelta,
reserve.liquidityIndex
);
// Update usage index (debt index) using compounded interest
@> reserve.usageIndex = calculateUsageIndex(
rateData.currentUsageRate,
timeDelta,
reserve.usageIndex
);
// ...
}
}

This creates a race condition where liquidations use interest rates from the previous accrual period rather than the current state. The miscalculation propagates through:

  • Incorrect scaledUserDebt value used for balance checks

  • Improper CRVUSD approval amount for finalizing liquidation

The vulnerability violates the intended protocol logic where interest accrual should always precede any debt-related operations, as defined in the interest rate model implementation.

Impact

The incorrect interest accrual timing creates two critical failure modes:

  1. Underestimated Debt Liquidation (Interest Rate Increase)
    When outdated usage interest < actual interest:

  • Stability Pool balance check passes using stale lower debt value

  • LendingPool.finalizeLiquidation computes debt with updated higher usageIndex

  • Transaction reverts due to insufficient funds during LendingPool interaction

  • Results in failed liquidations despite passing initial checks

  1. Overestimated Debt Lockup (Interest Rate Decrease)
    When outdated usage interest > actual interest:

  • Stability Pool balance check fails using stale higher debt value

  • Legitimate liquidations prevented despite sufficient actual funds

  • Protocol loses liquidation opportunities during favorable market conditions

The combination creates systemic risk where liquidations become unreliable precisely when needed most (during market volatility), undermining RAAC's core collateral management mechanism.

Tools Used

Manual Review

Recommendations

call LendingPool.updateState to update usage index before calculating the user debt for liquidation check and preparation:

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
+ // Update lending pool state before liquidation
+ lendingPool.updateState();
// Get the user's debt from the LendingPool.
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
// Approve the LendingPool to transfer the debt amount
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
- // Update lending pool state before liquidation
- lendingPool.updateState();
// Call finalizeLiquidation on LendingPool
lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}
Updates

Lead Judging Commences

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

StabilityPool: liquidateBorrower should call lendingPool.updateState earlier, to ensure the updated usageIndex is used in calculating the scaledUserDebt

Support

FAQs

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