Summary
The LendingPool contract includes functionality to deposit crvUSD into the Curve Vault when a curveVault address is configured. However, there's an accounting error in the deposit flow:
When a user deposits, their crvUSD tokens are transferred to the RToken contract
The LendingPool then attempts to deposit excess crvUSD into the vault
This fails because the LendingPool incorrectly assumes it holds the crvUSD balance, when the tokens are actually in the RToken contract.
Vulnerability Details
Let's check the deposit flow from LendingPool:
function deposit(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
ReserveLibrary.updateReserveState(reserve, rateData);
@> uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
_rebalanceLiquidity();
emit Deposit(msg.sender, amount, mintedAmount);
}
Notice how the transfer is done in: ReserveLibrary.deposit:
function deposit(ReserveData storage reserve,ReserveRateData storage rateData,uint256 amount,address depositor) internal returns (uint256 amountMinted) {
...
@>
@> IERC20(reserve.reserveAssetAddress).safeTransferFrom(
@> msg.sender,
@> reserve.reserveRTokenAddress,
@> amount
@> );
(bool isFirstMint, uint256 amountScaled, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).mint(
address(this),
depositor,
amount,
reserve.liquidityIndex
);
...
}
All the tokens deposited by the user(reserve.reserveAssetAddress a.k.a crvUSD) are sent directly toreserve.reserveRTokenAddress. Here we are certain that LendingPooldoesn't hold any crvUSD.
Now the LendingPool.depositwill call _rebalanceLiquidityand this will trigger a DoS when there is amount of tokens available to be deposited into the curve vault.
function _rebalanceLiquidity() internal {
...
if (currentBuffer > desiredBuffer) {
uint256 excess = currentBuffer - desiredBuffer;
@> _depositIntoVault(excess);
}
}
function _depositIntoVault(uint256 amount) internal {
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
curveVault.deposit(amount, address(this));
totalVaultDeposits += amount;
}
As shown above, the LendingPool will try to deposit funds directly from its balance through curveVault.deposit(amount, address(this));this will fail as the asset tokens are held in the RTokencontract.
Thus, leading the contract to a permanent DoS as it cannot accept any deposits.
PoC
Context
In the PoC below we demonstrate the following
Contract is deployed and user tries to do the first deposit.
LendingPool tries to deposit 80% of the deposited funds into the vault
Transaction reverts as LendingPool does not have funds to deposit into the Curve Vault.
Execution
Install foundry through:
npm i --save-dev @nomicfoundation/hardhat-foundry
Add require("@nomicfoundation/hardhat-foundry");on hardhat config file
Run npx hardhat init-foundry and forge install foundry-rs/forge-std --no-commit
Create a file called LendingPool.t.solin the test folder
Paste the code 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 MockCurveVault {
using SafeERC20 for IERC20;
IERC20 public immutable token;
mapping(address => uint256) public balanceOf;
constructor(address _token) {
token = IERC20(_token);
}
function deposit(uint256 amount, address depositor) external returns (uint256 shares) {
token.safeTransferFrom(depositor, address(this), amount);
balanceOf[depositor] += amount;
return amount;
}
function withdraw(
uint256 assets,
address receiver,
address owner,
uint256,
address[] calldata
) external {
require(balanceOf[receiver] >= assets, "Insufficient vault balance");
balanceOf[receiver] -= assets;
token.safeTransferFrom(owner, receiver, assets);
}
}
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;
MockCurveVault public curveVault;
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);
}
function test_DoS_whenDepositing_intoCurveVault_dueToLackOfFunds() public {
vm.prank(owner);
lendingPool.setCurveVault(address(curveVault));
vm.prank(user1);
lendingPool.deposit(100e18);
}
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
);
curveVault = new MockCurveVault(address(crvUSD));
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++) {
_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), initialBalance);
crvUSD.approve(address(lendingPool), initialBalance);
crvUSD.approve(address(curveVault), initialBalance);
rToken.approve(address(stabilityPool), initialBalance);
vm.stopPrank();
}
}
run: forge test --match-test test_DoS_whenDepositing_intoCurveVault_dueToLackOfFunds -vvvv
result: LendingPool tries to deposit into the vault but it has 0 amount of crvUSDin its balance.
├─ [248413] LendingPool::deposit(100000000000000000000 [1e20])
...
│ ├─ [4946] MockCurveVault::deposit(80000000000000000000 [8e19], LendingPool: [0x90A5b0DD8c4b06636A4BEf7BA82D9C58f44fAaAd])
│ │ ├─ [3770] crvUSDToken::transferFrom(LendingPool: [0x90A5b0DD8c4b06636A4BEf7BA82D9C58f44fAaAd], MockCurveVault: [0x83769BeEB7e5405ef0B7dc3C66C43E3a51A6d27f], 80000000000000000000 [8e19])
│ │ │ └─ ← [Revert] ERC20InsufficientBalance(0x90A5b0DD8c4b06636A4BEf7BA82D9C58f44fAaAd, 0, 80000000000000000000 [8e19])
│ │ └─ ← [Revert] ERC20InsufficientBalance(0x90A5b0DD8c4b06636A4BEf7BA82D9C58f44fAaAd, 0, 80000000000000000000 [8e19])
│ └─ ← [Revert] ERC20InsufficientBalance(0x90A5b0DD8c4b06636A4BEf7BA82D9C58f44fAaAd, 0, 80000000000000000000 [8e19])
└─ ← [Revert] ERC20InsufficientBalance(0x90A5b0DD8c4b06636A4BEf7BA82D9C58f44fAaAd, 0, 80000000000000000000 [8e19])
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 9.01ms (1.73ms CPU time)
Ran 1 test suite in 8.31s (9.01ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/DepositDoS.t.sol:LendingPoolTest
[FAIL: ERC20InsufficientBalance(0x90A5b0DD8c4b06636A4BEf7BA82D9C58f44fAaAd, 0, 80000000000000000000 [8e19])] test_DoS_whenDepositing_intoCurveVault_dueToLackOfFunds() (gas: 290020)
Impact
Tools Used
Manual Review & Foundry
Recommendations
Add a transfer call to move crvUSD from the RToken contract to the LendingPool before depositing into the CurveVault:
function _rebalanceLiquidity() internal {
...
if (currentBuffer > desiredBuffer) {
uint256 excess = currentBuffer - desiredBuffer;
+ IRToken(reserve.reserveRTokenAddress).transferAsset(address(this), excess);
// Deposit excess into the Curve vault
_depositIntoVault(excess);
}
This ensures the LendingPool has the required crvUSD balance for the vault deposit.
Now lets run the test again.
Result:
Ran 1 test for test/LendingPool.t.sol:LendingPoolTest
[PASS] test_DoS_whenDepositing_intoCurveVault_dueToLackOfFunds() (gas: 348711)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.91ms (172.08µs CPU time)