Core Contracts

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

LendingPool deposits do not work with CurveVault due to lack of funds

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:

  1. When a user deposits, their crvUSD tokens are transferred to the RToken contract

  2. The LendingPool then attempts to deposit excess crvUSD into the vault

  3. 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) {
// 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);
}

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) {
...
@> // 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
);
...
}

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;
// Deposit excess into the Curve vault
@> _depositIntoVault(excess);
}
}
function _depositIntoVault(uint256 amount) internal {
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
curveVault.deposit(amount, address(this)); // @audit-issue no funds, will revert.
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

  1. Contract is deployed and user tries to do the first deposit.

  2. LendingPool tries to deposit 80% of the deposited funds into the vault

  3. Transaction reverts as LendingPool does not have funds to deposit into the Curve Vault.

Execution

  1. 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

  2. Create a file called LendingPool.t.solin the test folder

  3. Paste the code below:

// SPDX-License-Identifier: MIT
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 from CurveVault v3:
// https://github.com/curvefi/scrvusd/blob/95a120847c7a2901cea5256ba081494e18ea5315/contracts/yearn/VaultV3.vy#L1827-L1833
// @param assets The amount of asset to withdraw.
// @param receiver The address to receive the assets.
// @param owner The address who's shares are being burnt.
// @param max_loss Optional amount of acceptable loss in Basis Points.
// @param strategies Optional array of strategies to withdraw from.
// @return The amount of shares actually burnt.
function withdraw(
uint256 assets,
address receiver,
address owner,
uint256,
address[] calldata
) external {
require(balanceOf[receiver] >= assets, "Insufficient vault balance");
balanceOf[receiver] -= assets;
// Issue: burns from owner instead of pool
token.safeTransferFrom(owner, receiver, assets);
}
}
contract LendingPoolTest is Test {
using WadRayMath for uint256;
// contracts
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;
// users
address owner = address(1);
address user1 = address(2);
address user2 = address(3);
address user3 = address(4);
address[] users = new address[](3);
function setUp() public {
// setup users
users[0] = user1;
users[1] = user2;
users[2] = user3;
vm.label(user1, "USER1");
vm.label(user2, "USER2");
vm.label(user3, "USER3");
// initiate timestamp and block
vm.warp(1738798039); // 2025-02-05
vm.roll(100); // block
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(1000e18);
}
function test_DoS_whenDepositing_intoCurveVault_dueToLackOfFunds() public {
// pre condition:
// LendingPool has curve vault set
vm.prank(owner);
lendingPool.setCurveVault(address(curveVault));
// action: user deposits 100 CRVUSD into lending pool.
// Result: Revert. DoS.
// Reason:
// 1. LendingPool sent all the crvUsd deposited to RToken
// 2. _rebalanceLiquidity try to deposit the "excess" of tokens into the CurveVault but LendingPool doesn't hold any crvUSD.
// revert with ERC20InsufficientBalance(0x90A5b0DD8c4b06636A4BEf7BA82D9C58f44fAaAd, 0, 80000000000000000000)
vm.prank(user1);
lendingPool.deposit(100e18);
}
// HELPER FUNCTIONS
function _deployAndSetupContracts() internal {
// Deploy base tokens
crvUSD = new crvUSDToken(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy real oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner); // Set owner as oracle
// Deploy real NFT contract
raacNFT = new RAACNFT(
address(crvUSD),
address(raacHousePrices),
owner
);
// Deploy Curve Vault
curveVault = new MockCurveVault(address(crvUSD));
// Deploy core contracts with proper constructor args
rToken = new RToken(
"RToken",
"RTK",
owner,
address(crvUSD)
);
deToken = new DEToken(
"DEToken",
"DET",
owner,
address(rToken)
);
debtToken = new DebtToken(
"DebtToken",
"DEBT",
owner
);
// Deploy pools with required constructor parameters
lendingPool = new LendingPool(
address(crvUSD), // reserveAssetAddress
address(rToken), // rTokenAddress
address(debtToken), // debtTokenAddress
address(raacNFT), // raacNFTAddress
address(raacHousePrices), // priceOracleAddress
0.8e27 // initialPrimeRate (RAY)
);
// Deploy RAACMinter with valid constructor args
raacMinter = new RAACMinter(
address(raacToken),
address(0x1234324423), // stability pool
address(lendingPool),
owner
);
stabilityPool = new StabilityPool(owner);
stabilityPool.initialize(
address(rToken), // _rToken
address(deToken), // _deToken
address(raacToken), // _raacToken
address(raacMinter), // _raacMinter
address(crvUSD), // _crvUSDToken
address(lendingPool) // _lendingPool
);
// workaround for another bug found in Stability Pool.
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));
// setup raacToken's minter and whitelist
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

  • Contract doesn't work with curveVault, permanent DoS.

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)
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months 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 4 months 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.