The issue can be demonstrated with the following scenario from the Proof of Concept:
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {crvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {RAACMinter, IRAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {PercentageMath} from "../../contracts/libraries/math/PercentageMath.sol";
import {ILendingPool} from "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {IStabilityPool} from "../../contracts/interfaces/core/pools/StabilityPool/IStabilityPool.sol";
contract FoundryTest is Test {
using PercentageMath for uint256;
StabilityPool public stabilityPool;
LendingPool public lendingPool;
RAACMinter public raacMinter;
crvUSDToken public crvusd;
RToken public rToken;
DEToken public deToken;
RAACToken public raacToken;
RAACNFT public raacNFT;
DebtToken public debtToken;
RAACHousePrices public raacHousePrices;
address public owner;
address public user1;
address public user2;
address public user3;
address public treasury;
uint256 public constant INITIAL_BALANCE = 1000e18;
uint256 public constant INITIAL_PRIME_RATE = 1e27;
uint256 constant INITIAL_BATCH_SIZE = 3;
uint256 constant HOUSE_PRICE = 100e18;
uint256 constant TOKEN_ID = 1;
function setUp() public {
owner = address(this);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
treasury = makeAddr("treasury");
crvusd = new crvUSDToken(owner);
crvusd.setMinter(owner);
raacToken = new RAACToken(owner, 100, 50);
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner);
raacHousePrices.setHousePrice(TOKEN_ID, HOUSE_PRICE);
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
INITIAL_PRIME_RATE
);
stabilityPool = new StabilityPool(owner);
vm.warp(block.timestamp + 2 days);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
lendingPool.setStabilityPool(address(stabilityPool));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
deToken.transferOwnership(address(stabilityPool));
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvusd),
address(lendingPool)
);
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
_setupInitialBalancesAndAllowances();
}
function test_AllNFtsGetLiquidated() public {
_printReserveState();
uint256 NFT_COUNT = 5;
for (uint256 i = 0; i < NFT_COUNT; i++) {
raacHousePrices.setHousePrice(TOKEN_ID + i, HOUSE_PRICE);
}
address borrower = makeAddr("borrower");
crvusd.mint(borrower, HOUSE_PRICE * NFT_COUNT);
assertEq(crvusd.balanceOf(borrower), HOUSE_PRICE * NFT_COUNT);
vm.startPrank(borrower);
crvusd.approve(address(raacNFT), HOUSE_PRICE * NFT_COUNT);
for (uint256 i = 0; i < NFT_COUNT; i++) {
raacNFT.mint(TOKEN_ID + i, HOUSE_PRICE);
raacNFT.approve(address(lendingPool), TOKEN_ID + i);
lendingPool.depositNFT(TOKEN_ID + i);
}
assertEq(raacNFT.balanceOf(address(lendingPool)), NFT_COUNT);
uint256 totalCollateralValue = lendingPool.getUserCollateralValue(borrower);
console2.log("totalCollateralValue at start", totalCollateralValue);
lendingPool.borrow(totalCollateralValue.percentMul(lendingPool.liquidationThreshold()));
vm.stopPrank();
uint256 healthFactor = lendingPool.calculateHealthFactor(borrower);
console2.log("healthFactor at start", healthFactor);
raacHousePrices.setHousePrice(TOKEN_ID, (HOUSE_PRICE * 95) / 100);
totalCollateralValue = lendingPool.getUserCollateralValue(borrower);
console2.log("totalCollateralValue after price drops by 5%", totalCollateralValue);
healthFactor = lendingPool.calculateHealthFactor(borrower);
console2.log("healthFactor after price drops by 5%", healthFactor);
lendingPool.initiateLiquidation(borrower);
assertEq(lendingPool.isUnderLiquidation(borrower), true);
vm.warp(lendingPool.liquidationStartTime(borrower) + lendingPool.liquidationGracePeriod() + 1 seconds);
crvusd.mint(address(stabilityPool), 410e18);
stabilityPool.liquidateBorrower(borrower);
_printReserveState();
}
function _setupInitialBalancesAndAllowances() internal {
crvusd.mint(user1, INITIAL_BALANCE);
crvusd.mint(user2, INITIAL_BALANCE);
crvusd.mint(user3, INITIAL_BALANCE);
vm.startPrank(user1);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
vm.startPrank(user2);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
vm.startPrank(user3);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
}
function _printReserveState() internal view {
(
address reserveRTokenAddr,
address reserveAssetAddr,
address reserveDebtTokenAddr,
uint256 totalLiquidity,
uint256 totalUsage,
uint128 liquidityIndex,
uint128 usageIndex,
uint40 lastUpdateTimestamp
) = lendingPool.reserve();
console2.log("Reserve Data");
console2.log("totalLiquidity", totalLiquidity);
console2.log("totalUsage", totalUsage);
console2.log("liquidityIndex", liquidityIndex);
console2.log("usageIndex", usageIndex);
console2.log("lastUpdateTimestamp", lastUpdateTimestamp);
console2.log("================================================");
}
function _printRateData() internal view {
(
uint256 currentLiquidityRate,
uint256 currentUsageRate,
uint256 primeRate,
uint256 baseRate,
uint256 optimalRate,
uint256 maxRate,
uint256 optimalUtilizationRate,
uint256 protocolFeeRate
) = lendingPool.rateData();
console2.log("Rate Data");
console2.log("currentLiquidityRate", currentLiquidityRate);
console2.log("currentUsageRate", currentUsageRate);
console2.log("primeRate", primeRate);
console2.log("baseRate", baseRate);
console2.log("optimalRate", optimalRate);
console2.log("maxRate", maxRate);
console2.log("optimalUtilizationRate", optimalUtilizationRate);
console2.log("protocolFeeRate", protocolFeeRate);
console2.log("================================================");
}
}
To demonstrate that it's sufficient to only transfer the lowest valued NFT to restore a positive health factor you can follow these steps (! Please note that this is only for demonstration purpose !) :
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);
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;
// Only transfer one NFT, we know that it's at position 0 in the array
+ raacNFT.transferFrom(address(this), stabilityPool, user.nftTokenIds[0]);
+ uint256 price = getNFTPrice(user.nftTokenIds[0]);
+ user.nftTokenIds[0] = user.nftTokenIds[user.nftTokenIds.length - 1];
+ user.nftTokenIds.pop();
- // Burn DebtTokens from the user
- (uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) = IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
+ // Burn the value of the collateral that gets liquidated (price of NFT)
+ (uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) = IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, price, reserve.usageIndex);
// Transfer reserve assets from Stability Pool to cover the debt
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
// Update user's scaled debt balance
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}
function test_LiquidateLowestValueNFT() public {
_printReserveState();
uint256 NFT_COUNT = 5;
for (uint256 i = 0; i < NFT_COUNT; i++) {
raacHousePrices.setHousePrice(TOKEN_ID + i, HOUSE_PRICE);
}
address borrower = makeAddr("borrower");
crvusd.mint(borrower, HOUSE_PRICE * NFT_COUNT);
assertEq(crvusd.balanceOf(borrower), HOUSE_PRICE * NFT_COUNT);
vm.startPrank(borrower);
crvusd.approve(address(raacNFT), HOUSE_PRICE * NFT_COUNT);
for (uint256 i = 0; i < NFT_COUNT; i++) {
raacNFT.mint(TOKEN_ID + i, HOUSE_PRICE);
raacNFT.approve(address(lendingPool), TOKEN_ID + i);
lendingPool.depositNFT(TOKEN_ID + i);
}
assertEq(raacNFT.balanceOf(address(lendingPool)), NFT_COUNT);
uint256 totalCollateralValue = lendingPool.getUserCollateralValue(borrower);
console2.log("totalCollateralValue at start", totalCollateralValue);
lendingPool.borrow(totalCollateralValue.percentMul(lendingPool.liquidationThreshold()));
vm.stopPrank();
uint256 totalDebt = lendingPool.getUserDebt(borrower);
console2.log("totalDebt at start", totalDebt);
uint256 healthFactor = lendingPool.calculateHealthFactor(borrower);
console2.log("healthFactor at start", healthFactor);
raacHousePrices.setHousePrice(TOKEN_ID, (HOUSE_PRICE * 95) / 100);
totalCollateralValue = lendingPool.getUserCollateralValue(borrower);
console2.log("totalCollateralValue after price drops by 5%", totalCollateralValue);
healthFactor = lendingPool.calculateHealthFactor(borrower);
console2.log("healthFactor after price drops by 5%", healthFactor);
lendingPool.initiateLiquidation(borrower);
assertEq(lendingPool.isUnderLiquidation(borrower), true);
vm.warp(lendingPool.liquidationStartTime(borrower) + lendingPool.liquidationGracePeriod() + 1 seconds);
crvusd.mint(address(stabilityPool), 410e18);
stabilityPool.liquidateBorrower(borrower);
_printReserveState();
healthFactor = lendingPool.calculateHealthFactor(borrower);
console2.log("healthFactor after liquidation", healthFactor);
totalCollateralValue = lendingPool.getUserCollateralValue(borrower);
console2.log("totalCollateralValue after liquidation", totalCollateralValue);
totalDebt = lendingPool.getUserDebt(borrower);
console2.log("totalDebt after liquidation", totalDebt);
}
Calculate the minimum number of NFTs needed to restore the health factor above the threshold, rather than liquidating all NFTs. You could sort the NFTs in ascending order and then calculate how many NFTs are needed.