Summary
Reserve assets deposited into LendingPool
is rebalanced between buffer and Curve vault. However, users are unable to withdraw funds because insufficient reserve asset balance
Vulnerability Details
The function LendingPool#_ensureLiquidity()
checks if RToken
contract has enough liquidity for the withdrawal. If not enough, asset token are withdrawn from Curve vault to ensure enough liquidity for the withdrawal. Note that the reserve assets is withdrawn from Curve vault to the LendingPool
contract curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0))
, but the availableLiquidity
is the reserve asset balance of RToken
contract
function _ensureLiquidity(uint256 amount) internal {
if (address(curveVault) == address(0)) {
return;
}
@> uint256 availableLiquidity = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (availableLiquidity < amount) {
uint256 requiredAmount = amount - availableLiquidity;
@> _withdrawFromVault(requiredAmount);
}
}
function _withdrawFromVault(uint256 amount) internal {
@> curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
totalVaultDeposits -= amount;
}
Besides, the function LendingPool#withdraw()
makes a call to RToken#burn()
with the full withdraw amount, not the amount after deducted by the withdrawn amount from Curve
function withdraw(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (withdrawalsPaused) revert WithdrawalsArePaused();
ReserveLibrary.updateReserveState(reserve, rateData);
@> _ensureLiquidity(amount);
(uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) = ReserveLibrary.withdraw(
reserve,
rateData,
@> amount,
msg.sender
);
_rebalanceLiquidity();
emit Withdraw(msg.sender, amountWithdrawn);
}
function withdraw(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address recipient
) internal returns (uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) {
if (amount < 1) revert InvalidAmount();
updateReserveInterests(reserve, rateData);
(uint256 burnedScaledAmount, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).burn(
recipient,
recipient,
@> amount,
reserve.liquidityIndex
);
amountWithdrawn = burnedScaledAmount;
updateInterestRatesAndLiquidity(reserve, rateData, 0, amountUnderlying);
emit Withdraw(recipient, amountUnderlying, burnedScaledAmount);
return (amountUnderlying, burnedScaledAmount, amountUnderlying);
}
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
_userState[from].index = index.toUint128();
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
@> IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}
So, even though reserve asset is withdrawn from Curve vault, users are still unable to withdraw the expected amount
Note that, the issue also happens with the function LendingPool#borrow()
such that reserve assets are not withdrawn from Curve to RToken
contract before sending to borrowers
PoC
Modify the mock contract crvUSDToken
to support the test by adding function ownerBurn()
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract crvUSDToken is ERC20, Ownable {
using SafeERC20 for IERC20;
address public minter;
constructor(address initialOwner) ERC20("Curve USD", "crvUSD") Ownable(initialOwner) {
minter = initialOwner;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function ownerBurn(address account, uint amount) external {
_burn(account, amount);
}
function setMinter(address _minter) external onlyOwner {
minter = _minter;
}
function burn(uint256 amount) external {
_burn(msg.sender, amount);
}
function burnFrom(address account, uint256 amount) external {
uint256 currentAllowance = allowance(account, msg.sender);
require(currentAllowance >= amount, "ERC20: burn amount exceeds allowance");
unchecked {
_approve(account, msg.sender, currentAllowance - amount);
}
_burn(account, amount);
}
}
Add a mock contract MockCurveVault
to the testsuite:
pragma solidity ^0.8.19;
import "../primitives/MockContractWithConfig.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockCurveVault is MockContractWithConfig {
IERC20 token;
function setToken(address _token) public {
token = IERC20(_token);
}
function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
token.transferFrom(msg.sender, address(this), assets);
}
function withdraw(
uint256 assets,
address receiver,
address owner,
uint256 maxLoss,
address[] calldata strategies
) external returns (uint256 shares) {
token.transfer(msg.sender, assets);
}
}
Add the test below to test/unit/core/pools/LendingPool/LendingPool.test.js
describe("Deposit and Withdraw", function () {
it.only("liquidity is not ensured", async function () {
const withdrawAmount = ethers.parseEther("1000");
await lendingPool.connect(user2).withdraw(withdrawAmount);
const depositAmount = ethers.parseEther("100");
const curveVaultFactory = await ethers.getContractFactory("MockCurveVault");
const curveVault = await curveVaultFactory.deploy();
await curveVault.setToken(await crvusd.getAddress());
await lendingPool.connect(owner).setCurveVault(await curveVault.getAddress());
await lendingPool.connect(owner).setParameter(3, 5000);
await crvusd.connect(owner).mint(await lendingPool.getAddress(), ethers.parseEther("10000") );
await crvusd.connect(owner).mint(user1.address, ethers.parseEther("10000") );
await lendingPool.connect(user1).deposit(depositAmount);
await rToken.connect(user1).approve(await lendingPool.getAddress(), depositAmount);
await crvusd.connect(owner).mint(await curveVault.getAddress(), ethers.parseEther("100") );
await crvusd.connect(owner).ownerBurn(await rToken.getAddress(), ethers.parseEther("50"))
await lendingPool.connect(user1).withdraw(depositAmount);
});
Run the test and it failed. It means that LendingPool
failed because of insufficient balance
1) LendingPool
Deposit and Withdraw
liquidity is not ensured:
Error: VM Exception while processing transaction: reverted with custom error 'ERC20InsufficientBalance("0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", 50000000000000000000, 100000000000000000000)'
at crvUSDToken.burnFrom (contracts/mocks/core/tokens/crvUSDToken.sol:37)
at crvUSDToken._transfer (@openzeppelin/contracts/token/ERC20/ERC20.sol:178)
at crvUSDToken.transfer (@openzeppelin/contracts/token/ERC20/ERC20.sol:111)
at RToken.functionCallWithValue (@openzeppelin/contracts/utils/Address.sol:87)
at RToken.verifyCallResultFromTarget (@openzeppelin/contracts/utils/Address.sol:120)
at RToken.functionCallWithValue (@openzeppelin/contracts/utils/Address.sol:88)
at RToken.functionCall (@openzeppelin/contracts/utils/Address.sol:71)
at RToken._callOptionalReturn (@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol:96)
at RToken.safeTransfer (@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol:37)
at RToken.burn (contracts/core/tokens/RToken.sol:187)
at LendingPool.withdraw (contracts/libraries/pools/ReserveLibrary.sol:384)
at LendingPool.withdraw (contracts/core/pools/LendingPool/LendingPool.sol:260)
...
Impact
Users are unable to withdraw reserve asset token
Users are unable to borrow from LendingPool
Users funds get stuck because unable to withdraw correctly from Curve vault
Tools Used
Manual
Recommendations
Reserve asset tokens should be withdrawn from Curve vault to RToken
contract, instead of withdrawn directly to LendingPool
contract