Core Contracts

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

LACK OF INCENTIVES FOR LIQUIDATING SMALL POSITIONS IN `LendingPool` AND `StabilityPool`.

Summary

A vulnerability stems from the lendingPool and stabilityPool contracts where there is no effective incentive mechanism to encourage the liquidation of small positions. These small positions become uneconomical to liquidate due to high gas costs relative to potential profit. This could lead to unliquidatable positions that accrue interest indefinately, posing a risk of accumulating bad debt and threatening the protocol's solvency. The current liquidation process lacks an explicit bonus, and an apparent profit from transferring scaled amounts is a bug rather than a designed incentive.

Vulnerability Details

The LendingPool contract allows users to deposit NFTs of any value as collateral and borrow against them without a minimum size requirement. The liquidation process, executed via finalizeLiquidation and triggered by StabilityPool.liquidateBorrower, involves the StabilityPool paying the user's debt in crvUSD and receiving the NFTs as collateral. However no additional reward or incentive is attached to offset the gas cost for liquidation, particularly for small positions.

`LendingPool.finalizeLiquidation`
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L496-L536
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
// Transfer NFTs to Stability Pool
@> for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
delete user.nftTokenIds;
// 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;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}

. Transfers amountScaled (incorrectly less than user's debt) and NFTs with no bonus.
. should transfer userDebt to maintain solvency.

`StabilityPool.liquidateBorrower`
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/StabilityPool/StabilityPool.sol#L449-L471
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); //@audit: Incorrect scalling
if (!approveSuccess) revert ApprovalFailed();
// Update lending pool state before liquidation
lendingPool.updateState();
// Call finalizeLiquidation on LendingPool
lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}
}

. No logic to claim or calculate a liquidation incentive
. Approves an inflated scaledUserDebt instead of userDebt.

Impact

  1. Small positions remain open as liquidation cost exceed rewards accruing intrest via reserve.usageIndex and increasing DebtTokenBalances.

  2. Over-collateralised debt grows, potentially leading to the protocol's insolvency.

Tools Used

Manual review

Recommendations

  1. Add a liquidation bonus in LendingPool

  2. In finalizeLiquidation, transfer userDebt instead of amountScaled to maintain protocol solvency.
    In liquidateBorrower, approve userDebt instead of scaledUserDebt.

  3. Add checks in depositNFT to avoid low-value NFTs.

Updates

Lead Judging Commences

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

Give us feedback!