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;
uint256 desiredBuffer = totalDeposits.percentMul(liquidityBufferRatio);
uint256 currentBuffer = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (currentBuffer > desiredBuffer) {
uint256 excess = currentBuffer - desiredBuffer;
_depositIntoVault(excess);
} else if (currentBuffer < desiredBuffer) {
uint256 shortage = desiredBuffer - currentBuffer;
_withdrawFromVault(shortage);
}
Note that currentBuffer
is 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 curveVault
to 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,
reserve.reserveRTokenAddress,
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;
IERC20 asset = IERC20(0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E);
LendingPool lendingPool;
RAACHousePricesMock housePrice;
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:
@@ -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