Summary
LendingPool has a built-in liquidity management mechanism that keeps only a certain percentage of liquidity in the RToken contract ("buffer") and deposits the remaining amount into a curve vault that provides yield. However, the liquidity management mechanism uses the LendingPool contract address instead of the RToken contract address (which holds the crvUSD), therefore, the mechanism is faulty and will revert due to insufficient balance error. The error occurs only when the curve vault address is set.
Vulnerability Details
In LendingPool.sol:L225, the deposit function transfers crvUSD from the caller to the RToken contract and mints RToken to the caller. A certain amount of crvUSD is kept in RToken as a liquidity buffer (20% by default), and the rest is deposited into a curve vault to earn yield. However, the vault interactions use the lending pool's address, which does not hold any crvUSD, therefore all related functions will revert with the ERC20 insufficient balance error. The liquidity management is enabled only when a curve vault is set.
Similarly, withdraw and borrow is affected, as they also call the faulty internal functions.
(Indentation is messed up, I don't know why. Sorry!)
function deposit(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
ReserveLibrary.updateReserveState(reserve, rateData);
uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
_rebalanceLiquidity();
emit Deposit(msg.sender, amount, mintedAmount);
}
function _rebalanceLiquidity() internal {
if (address(curveVault) == address(0)) {
return;
}
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);
}
emit LiquidityRebalanced(currentBuffer, totalVaultDeposits);
}
* @notice Internal function to deposit liquidity into the Curve vault
* @param amount The amount to deposit
*/
function _depositIntoVault(uint256 amount) internal {
IERC20(reserve.reserveAssetAddress).approve(address(curveVault), amount);
curveVault.deposit(amount, address(this));
totalVaultDeposits += amount;
}
* @notice Internal function to withdraw liquidity from the Curve vault
* @param amount The amount to withdraw
*/
function _withdrawFromVault(uint256 amount) internal {
curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
totalVaultDeposits -= amount;
}
The rest of the deposit function is in ReserveLibrary.sol which I show to prove that RToken handles crvUSD tokens:
function deposit(ReserveData storage reserve,ReserveRateData storage rateData,uint256 amount,address depositor) internal returns (uint256 amountMinted) {
if (amount < 1) revert InvalidAmount();
updateReserveInterests(reserve, rateData);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender,
reserve.reserveRTokenAddress,
amount
);
(bool isFirstMint, uint256 amountScaled, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).mint(
address(this),
depositor,
amount,
reserve.liquidityIndex
);
amountMinted = amountScaled;
updateInterestRatesAndLiquidity(reserve, rateData, amount, 0);
emit Deposit(depositor, amount, amountMinted);
return amountMinted;
}
Impact
Lending pool deposits, withdrawals and borrows will always revert when the curve vault is set.
Tools Used and Proof of Concept
I created a simple mock CurveVault as this seemed the fastest way to test the issue.
pragma solidity ^0.8.0;
interface IERC20 {
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
}
contract CurveVault {
IERC20 public token;
constructor(address _tokenAddress) {
token = IERC20(_tokenAddress);
}
function deposit(uint256 amount, address sender) external {
require(amount > 0, "Amount must be greater than zero");
require(token.transferFrom(sender, address(this), amount), "Transfer failed");
}
}
The hardhat test file. Executing the test will produce an ERC20 insufficient balance error. Commenting out the setCurveVault call will mask the issue, as the liquidity management mechanism is only active when curve vault is set.
import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
const WAD = ethers.parseEther("1");
const RAY = ethers.parseUnits("1", 27);
const WadRayMath = {
wadToRay: (wad) => (BigInt(wad) * BigInt(RAY)) / BigInt(WAD),
rayToWad: (ray) => {
ray = BigInt(ray);
return (ray * BigInt(WAD)) / BigInt(RAY);
},
};
describe("LendingPool", function () {
let owner, user1;
let crvusd, raacNFT, raacHousePrices, stabilityPool;
let lendingPool, rToken, debtToken, curveVault;
beforeEach(async function () {
[owner, user1] = await ethers.getSigners();
const CrvUSDToken = await ethers.getContractFactory("crvUSDToken");
crvusd = await CrvUSDToken.deploy(owner.address);
await crvusd.setMinter(owner.address);
const CurveVault = await ethers.getContractFactory("CurveVault");
curveVault = await CurveVault.deploy(crvusd.target);
const RAACHousePrices = await ethers.getContractFactory("RAACHousePrices");
raacHousePrices = await RAACHousePrices.deploy(owner.address);
const RAACNFT = await ethers.getContractFactory("RAACNFT");
raacNFT = await RAACNFT.deploy(crvusd.target, raacHousePrices.target, owner.address);
stabilityPool = { target: owner.address };
const RToken = await ethers.getContractFactory("RToken");
rToken = await RToken.deploy("RToken", "RToken", owner.address, crvusd.target);
const DebtToken = await ethers.getContractFactory("DebtToken");
debtToken = await DebtToken.deploy("DebtToken", "DT", owner.address);
const initialPrimeRate = ethers.parseUnits("0.1", 27);
const LendingPool = await ethers.getContractFactory("LendingPool");
lendingPool = await LendingPool.deploy(crvusd.target, rToken.target, debtToken.target, raacNFT.target, raacHousePrices.target, initialPrimeRate);
await lendingPool.setCurveVault(curveVault.target);
await rToken.setReservePool(lendingPool.target);
await debtToken.setReservePool(lendingPool.target);
await rToken.transferOwnership(lendingPool.target);
await debtToken.transferOwnership(lendingPool.target);
const mintAmount = ethers.parseEther("1000");
await crvusd.mint(user1.address, mintAmount);
await crvusd.connect(user1).approve(lendingPool.target, mintAmount);
await raacHousePrices.setOracle(owner.address);
});
describe("Deposit", function () {
it("should allow user to deposit crvUSD and receive rToken", async function () {
const depositAmount = ethers.parseEther("100");
await lendingPool.connect(user1).deposit(depositAmount);
await ethers.provider.send("evm_mine", []);
});
});
});
Recommendations
Fix the relevant functions by changing address(this)
to the address of RToken, which holds the actual crvUSD tokens.