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 LendingPool
doesn't hold any crvUSD
.
Now the LendingPool.deposit
will call _rebalanceLiquidity
and 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 RToken
contract.
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.sol
in 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 crvUSD
in 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)