Core Contracts

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

The earned yield from the Curve vault can never be utilized when withdrawing or borrowing

Summary

The Lending pool is designed to interact with Yearn V3 vault (Curve vault), which serves as a buffer mechanism for rebalancing liquidity. If the buffer exceeds the targeted buffer, it deposits the excess amount in to a Curve vault and in case of shortage it withdraws the needed amount + any earned yield. However the earned yield can never be withdrawn or utilized within the protocol.

Vulnerability Details

Everytime the liquidity needs to be optimized there is a deposit or withdraw from the Curve vault:

function _rebalanceLiquidity() internal {
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);
}
...
}
function _depositIntoVault(uint256 amount) internal {
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
curveVault.deposit(amount, address(this));
@> totalVaultDeposits += amount;
}
function _withdrawFromVault(uint256 amount) internal {
curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
@> totalVaultDeposits -= amount;

Notice that the deposits and withdrawals to/from the vault are tracked by the totalVaultDeposits variable. The problem is that this variable will prevent withdrawing more than deposited, due to an underflow revert, which means the earning yield will be locked in the vault and can never be utilized within the protocol.

Consider the following scenario:

  1. Alice deposits 1000 tokens -> desiredBuffer (200) < currentBuffer (1000) -> excess amount = 800 (will be deposited into the Curve vault) -> 200 to RToken

  2. totalVaultDeposits += 800

  3. Bob deposits 1000 tokens -> desiredBuffer (400) < currentBuffer (1200) -> excess amount = 800

  4. totalVaultDeposits += 1600 -> 200 to RToken

  5. Curve vault will have balance of 1600 crvUSD, let's say it earns yield of 10 tokens, so new balance = 1610

  6. Now when a user decides to withdraw or borrow some amount, first _ensureLiquidity is called to provide from the Curve vault the required amount.

function _ensureLiquidity(uint256 amount) internal {
...
uint256 availableLiquidity = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (availableLiquidity < amount) {
uint256 requiredAmount = amount - availableLiquidity;
// Withdraw required amount from the Curve vault
_withdrawFromVault(requiredAmount);
}
}
  1. Now let's say that a borrower wants to borrow 2010 tokens:

    • availableLiquidity will be 400

    • requiredAmount = 2010 - 400 = 1610

    • that means totalVaultDeposits (1600) -= 1610, here it reverts

  2. The call will revert with underflow error, even though the protocol actually has this balance and can provide the selected amount

Overall the totalVaultDeposits variable doesn't allow the gained yield to be withdrawn from the vault, hence it will result in stuck yield, hence loss of yield.

2nd example is:

  1. Essentially the protocol allows users to specify any amount they wish for withdrawals of crvUSD tokens from the protocol.

  2. And if he specifies more than he has deposited, the required amount will be withdrawn from the Curve as can be seen above from the code snippets (but the protocol will not let him to take out more than deposited, because this amount will be capped to his actual deposits later on)

  3. That means by specifiying large values, he can withdraw all of the deposits in the Curve vault (i assume this is intentional design, so the earned yield from the vault can also be used, acting as buffer helper mechanism)

  4. However the call will again revert if the required amount will be greater than totalVaultDeposits.

Here is a coded POC, demonstrating the issue (i'm using Foundry for tests):

  1. Install Foundry

  2. Run forge init --force in the terminal

  3. In order to run the test successfully there are 2 other issues that must be fixed

    • one of them is fixed in the test file itself (it's also mentioned there)

    • second one is to refactor the code of LendingPool::_withdrawFromVault()

function _withdrawFromVault(uint256 amount) internal {
- curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
+ curveVault.withdraw(amount, reserve.reserveRTokenAddress, msg.sender, 0, new address[](0));
  1. Paste the following file in the test folder and run forge test --mt testX

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console2} from "../lib/forge-std/src/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {LendingPool} from "../contracts/core/pools/LendingPool/LendingPool.sol";
import {ILendingPool} from "../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {RToken} from "../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../contracts/core/tokens/DebtToken.sol";
import {RAACHousePrices} from "../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../contracts/core/tokens/RAACNFT.sol";
import {IRAACNFT} from "../contracts/interfaces/core/tokens/IRAACNFT.sol";
import {WadRayMath} from "../contracts/libraries/math/WadRayMath.sol";
import {ICurveCrvUSDVault} from "../contracts/interfaces/curve/ICurveCrvUSDVault.sol";
contract CrvUSD is IERC20, ERC20 {
constructor() ERC20("crvUSD", "CRVUSD") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
// Note: the mock vault is only transfering assets, without issuing shares, for simplicity, because the desbribed problem is not shares related
contract MockCurveVault is ICurveCrvUSDVault {
CrvUSD crvUSD;
constructor(CrvUSD _crvUSD) {
crvUSD = _crvUSD;
}
function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
crvUSD.transferFrom(msg.sender, address(this), assets);
}
function withdraw(uint256 assets, address receiver, address owner, uint256 maxLoss, address[] calldata strategies) external returns (uint256 shares) {
crvUSD.transfer(receiver, assets);
}
function asset() external view returns (address) {}
function totalAssets() external view returns (uint256) {}
function pricePerShare() external view returns (uint256) {}
function totalIdle() external view returns (uint256) {}
function totalDebt() external view returns (uint256) {}
function isShutdown() external view returns (bool) {}
}
contract Tester is Test {
LendingPool lendingPool;
CrvUSD crvUSD;
RToken rToken;
DebtToken debtToken;
RAACHousePrices housePrices;
RAACNFT nft;
MockCurveVault vault;
uint256 initialPrimeRate = 1e26;
address owner = makeAddr("owner");
address bob = makeAddr("bob");
address lender = makeAddr("lender");
function setUp() external {
vm.startPrank(owner);
crvUSD = new CrvUSD();
vault = new MockCurveVault(crvUSD);
rToken = new RToken("RToken", "RT", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
housePrices = new RAACHousePrices(owner);
nft = new RAACNFT(address(crvUSD), address(housePrices), owner);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(nft),
address(housePrices),
initialPrimeRate
);
housePrices.setOracle(owner); // owner is set as oracle for simplicity
housePrices.setHousePrice(1, 2000e18); // tokenID = 1 will cost 2000e18 crvUSD
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
lendingPool.setCurveVault(address(vault));
vm.stopPrank();
// Lender provides liquidity
vm.startPrank(lender);
crvUSD.mint(lender, 8000e18);
crvUSD.transfer(address(lendingPool), 800e18); // directly transfering some amount so it can fix other issue mentioned in separate report
crvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(1000e18);
vm.stopPrank();
// Bob (borrower) setup
vm.startPrank(bob);
crvUSD.mint(bob, 2000e18);
crvUSD.approve(address(lendingPool), type(uint256).max);
crvUSD.approve(address(nft), type(uint256).max);
nft.mint(1, 2000e18);
nft.setApprovalForAll(address(lendingPool), true);
vm.stopPrank();
}
function testX() public {
// This replicates the earned yield, just transfers some amount for simplicity, to avoid additional code complexity
address yieldProvider = makeAddr("yp");
crvUSD.mint(yieldProvider, 1e18);
vm.prank(yieldProvider);
crvUSD.transfer(address(vault), 1e18); // Here the vault "earns" yield
vm.startPrank(bob);
lendingPool.depositNFT(1); // has deposited collateral worth of 2000 crvUSD
vm.expectRevert(); // remove this line and run the test again, to confirm that it reverts with arithmetic error
lendingPool.borrow(1801e18);
vm.stopPrank();
}
}

Impact

The earned yield can't be withdrawn, when interacting with the withdraw or borrow functions, hence results in loss of yield.

Tools Used

Manual Review

Recommendations

Currently the totalVaultDeposits variable is not used in any core functionality. It only provides information about the token balances of the Curve vault, but this can be checked from the Curve vault itself. My recommendation is to remove the totalVaultDeposits variable.

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::totalVaultDeposits can underflow when withdrawing yield-inclusive amounts and vault yield isn't factored into interest rate calculations

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::totalVaultDeposits can underflow when withdrawing yield-inclusive amounts and vault yield isn't factored into interest rate calculations

Support

FAQs

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