Core Contracts

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

Incorrect Asset Transfer Implementation in LendingPool's Vault Deposit Function Leads To Transaction Failure

Summary

The _depositIntoVault function in the LendingPool contract attempts to deposit crvUSD tokens directly into the Curve vault without first transferring them from the RToken contract where they are actually stored. This results in failed transactions as the LendingPool contract does not have the necessary token balance.

Vulnerability Details

Pre-condition:

  • CurveVault contract is deployed and set in LendingPool via setCurveVault()

Vulnerability Steps:

  • User wants to deposit crvUSD into the LendingPool and calls the deposit() function:

function deposit(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
//... Rebalance liquidity after deposit
_rebalanceLiquidity();
}
  • The _rebalanceLiquidity() function gets called :

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

The _depositIntoVault() function gets called if the currentBuffer is larger than the desiredBuffer:

function _depositIntoVault(uint256 amount) internal {
// LendingPool approves CurveVault to spend tokens it doesn't have
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
// Transaction reverts here because LendingPool has no tokens
// All tokens are held by RToken
@> curveVault.deposit(amount, address(this)); // REVERTS
totalVaultDeposits += amount; // Never reached
}

The issue is that the function attempts to deposit crvUSD tokens directly from the LendingPool contract into the Curve vault. However, all crvUSD tokens are actually stored in the RToken contract, as evidenced by the transferAsset function in RToken:

function transferAsset(address user, uint256 amount) external override onlyReservePool {
IERC20(_assetAddress).safeTransfer(user, amount);
}

PoC

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {crvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {RAACMinter, IRAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {PercentageMath} from "../../contracts/libraries/math/PercentageMath.sol";
import {ILendingPool} from "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {IStabilityPool} from "../../contracts/interfaces/core/pools/StabilityPool/IStabilityPool.sol";
import {WadRayMath} from "../../contracts/libraries/math/WadRayMath.sol";
import {stdError} from "forge-std/StdError.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockCurveVault {
using SafeERC20 for IERC20;
IERC20 public immutable token;
uint256 public balance;
constructor(address _token) {
token = IERC20(_token);
}
function deposit(uint256 amount, address receiver) external returns (uint256) {
// Transfer tokens from sender to vault
token.safeTransferFrom(msg.sender, address(this), amount);
balance += amount;
return amount;
}
function withdraw(
uint256 amount,
address receiver,
address owner,
uint256, // maxLoss
address[] calldata // strategies
) external returns (uint256) {
// Transfer tokens from vault to receiver
token.safeTransfer(receiver, amount);
balance -= amount;
return amount;
}
}
contract FoundryTest is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
StabilityPool public stabilityPool;
LendingPool public lendingPool;
RAACMinter public raacMinter;
crvUSDToken public crvusd;
RToken public rToken;
DEToken public deToken;
RAACToken public raacToken;
RAACNFT public raacNFT;
DebtToken public debtToken;
RAACHousePrices public raacHousePrices;
MockCurveVault public mockCurveVault;
address public owner;
address public user1;
address public user2;
address public user3;
address public treasury;
uint256 public constant INITIAL_BALANCE = 1000e18;
uint256 public constant INITIAL_PRIME_RATE = 1e27;
uint256 constant INITIAL_BATCH_SIZE = 3;
uint256 constant HOUSE_PRICE = 100e18;
uint256 constant TOKEN_ID = 1;
function setUp() public {
// Setup accounts
owner = address(this);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
treasury = makeAddr("treasury");
// Deploy base tokens
crvusd = new crvUSDToken(owner);
crvusd.setMinter(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy price oracle and set oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner);
// Set initial house prices
raacHousePrices.setHousePrice(TOKEN_ID, HOUSE_PRICE);
// Deploy NFT
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
// Deploy pool tokens
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
mockCurveVault = new MockCurveVault(address(crvusd));
// Deploy pools
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
INITIAL_PRIME_RATE
);
lendingPool.setCurveVault(address(mockCurveVault));
stabilityPool = new StabilityPool(owner);
// this is needed otherwise lastEmissionUpdateTimestamp will underflow in the RAACMinter constructor
vm.warp(block.timestamp + 2 days);
// Deploy RAAC minter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
// Setup cross-contract references
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));
// Initialize Stability Pool
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvusd),
address(lendingPool)
);
// Setup permissions
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function test_CurveVault() public {
assertEq(address(mockCurveVault.token()), address(crvusd));
assertEq(address(lendingPool.curveVault()), address(mockCurveVault));
crvusd.mint(user1, INITIAL_BALANCE);
vm.startPrank(user1);
crvusd.approve(address(lendingPool), INITIAL_BALANCE);
vm.expectRevert();
// next call reverts because of wrong implementation in _depositIntoVault() function
lendingPool.deposit(INITIAL_BALANCE);
vm.stopPrank();
}
}

Impact

I rate this as medium because the problem might not exist from the beginning but once the curveVault address has been set, all deposits will start to fail because of this issue. There is no way to set the curveVault address back to address(0) due to this check in the setCurveVault() function [https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L704] to disable the _rebalanceLiquidity() function.

This will cause:

  • All deposit transactions that trigger vault deposits will fail

  • The protocol's yield generation mechanism through Curve vaults is non-functional

  • Users cannot deposit funds when the protocol needs to rebalance liquidity into the vault

  • The protocol cannot maintain its intended liquidity buffer ratio

  • The protocol needs to redeploy

Tools Used

  • Manual review

  • Foundry

Recommendations

Modify the _depositIntoVault function to first transfer the assets from the RToken contract before depositing into the vault:

function _depositIntoVault(uint256 amount) internal {
// First transfer assets from RToken to LendingPool
IRToken(reserve.reserveRTokenAddress).transferAsset(address(this), amount);
// Then approve and deposit into vault
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
curveVault.deposit(amount, address(this));
totalVaultDeposits += amount;
}
Updates

Lead Judging Commences

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