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 {
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);
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
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;
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
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:
State changes before NFT transfers
No verification of NFT transfer success
Debt burning after NFT transfers
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:
borrower has debt of 1000 RAAC
borrower's NFT collateral in LendingPool
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() {
}
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 {
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);
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender,
reserve.reserveRTokenAddress,
amountScaled
);
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];
bool success = raacNFT.transferFrom(address(this), stabilityPool, tokenId);
require(success, "NFT transfer failed");
transferredTokens[transferCount++] = tokenId;
user.depositedNFTs[tokenId] = false;
}
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));
}