Core Contracts

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

Unable to withdraw/borrow from LendingPool because liquidity is not ensured

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 curve vault is not set, do nothing
if (address(curveVault) == address(0)) {
return;
}
@> uint256 availableLiquidity = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (availableLiquidity < amount) {
uint256 requiredAmount = amount - availableLiquidity;
// Withdraw required amount from the Curve vault
@> _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

// LendingPool
function withdraw(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (withdrawalsPaused) revert WithdrawalsArePaused();
// Update the reserve state before the withdrawal
ReserveLibrary.updateReserveState(reserve, rateData);
// Ensure sufficient liquidity is available
@> _ensureLiquidity(amount);
// Perform the withdrawal through ReserveLibrary
(uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) = ReserveLibrary.withdraw(
reserve, // ReserveData storage
rateData, // ReserveRateData storage
@> amount, // Amount to withdraw
msg.sender // Recipient
);
// Rebalance liquidity after withdrawal
_rebalanceLiquidity();
emit Withdraw(msg.sender, amountWithdrawn);
}
// ReserveLibrary
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();
// Update the reserve interests
updateReserveInterests(reserve, rateData);
// Burn RToken from the recipient - will send underlying asset to the recipient
(uint256 burnedScaledAmount, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).burn(
recipient, // from
recipient, // receiverOfUnderlying
@> amount, // amount
reserve.liquidityIndex // index
);
amountWithdrawn = burnedScaledAmount;
// Update the total liquidity and interest rates
updateInterestRatesAndLiquidity(reserve, rateData, 0, amountUnderlying);
emit Withdraw(recipient, amountUnderlying, burnedScaledAmount);
return (amountUnderlying, burnedScaledAmount, amountUnderlying);
}
// RToken
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);
// @info update index for the account
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance; // @info this adjust can make `amount` to be scaled amount -> the _burn() below would use scaled amount
}
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()

// SPDX-License-Identifier: MIT
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 {
// We allow everyone to mint
// require(msg.sender == minter, "Only minter can mint");
_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:

// SPDX-License-Identifier: MIT
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");
// Because user2 already deposited 1000 ether, just withdraw all
// to make the pool empty
await lendingPool.connect(user2).withdraw(withdrawAmount);
const depositAmount = ethers.parseEther("100");
// deploy and set Curve vault address
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());
// set buffer ratio to 50%
// -> 50% stays in RToken contract, 50% deposited into Curve
await lendingPool.connect(owner).setParameter(3, 5000);
// send asset token to LendingPool so that it has enough funds to deposit into Curve
// this is because the `deposit()` function is already error such that the buffer mechanism does not work
await crvusd.connect(owner).mint(await lendingPool.getAddress(), ethers.parseEther("10000") );
// fund user1
await crvusd.connect(owner).mint(user1.address, ethers.parseEther("10000") );
// deposit to increase `totalVaultDeposits`
await lendingPool.connect(user1).deposit(depositAmount);
await rToken.connect(user1).approve(await lendingPool.getAddress(), depositAmount);
// fund Curve vault
await crvusd.connect(owner).mint(await curveVault.getAddress(), ethers.parseEther("100") );
// Since the function `deposit()` is already error that the full amount `depositAmount`
// is sent to RToken contract
// then we need to burn 50% from RToken contract to make the scenario reached
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

Updates

Lead Judging Commences

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