Core Contracts

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

Stability Pool Liquidation Accounting Mismatch When Scaled Meets Unscaled

Summary

StabilityPool's liquidation flow contains a critical accounting error where the debt repayment amount doesn't match the actual debt cleared. When liquidating a borrower, the contract transfers the scaled user debt but updates state with the unscaled amount, leading to accounting inconsistencies.

When a borrower is liquidated:

  1. Their debt should be cleared (set to 0)

  2. The StabilityPool balance should decrease by exactly the scaled debt amount

Looking at the code flow:

function liquidateBorrower(address userAddress) external {
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
// Transfer scaled amount
crvUSDToken.approve(address(lendingPool), scaledUserDebt);
lendingPool.finalizeLiquidation(userAddress);
}

The issue arises because:

  1. The StabilityPool calculates the scaled debt correctly using normalized debt

  2. But when calling finalizeLiquidation(), the LendingPool clears the user's raw debt instead of scaled debt

  3. This creates a mismatch between tokens transferred and debt cleared

The root cause is in the debt accounting during liquidation, the StabilityPool pays the scaled amount but the LendingPool clears the unscaled amount.

Vulnerability Details

The protocol's stability relies heavily on precise accounting between the StabilityPool and LendingPool during liquidations. However, I've identified a critical accounting mismatch in this interaction.

When a borrower faces liquidation, the StabilityPool steps in to handle debt repayment. The process seems straightforward, calculate the debt, scale it appropriately, and clear it. However, i notice how the scaling creates a unforsen divergence.

And this is what happens, the StabilityPool correctly calculates the scaled debt using ray math (multiplication with high precision): function liquidateBorrower

function liquidateBorrower(address userAddress) external {
// FLS1: Calculate scaled debt with ray math precision
uint256 userDebt = lendingPool.getUserDebt(userAddress);
// KP: Debt gets scaled up using normalized debt rate
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
// FLS2: Verify StabilityPool has enough funds
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
// FLS3: Approve SCALED amount transfer
// CRIT: We're approving the scaled amount here
crvUSDToken.approve(address(lendingPool), scaledUserDebt);
// FLS4: Trigger liquidation with scaled debt
lendingPool.finalizeLiquidation(userAddress);
// IMPORTANT: Event emits scaled debt amount
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}

This scaled amount is what gets transferred to the LendingPool. However, the LendingPool's finalizeLiquidation function operates on raw debt values: function finalizeLiquidation

function finalizeLiquidation(address userAddress) external {
// FLST5: Calculate user's current debt
UserData storage user = userData[userAddress];
// IMPORTANT: This calculates debt differently than StabilityPool
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// FLST6: Handle NFT collateral transfer
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
// FLS7: Burn debt tokens
// CRITICAL MISMATCH: Burns using userDebt but receives scaledUserDebt
(uint256 amountScaled,,uint256 amountBurned,) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(
userAddress,
userDebt, // Using unscaled debt amount
reserve.usageIndex
);
// FLST8: Transfer scaled amount from StabilityPool
// But amount transferred doesn't match amount burned
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender,
reserve.reserveRTokenAddress,
amountScaled
);
}

You can see here we're mixing scaled and unscaled values. Think of it like paying a $100 debt with €100, the numbers match but the actual value doesn't.

Which sims means that the StabilityPool could end up paying more or less than the actual debt being cleared, breaking the core invariant that debt cleared must equal debt paid. In a protocol handling real estate value, such accounting mismatches could cascade into larger stability issues.

Impact

As the main issue emerges during liquidation flows. When a borrower defaults on their RAAC-backed loan, the StabilityPool steps in to handle debt repayment. The protocol calculates debt in two different ways

// StabilityPool uses ray math for precise scaling
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
crvUSDToken.approve(address(lendingPool), scaledUserDebt);

This means the StabilityPool transfers the precisely scaled amount. However, when we look at how the LendingPool handles this

function finalizeLiquidation(address user) external {
// Simply clears raw debt without scaling
userDebt[user] = 0;
}

This creates a fundamental mismatch, imagine paying a $150 debt with €100. The numbers might look similar, but the actual value transfer is incorrect. In RAAC's case, this could lead to the StabilityPool consistently overpaying or underpaying liquidations, directly impacting the protocol's ability to maintain stable real estate backing.

Tools Used

manual

Recommendations

  1. Either scale the transfer amount down to match the raw debt

  2. Or update the LendingPool to clear the scaled debt amount

This vulnerability could lead to accounting errors in the protocol's debt tracking system, potentially affecting the overall stability mechanism.

The issue is more complex than initially suggested

function liquidateBorrower(address userAddress) external {
// CORRECT: Properly scales debt using normalized rate ✓
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
// CORRECT: Transfers scaled amount ✓
crvUSDToken.approve(address(lendingPool), scaledUserDebt);
lendingPool.finalizeLiquidation(userAddress);
}
function finalizeLiquidation(address userAddress) external {
UserData storage user = userData[userAddress];
// ! CRITPOINT 1: Already using scaled debt balance
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// ! CRITPOINT 2: Burn function handles scaling internally
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, ) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(
userAddress,
userDebt,
reserve.usageIndex
);
// ! CRITPOINT 3: Updates with burned amount
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
}

Our fix needs to focus on:

  1. Ensuring the burn amount matches the scaled debt received

  2. Maintaining consistency between StabilityPool's scaledUserDebt and LendingPool's amountBurned

  3. Properly updating reserve.totalUsage with the scaled values

Fix structure

function finalizeLiquidation(address userAddress) external {
UserData storage user = userData[userAddress];
// Calculate final scaled debt
uint256 scaledDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// Burn exact scaled amount
(uint256 amountScaled, uint256 newTotalSupply, ,) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(
userAddress,
scaledDebt, // Use scaled debt directly
reserve.usageIndex
);
// Update state with scaled values
user.scaledDebtBalance = 0;
reserve.totalUsage = newTotalSupply;
}

This way we maintain consistency between debt scaling across both pools while properly handling the ray math calculations.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!