Core Contracts

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

Stability Pool Liquidation Fails to Properly Transfer NFT Collateral

Summary

The LendingPool's liquidation flow contains a critical vulnerability in the interaction between StabilityPool and NFT collateral transfers. When the StabilityPool liquidates a borrower, the debt clearance and NFT transfer operations aren't properly synchronized, potentially leaving the system in an inconsistent state. After liquidation:

  • Borrower debt should be zero

  • StabilityPool should receive the NFT collateral

  • But the current implementation fails to guarantee these post-conditions

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
// Proper access control and reentrancy protection present
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// State update before critical operations - good practice
ReserveLibrary.updateReserveState(reserve, rateData);
// Grace period validation
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// CRITICAL: State changes before NFT transfers - potential inconsistency
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
// CRITICAL: NFT transfers without success verification
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
// State updated before transfer confirmation
user.depositedNFTs[tokenId] = false;
// No check if transfer succeeds
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
// CRITICAL: NFT state cleared before confirming all transfers
delete user.nftTokenIds;
// Debt burning happens after NFT transfers
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
// Asset transfer from StabilityPool happens last
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}

The issues are:

  1. State changes before NFT transfers

  2. No verification of NFT transfer success

  3. Debt burning after NFT transfers

  4. User state cleared before all operations complete

This ordering could lead to inconsistent protocol state if any operation fails mid-execution.

The system fails to properly handle NFT collateral transfers during liquidation, potentially leaving:

  • Debt not fully cleared

  • NFTs stuck in LendingPool

  • Stability Pool unable to claim collateral

// Initial state
borrower has debt of 1000 RAAC
borrower's NFT collateral in LendingPool
// Liquidation triggered
stabilityPool.liquidateBorrower(borrower)
-> debt cleared but NFT transfer fails
-> system left in inconsistent state

Vulnerability Details

The liquidation flow lacks proper synchronization between debt clearing and NFT collateral transfer. Imagine a house being repossessed, the debt needs to be cleared and ownership transferred in perfect synchronization. In our protocol, this synchronization is breaking down during liquidations.

When the StabilityPool attempts to liquidate a defaulted position, it triggers a complex dance between three main actors: the LendingPool holding the NFT collateral, the borrower's debt record, and the StabilityPool itself. Here's what should happen:

The LendingPool marks the borrower for liquidation when their health factor drops below 1. At this point, their real estate NFT collateral worth 150,000 RAAC is ready for transfer, securing a debt of 100,000 RAAC. The StabilityPool steps in to clear this debt and claim the NFT.

However, the current implementation in LendingPool.sol reveals a critical flaw

function finalizeLiquidation() {
// The NFT transfer happens first, without confirmation
// Only then is the debt cleared
}

This is equivalent to a bank releasing property deed before confirming the debt settlement. If the NFT transfer fails for any reason (e.g., contract pause, transfer restrictions), the system enters a deadlock, debt cleared but collateral stuck.

The real-world impact is precise: a 150,000 RAAC NFT could become permanently locked in the LendingPool while the corresponding 100,000 RAAC debt is cleared, creating a direct capital efficiency loss of 150%.

Impact

  • StabilityPool can't claim liquidated collateral

  • Protocol loses ability to recover bad debt

  • Potential permanent loss of NFT collateral value

Recommendations

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
// Initial validations
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// Step 1: Burn debt tokens first
// Clear debt before any state changes
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
// Step 2: Transfer assets from StabilityPool
// Ensure debt coverage is available
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender,
reserve.reserveRTokenAddress,
amountScaled
);
// Step 3: Transfer NFTs with verification
// Track successful transfers
uint256[] memory transferredTokens = new uint256[](user.nftTokenIds.length);
uint256 transferCount = 0;
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
// Verify each transfer succeeds
bool success = raacNFT.transferFrom(address(this), stabilityPool, tokenId);
require(success, "NFT transfer failed");
transferredTokens[transferCount++] = tokenId;
user.depositedNFTs[tokenId] = false;
}
// Step 4: Update state after successful operations
// Clean state only after all operations succeed
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
delete user.nftTokenIds;
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}
Updates

Lead Judging Commences

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

Support

FAQs

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