Core Contracts

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

Users can lose NFT collateral after repaying debt if they don't call closeLiquidation()

Relevant GitHub Links

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L496-L535

Summary

When a user repays their debt during liquidation grace period but fails to call closeLiquidation(), their NFT collateral can still be seized through finalizeLiquidation(), leading to loss of valuable real estate NFTs and incorrect protocol state.

Vulnerability Details

The LendingPool contract allows liquidating users' NFT collateral even after they have fully repaid their debt, if they haven't explicitly called closeLiquidation(). This is possible because finalizeLiquidation() does not verify current debt balance before seizing collateral.

Flow:

  1. User's position gets flagged for liquidation

  2. User repays full debt during grace period via repay()

  3. User fails to call closeLiquidation()

  4. After grace period expires, anyone can call finalizeLiquidation() which will:

  • Transfer NFTs to Stability Pool despite zero debt

  • Process "duplicate" debt settlement from Stability Pool

  • Result in incorrect reserve accounting

This creates severe risk as NFTs represent actual real estate ownership.

Impact

  • direct loss of valuable real estate NFTs

  • Stability Pool pays for already repaid debt and still gets user's NFTs that should remain with the user since debt is repaid

Code Snippet

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);
// No check if debt is already repaid (!)
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;
// Process debt settlement again even if already repaid
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);

Tool Used

Manual Review

Recommendation

Add debt verification in finalizeLiquidation() or automatically close liquidation in repay():

function repay(uint256 amount, address onBehalfOf) external {
// ... existing repay logic ...
if (isUnderLiquidation[onBehalfOf] && user.scaledDebtBalance <= DUST_THRESHOLD) {
isUnderLiquidation[onBehalfOf] = false;
liquidationStartTime[onBehalfOf] = 0;
emit LiquidationClosed(onBehalfOf);
}
}
// OR
function finalizeLiquidation(address userAddress) external {
// ...
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
if (userDebt <= DUST_THRESHOLD) revert DebtAlreadyRepaid();
// ... rest of the function
}
Updates

Lead Judging Commences

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

A borrower can LendingPool::repay to avoid liquidation but might not be able to call LendingPool::closeLiquidation successfully due to grace period check, loses both funds and collateral

Support

FAQs

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

Give us feedback!