Summary
The Lending Pool contract attempts to deposit excess liquidity into the Curve Vault via _rebalanceLiquidity(). However, due to the way liquidity is handled in the system, there are no assets in the Lending Pool contract itself, causing the deposit into the Curve Vault to fail. This results in deposit transactions reverting, preventing users from adding liquidity to the Lending Pool.
Vulnerability Details
Whenever funds are dealt with (for the most part) in the Lending Pool contract, LendingPool::__rebalanceLiquidity() is called to make sure that the Rtoken has enough available liquidity to manage its normal activities. See function below:
* @notice Rebalances liquidity between the buffer and the Curve vault to maintain the desired buffer ratio
*/
function _rebalanceLiquidity() internal {
if (address(curveVault) == address(0)) {
return;
}
uint256 totalDeposits = reserve.totalLiquidity;
uint256 desiredBuffer = totalDeposits.percentMul(liquidityBufferRatio);
uint256 currentBuffer = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (currentBuffer > desiredBuffer) {
uint256 excess = currentBuffer - desiredBuffer;
_depositIntoVault(excess);
} else if (currentBuffer < desiredBuffer) {
uint256 shortage = desiredBuffer - currentBuffer;
_withdrawFromVault(shortage);
}
emit LiquidityRebalanced(currentBuffer, totalVaultDeposits);
}
The idea is that if the Rtoken has more assets than the buffer, then the excess tokens should be deposited in the crvUsd vault to gain rewards and if there is a shortage of tokens, then tokens should be withdrawn from the vault. See LendingPool::_depositIntoVault:
* @notice Internal function to deposit liquidity into the Curve vault
* @param amount The amount to deposit
*/
function _depositIntoVault(uint256 amount) internal {
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
curveVault.deposit(amount, address(this));
totalVaultDeposits += amount;
}
The issue is that this function attempts to deposit tokens into the curve vault using funds inside the LendingPool contract. Whenever assets are sent to the LendingPool. For example, via LendingPool::deposit. See below:
* @notice Allows a user to deposit reserve assets and receive RTokens
* @param amount The amount of reserve assets to deposit
*/
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);
}
This makes a call to ReserveLibrary::deposit :
* @notice Handles deposit operation into the reserve.
* @dev Transfers the underlying asset from the depositor to the reserve, and mints RTokens to the depositor.
* This function assumes interactions with ERC20 before updating the reserve state (you send before we update how much you sent).
* A untrusted ERC20's modified mint function calling back into this library will cause incorrect reserve state updates.
* Implementing contracts need to ensure reentrancy guards are in place when interacting with this library.
* @param reserve The reserve data.
* @param rateData The reserve rate parameters.
* @param amount The amount to deposit.
* @param depositor The address of the depositor.
* @return amountMinted The amount of RTokens minted.
*/
function deposit(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address depositor
) internal returns (uint256 amountMinted) {
if (amount < 1) revert InvalidAmount();
updateReserveInterests(reserve, rateData);
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
);
amountMinted = amountScaled;
updateInterestRatesAndLiquidity(reserve, rateData, amount, 0);
emit Deposit(depositor, amount, amountMinted);
return amountMinted;
}
As you can see, this deposit transfers the tokens to the Rtoken contract. As a result, no assets are in the Lending Pool contract. So any attempts to deposit into the crvvault will revert as the Lending Pool will not have the balance to deposit into the curve vault. As a result, whenever a user attempts to deposit tokens into the Lending Pool, it will revert which means no user can deposit tokens into the Lending Pool contract.
Proof Of Code (POC)
To run this test , I created a mock crvVault contract with the following code which takes some functionality from how curve implements its vaults which is standard ERC4626 with some extra safeguards. You can see the curve vault contracts at https://github.com/curvefi/scrvusd/blob/main/contracts/yearn/VaultV3.vy
. See contract below:
pragma solidity ^0.8.20;
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockCrvVault is ERC20, ERC4626 {
address public crv;
error CannotDepositZeroShares();
constructor(address _crv) ERC20("Vault", "VLT") ERC4626(IERC20(_crv)) {
crv = _crv;
}
function decimals() public pure override(ERC20, ERC4626) returns (uint8) {
return 18;
}
function deposit(
uint256 assets,
address receiver
) public override returns (uint256) {
uint256 previewedShares = convertToShares(assets);
if (previewedShares == 0) {
revert CannotDepositZeroShares();
}
super.deposit(assets, receiver);
}
}
This test was run in LendingPool.test.js in the "Borrow and Repay" describe block
See comments I left in the test below for instructions on how to run the test with MockCrvVault contract. See test below:
it("deposits in curve vault will revert with insufficient balance", async function () {
const MockCrvVault = await ethers.getContractFactory("MockCrvVault");
mockcrvvault = await MockCrvVault.deploy(crvusd.target);
You also need to deploy the mockcrvvault I created which i have shared in the submission
*/
await lendingPool.setCurveVault(mockcrvvault.target);
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await expect(
lendingPool.connect(user2).deposit(depositAmount)
).to.be.revertedWithCustomError("ERC20InsufficientBalance");
});
Impact
Complete Deposit Failure: Users cannot deposit funds into the Lending Pool, as the deposit transaction will always revert.
Protocol Deadlock: Since _rebalanceLiquidity() is called in various functions, other interactions with the Lending Pool may also fail.
No Yield Optimization: The intended functionality of depositing excess liquidity into Curve Vaults for yield farming is completely broken.
Tools Used
Manual Review, Hardhat
Recommendations
Instead of attempting to deposit tokens into the Curve Vault from the Lending Pool contract, add a function in the RToken contract that allows it to deposit into the Curve Vault directly. Then, modify _depositIntoVault() in the Lending Pool contract to call this function.
Modify RToken contract: Add a function to deposit directly into the Curve Vault.
function depositIntoCurveVault(uint256 amount, address vault) external onlyLendingPool {
IERC20(reserveAssetAddress).approve(vault, amount);
ICurveVault(vault).deposit(amount, address(this));
}
Modify Lending Pool: Update _depositIntoVault() to use the RToken function.
function _depositIntoVault(uint256 amount) internal {
IRToken(reserve.reserveRTokenAddress).depositIntoCurveVault(amount, address(curveVault));
totalVaultDeposits += amount;
}
This ensures that tokens are deposited from the RToken contract, which actually holds the assets. Prevents reverting transactions due to insufficient balance and maintains the intended liquidity optimization functionality.
This fix ensures that deposits into the Lending Pool proceed smoothly without unnecessary reverts, allowing liquidity rebalancing to function as expected.