Core Contracts

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

Incorrect Double-Scaling of Debt in `StabilityPool.liquidateBorrower` Causes Failed Liquidations and Protocol Insolvency Risk

Summary

A critical scaling error in the liquidation process causes the StabilityPool to use doubly-compounded debt values, leading to failed liquidations and protocol insolvency risk. The StabilityPool.liquidateBorrower function incorrectly applies interest scaling twice when:

  1. Incorrectly scaling debt twice by multiplying getUserDebt() (already interest-adjusted) with getNormalizedDebt() (the same interest index)

  2. Checking CRVUSD balances against this over-inflated debt

  3. Approving transfers using the incorrect scaled amount

This forces the StabilityPool to hold excess funds for liquidations beyond actual requirements, allowing undercollateralized positions to persist during market stress. The error fundamentally undermines the protocol's risk management system by preventing valid liquidations when most needed.

Vulnerability Details

The vulnerability arises from an incorrect debt calculation in the liquidation process that uses doubly-scaled debt values. The key issue occurs in StabilityPool.liquidateBorrower (StabilityPool.sol#L452-L462) where:

  1. Double Scaling of Debt:

  • userDebt is retrieved from LendingPool.getUserDebt which already returns scaled debt (scaledDebtBalance * usageIndex)

  • This value is then incorrectly scaled again by multiplying with LendingPool.getNormalizedDebt (which returns the same usageIndex)

  1. Insufficient Balance Check:

  • StabilityPool.liquidateBorrower checks CRVUSD balance against this doubly-scaled debt value rather than the actual debt amount

  1. Improper Approval:

  • Approves the doubly-scaled value for transfer instead of the actual debt amount

The root cause stems from misunderstanding the return values of LendingPool functions:

  • getUserDebt() returns already scaled debt (rayMul(scaledDebtBalance, usageIndex))

  • getNormalizedDebt() returns the same usage index used in the first scaling

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 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;
}
}

This results in debt being scaled twice (userDebt * usageIndex²) when it should only be scaled once. The proper debt amount to use is the initial userDebt value returned from getUserDebt(), as the subsequent operations in LendingPool.finalizeLiquidation() and DebtToken.burn() expect the single-scaled value:

contract LendingPool is ILendingPool, Ownable, ReentrancyGuard, ERC721Holder, Pausable {
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
// ...
UserData storage user = userData[userAddress];
@> uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// ...
// Burn DebtTokens from the user
@> (uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) = IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
// Transfer reserve assets from Stability Pool to cover the debt
@> IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
// Update user's scaled debt balance
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
// ...
}
}
contract DebtToken is ERC20, ERC20Permit, IDebtToken, Ownable {
function burn(
address from,
@> uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
// ...
_burn(from, amount.toUint128());
emit Burn(from, amountScaled, index);
@> return (amount, totalSupply(), amountScaled, balanceIncrease);
}
}

Under normal protocol operation where usageIndex > 1 (indicating accrued interest), this creates a dangerous mismatch:

  • Example Scenario: With usageIndex = 1.1e27 (10% interest) and actual debt of 100 CRVUSD:

    • Correct debt: 100 * 1.1 = 110 CRVUSD

    • Current calculation: 100 * 1.1 * 1.1 = 121 CRVUSD

This forces the StabilityPool to hold 21% more CRVUSD than actually needed for liquidation. Valid liquidations will fail unnecessarily when the pool has sufficient (but not excess) funds, while protocol-level debt continues accumulating.

The error directly undermines the protocol's liquidation safety mechanism, creating systemic risk by allowing undercollateralized positions to persist unaddressed. This could lead to cascading insolvency if multiple positions enter liquidation simultaneously during market stress.

Impact

This vulnerability creates a critical failure in the protocol's liquidation mechanism with severe consequences:

  1. Failed Liquidations During Market Stress
    When the utilization index > 1 (normal operation with accrued interest), the StabilityPool will reject valid liquidations unless it holds excess CRVUSD beyond actual debt obligations. This allows undercollateralized positions to persist, accumulating more risk over time.

  2. Protocol Insolvency Risk
    Unliquidated bad debt directly threatens protocol solvency. As interest compounds on unresolved positions, the system's liability grows exponentially while collateral values remain static.

  3. Cascading Liquidation Failures
    A single failed liquidation creates a domino effect - the unresolved debt reduces available liquidity, increasing the likelihood of subsequent liquidation failures during market downturns.

The vulnerability fundamentally breaks a critical safety mechanism designed to maintain protocol health, creating existential risk during periods of market volatility.

Tools Used

Manual Review

Recommendations

Fix Redundant Scaling in StabilityPool.liquidateBorrower:

function liquidateBorrower(address userAddress) external ... {
// ...
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();
+ if (crvUSDBalance < userDebt) revert InsufficientBalance();
- bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
+ bool approveSuccess = crvUSDToken.approve(address(lendingPool), userDebt);
// ...
- emit BorrowerLiquidated(userAddress, scaledUserDebt);
+ emit BorrowerLiquidated(userAddress, userDebt);
}
Updates

Lead Judging Commences

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

StabilityPool::liquidateBorrower double-scales debt by multiplying already-scaled userDebt with usage index again, causing liquidations to fail

Support

FAQs

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