Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: medium
Valid

LendingPool::__rebalanceLiquidity() will always revert as Lending Pool contract doesnt hold assets which causes reverts on LendingPool::deposit meaning that users cannot deposit

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 curve vault is not set, do nothing
if (address(curveVault) == address(0)) {
return;
}
uint256 totalDeposits = reserve.totalLiquidity; // Total liquidity in the system
uint256 desiredBuffer = totalDeposits.percentMul(liquidityBufferRatio);
uint256 currentBuffer = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (currentBuffer > desiredBuffer) {
uint256 excess = currentBuffer - desiredBuffer;
// Deposit excess into the Curve vault
_depositIntoVault(excess);
} else if (currentBuffer < desiredBuffer) {
uint256 shortage = desiredBuffer - currentBuffer;
// Withdraw shortage from the Curve vault
_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) {
// Update the reserve state before the deposit
ReserveLibrary.updateReserveState(reserve, rateData);
// Perform the deposit through ReserveLibrary
uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
// Rebalance liquidity after deposit
_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();
// Update reserve interests
updateReserveInterests(reserve, rateData);
// Transfer asset from caller to the RToken contract
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender, // from
reserve.reserveRTokenAddress, // to
amount // amount
);
// Mint RToken to the depositor (scaling handled inside RToken)
(
bool isFirstMint,
uint256 amountScaled,
uint256 newTotalSupply,
uint256 amountUnderlying
) = IRToken(reserve.reserveRTokenAddress).mint(
address(this), // caller
depositor, // onBehalfOf
amount, // amount
reserve.liquidityIndex // index
);
amountMinted = amountScaled;
// Update the total liquidity and interest rates
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:

//SDPX-license-Identifier: MIT
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; // Initialize the crv variable
}
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 () {
//c for testing purposes
/*c to run this test, deploy the mockcrvvault contract and set it as the curve vault in the lending pool. to do this, add the following line to the test script:
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.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::_depositIntoVault and _withdrawFromVault don't transfer tokens between RToken and LendingPool, breaking Curve vault interactions

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::_depositIntoVault and _withdrawFromVault don't transfer tokens between RToken and LendingPool, breaking Curve vault interactions

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.