Core Contracts

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

Users cannot deposit into lending pool when curve vault deposit is enabled

Summary

If curve vault is set in LendingPool, it will maintain specified buffer ratio by depositing excess liquidity to and withdrawing shortage liquidity from curve vault. This rebalancing happens on every user interaction such as deposit and withdraw.

However, when lending pool tries to deposit into curve vault, it reverts because lending pool does not hold any asset. All asset is deposited into RToken, but not into LendingPool.

Vulnerability Details

Root Cause Analysis

LendingPool has a liquidity buffer mechanism. It deposits part of its supplied liquidity into the Curve vault while keeping only a specified buffer ratio of the total liquidity.

For example, after user deposit, LendingPool will rebalance liquidity like the following:

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

Note that currentBufferis the deposited asset amount of RToken.

If we look into _depositIntoVault implementation:

function _depositIntoVault(uint256 amount) internal {
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
curveVault.deposit(amount, address(this));
totalVaultDeposits += amount;
}

LendingPool approves curveVaultto spend asset. But as we've seen above, liquidity is held in RToken, not in LendingPool. So curve vault cannot spend asset of LendingPool and deposit will revert.

We can also confirm above statement in ReserveLibrary.deposit implementation:

IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender, // from
reserve.reserveRTokenAddress, // to
amount // amount
);

i.e. When user deposits asset into lending pool, it will transfer all asset to RToken.

Thus, LendingPool does not hold any asset and vault deposit will fail.

Same mistake happens in _withdrawFromVault

curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));

Withdraw receiver is set to address(this), i.e.LendingPool, but LendingPool is not the asset holder. It should be set to RToken address.

POC

The POC shows that deposit on curve-vault-enabled LendingPool will revert with EvmError due to lack of asset amount in LendingPool.

First, follow the steps in foundry-hardhat integration tutorial

Create a file test/depositFailure.t.sol and put the following content:

pragma solidity ^0.8.19;
import "../lib/forge-std/src/Test.sol";
import {RToken} from "../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../contracts/core/tokens/DebtToken.sol";
import {RAACNFT} from "../contracts/core/tokens/RAACNFT.sol";
import {LendingPool} from "../contracts/core/pools/LendingPool/LendingPool.sol";
import {RAACHousePricesMock} from "../contracts/mocks/core/primitives/RAACHousePricesMock.sol";
import {ICurveCrvUSDVault} from "../contracts/interfaces/curve/ICurveCrvUSDVault.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract RAACTest is Test {
RToken rtoken;
DebtToken debtToken;
RAACNFT raacNft;
// official crvUSD stablecoin address on ETH mainnet
// @see https://docs.curve.fi/references/deployed-contracts/#curve-stablecoin
IERC20 asset = IERC20(0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E);
LendingPool lendingPool;
RAACHousePricesMock housePrice;
// crvUSD-WETH curve vault
// @see https://etherscan.io/address/0x5AE28c9197a4a6570216fC7e53E7e0221D7A0FEF#code
ICurveCrvUSDVault curveVault = ICurveCrvUSDVault(0x5AE28c9197a4a6570216fC7e53E7e0221D7A0FEF);
address user = makeAddr("user");
function setUp() external {
vm.createSelectFork(vm.envString("MAINNET_FORK_URL"), 21826293);
housePrice = new RAACHousePricesMock();
debtToken = new DebtToken("DebtToken", "DTK", address(this));
rtoken = new RToken("RToken", "RTK", address(this), address(asset));
raacNft = new RAACNFT(address(asset), address(housePrice), address(this));
lendingPool = new LendingPool(
address(asset), address(rtoken), address(debtToken), address(raacNft), address(housePrice), 0.1e27
);
lendingPool.setCurveVault(address(curveVault));
rtoken.setReservePool(address(lendingPool));
vm.label(address(asset), "crvUSD");
vm.label(0xaade9230AA9161880E13a38C83400d3D1995267b, "crvUSD Controller");
vm.label(address(curveVault), "CurveCrvUSDVault");
}
function testFailDeposit() external {
uint256 userAssetAmount = 10_000e18;
deal(address(asset), user, userAssetAmount);
vm.startPrank(user);
asset.approve(address(lendingPool), userAssetAmount);
lendingPool.deposit(userAssetAmount);
vm.stopPrank();
}
}

Run the following command:

MAINNET_FORK_URL={YOUR_MAINNET_FORK_URL} forge test depositFailure.t.sol -vvvv

Here is example console output that demonstrates crvUSD transfer fails with EvmError:

├─ [44370] CurveCrvUSDVault::deposit(8000000000000000000000 [8e21], LendingPool: [0xc7183455a4C133Ae270771860664b6B7ec320bB1])
│ │ ├─ [41696] 0xc014F34D5Ba10B6799d76b0F5ACdEEe577805085::deposit(8000000000000000000000 [8e21], LendingPool: [0xc7183455a4C133Ae270771860664b6B7ec320bB1]) [delegatecall]
│ │ │ ├─ [2242] crvUSD Controller::d0c581bf() [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000001
│ │ │ ├─ [2710] crvUSD::balanceOf(crvUSD Controller: [0xaade9230AA9161880E13a38C83400d3D1995267b]) [staticcall]
│ │ │ │ └─ ← [Return] 25734845114540581397429 [2.573e22]
│ │ │ ├─ [13855] crvUSD Controller::total_debt() [staticcall]
│ │ │ │ ├─ [6669] 0xb46aDcd1eA7E35C4EB801406C3E76E76e9a46EdF::get_rate_mul() [staticcall]
│ │ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000f492d9236cc4426
│ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000045e9b06bc948d880617a
│ │ │ ├─ [4934] crvUSD::transferFrom(LendingPool: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], crvUSD Controller: [0xaade9230AA9161880E13a38C83400d3D1995267b], 8000000000000000000000 [8e21])
│ │ │ │ ├─ emit Approval(owner: LendingPool: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], spender: CurveCrvUSDVault: [0x5AE28c9197a4a6570216fC7e53E7e0221D7A0FEF], value: 0)
│ │ │ │ └─ ← [Revert] EvmError: Revert
│ │ │ └─ ← [Revert] EvmError: Revert
│ │ └─ ← [Revert] EvmError: Revert
│ └─ ← [Revert] EvmError: Revert

crvUSD tries to transfer asset from LendingPool to crvUSD Controller. But it reverts because LendingPool does not hold any crvUSD

Impact

  • Users won't be able to deposit into LendingPool when curve vault is enabled

  • Liquidity withdrawn from CurveVault will not be returned to RToken automatically. Owner has to rescue those tokens into RToken manually.

Tools Used

Manual Review, Foundry

Recommendations

We can fix the issue by the following approach:

  • Transfer asset from RToken to LendingPool before depositing into vault

  • When withdrawing from vault, set asset receiver address to RToken

The following diff demonstrates the above idea:

diff --git a/contracts/core/pools/LendingPool/LendingPool.sol b/contracts/core/pools/LendingPool/LendingPool.sol
index 672c7dc..e331dac 100644
--- a/contracts/core/pools/LendingPool/LendingPool.sol
+++ b/contracts/core/pools/LendingPool/LendingPool.sol
@@ -764,6 +764,7 @@ contract LendingPool is ILendingPool, Ownable, ReentrancyGuard, ERC721Holder, Pa
* @param amount The amount to deposit
*/
function _depositIntoVault(uint256 amount) internal {
+ IRToken(Ireserve.reserveRTokenAddress).transferAsset(address(this), amount);
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
curveVault.deposit(amount, address(this));
totalVaultDeposits += amount;
@@ -774,7 +775,7 @@ contract LendingPool is ILendingPool, Ownable, ReentrancyGuard, ERC721Holder, Pa
* @param amount The amount to withdraw
*/
function _withdrawFromVault(uint256 amount) internal {
- curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
+ curveVault.withdraw(amount, reserve.reserveRTokenAddress, address(this), 0, new address[](0));
totalVaultDeposits -= amount;
}
}
\ No newline at end of file
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.