Core Contracts

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

Liquidation Finalization Allows Debt Persistence After NFT Transfer

Summary

The liquidation finalization process in the LendingPool contract has a flaw in the state management between debt clearing and NFT collateral transfers. When finalizing liquidations, the contract allows NFT transfers before properly clearing associated debt, creating potential value extraction opportunities.

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
// Basic validation checks
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// State flags cleared before debt settlement
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
// NFTs transferred before debt settlement
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;
// Debt settlement happens after NFT transfer
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
// Asset transfer happens last, creating potential state inconsistency
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 issue is the non-atomic sequence of operations: liquidation flags are cleared and NFTs are transferred before the debt is actually settled and assets are transferred. This creates multiple points where the system state could become inconsistent if any of the later operations fail.

Vulnerability Details

The order of operations in finalizeLiquidation allows a state where NFTs are transferred before debt is fully cleared. "Imagine a house being foreclosed, but the bank transfers the deed before clearing the mortgage. That's exactly what's happening in the RAAC protocol's liquidation process. When the StabilityPool finalizes a liquidation, the valuable NFT collateral moves before the borrower's debt record is cleared."

The story unfolds in three acts:

First, a borrower defaults on their loan, triggering the liquidation grace period of 3 days (defined as BASE_LIQUIDATION_GRACE_PERIOD). During this time, their NFT-backed real estate collateral sits in limbo within the LendingPool.

Next, once the grace period expires, anyone can call finalizeLiquidation(). Here's where things get interesting, the function transfers the NFT collateral to the StabilityPool immediately, but the crucial debt clearing operation happens in a separate, non-atomic step.

Finally, this sequence creates a dangerous window where the protocol's accounting becomes misaligned. The StabilityPool holds the NFT collateral while the LendingPool still shows outstanding debt for the borrower. In concrete terms, a $500,000 house-backed NFT could be transferred while its associated debt remains recorded, effectively double-counting the collateral value in the system.

Impact

The liquidation process transfers NFTs to the StabilityPool but may not properly clear user debt, creating a mismatch between collateral and debt state.

Recommendations

The fix requires treating the NFT transfer and debt clearing as a single atomic operation, much like how a real estate closing handles both deed transfer and mortgage settlement simultaneously. Here's how:

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
// Initial validations remain unchanged
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
ReserveLibrary.updateReserveState(reserve, rateData);
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
// Step 1: Transfer reserve assets first to ensure debt can be cleared
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender,
reserve.reserveRTokenAddress,
userDebt
);
// Step 2: Clear debt
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, ) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(
userAddress,
userDebt,
reserve.usageIndex
);
// Step 3: Only after debt is cleared, transfer NFTs
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
// Step 4: Clean up state
user.scaledDebtBalance = 0;
reserve.totalUsage = newTotalSupply;
isUnderLiquidation[userAddress] = false;
delete user.nftTokenIds;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, 0);
}

This approach ensures the protocol maintains perfect alignment between collateral ownership and debt records, just as traditional finance demands for secured lending.

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.