Summary
The vulnerability allows the Stability Pool to liquidate users whose positions have become healthy due to collateral value appreciation during the liquidation grace period. This occurs because the protocol does not revalidate the health factor before finalizing the liquidation, leading to unfair liquidations of positions that no longer meet the criteria.
Vulnerability Details
Users who have deposited RAACNft's into the protocol are allowed to borrow assets from the rToken vault via the LendingPool::borrow
* @notice Allows a user to borrow reserve assets using their NFT collateral
* @param amount The amount of reserve assets to borrow
*/
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();
UserData storage user = userData[msg.sender];
uint256 collateralValue = getUserCollateralValue(msg.sender);
if (collateralValue == 0) revert NoCollateral();
ReserveLibrary.updateReserveState(reserve, rateData);
_ensureLiquidity(amount);
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
reserve.totalUsage = newTotalSupply;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);
_rebalanceLiquidity();
emit Borrow(msg.sender, amount);
}
As expected, a health factor is measured which calculates whether the user's position is healthy or unhealthy. Users with unhealthy positions can be liquidated by any user calling LendingPool::initiateLiquidation which starts the liquidation process .
**
* @notice Allows anyone to initiate the liquidation process if a user's health factor is below threshold
* @param userAddress The address of the user to liquidate
*/
function initiateLiquidation(address userAddress) external nonReentrant whenNotPaused {
if (isUnderLiquidation[userAddress]) revert UserAlreadyUnderLiquidation();
ReserveLibrary.updateReserveState(reserve, rateData);
UserData storage user = userData[userAddress];
uint256 healthFactor = calculateHealthFactor(userAddress);
if (healthFactor >= healthFactorLiquidationThreshold) revert HealthFactorTooLow();
isUnderLiquidation[userAddress] = true;
liquidationStartTime[userAddress] = block.timestamp;
emit LiquidationInitiated(msg.sender, userAddress);
}
Once liquidation has been initiated, the user to be liquidated has a grace period in which they can repay their debts. Once the grace period has passed, liquidatable users can no longer close liquidations and can be liquidated by the stability pool via LendingPool::finalizeliquidation. The issue lies where the price of the RAACNft owned by the user to be liquidated is set to a higher value by RAAC due to any external conditions which increase the value of the user's house.
This price increase can make the user's health factor go above the threshold which no longer qualifies them for liquidation. The problem occurs because if the user doesnt have any funds to pay back to the Lending Pool contract to close the liquidation via LendingPool::closeLiquidation, then the stability pool will be able to liquidate the user when the user's health is above the threshold.
Proof Of Code (POC)
This test was run in protocols-tests.js file in the "StabilityPool" describe block
it("user is wrongfully liquidated when health factor is above threshold", async function () {
await contracts.stabilityPool.connect(user1).deposit(STABILITY_DEPOSIT);
await contracts.crvUSD
.connect(user3)
.approve(contracts.stabilityPool.target, STABILITY_DEPOSIT);
await contracts.crvUSD
.connect(user3)
.transfer(contracts.stabilityPool.target, STABILITY_DEPOSIT);
const newTokenId = HOUSE_TOKEN_ID + 2;
await contracts.housePrices.setHousePrice(newTokenId, HOUSE_PRICE);
await contracts.crvUSD
.connect(user2)
.approve(contracts.nft.target, HOUSE_PRICE);
await contracts.nft.connect(user2).mint(newTokenId, HOUSE_PRICE);
await contracts.nft
.connect(user2)
.approve(contracts.lendingPool.target, newTokenId);
await contracts.lendingPool.connect(user2).depositNFT(newTokenId);
await contracts.lendingPool
.connect(user2)
.borrow(ethers.parseEther("90"));
const user2healthfactor =
await contracts.lendingPool.calculateHealthFactor(user2.address);
console.log(`user2healthfactor: ${user2healthfactor}`);
await contracts.lendingPool
.connect(user3)
.initiateLiquidation(user2.address);
await time.increase(24 * 60 * 60);
await contracts.housePrices.setHousePrice(
newTokenId,
ethers.parseEther("150")
);
const user2healthfactor1 =
await contracts.lendingPool.calculateHealthFactor(user2.address);
console.log(`user2healthfactor1: ${user2healthfactor1}`);
const healthFactorLiquidationThreshold =
await contracts.lendingPool.BASE_HEALTH_FACTOR_LIQUIDATION_THRESHOLD();
assert(user2healthfactor1 > user2healthfactor);
assert(user2healthfactor1 > healthFactorLiquidationThreshold);
await time.increase(49 * 60 * 60);
await contracts.lendingPool.connect(owner).updateState();
const tx = await contracts.stabilityPool
.connect(owner)
.liquidateBorrower(user2.address);
});
Impact
Unfair Liquidations: Users with healthy positions (post-collateral appreciation) lose their collateral unfairly.
Loss of Trust: Users may lose confidence in the protocol due to unexpected liquidations.
Financial Loss: Borrowers lose collateral for loans they could have otherwise repaid given the updated collateral value.
Tools Used
Manual Review, Hardhat
Recommendations
Ensure collateral prices are refreshed at both initiation and finalization of liquidations to reflect real-time values.
function initiateLiquidation(address borrower) external {
refreshCollateralPrice(borrower);
}
function finalizeLiquidation(address borrower) external {
refreshCollateralPrice(borrower);
}