Core Contracts

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

Liquidity management mechanism in LendingPool reverts due to incorrect transfer address

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) {
// Update the reserve state before the deposit
ReserveLibrary.updateReserveState(reserve, rateData);
// Perform the deposit through ReserveLibrary
uint256 mintedAmount = ReserveLibrary.deposit(reserve, rateData, amount, msg.sender);
// Rebalance liquidity after deposit
_rebalanceLiquidity();
emit Deposit(msg.sender, amount, mintedAmount);
}
function _rebalanceLiquidity() internal {
// if curve vault is not set, do nothing
if (address(curveVault) == address(0)) {
return;
}
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);
}
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)); // address(this) does not hold any tokens
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)); // address(this) will not transfer any tokens to withdraw initiator
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();
// Update reserve interests
updateReserveInterests(reserve, rateData);
// Transfer asset from caller to the RToken contract
IERC20(reserve.reserveAssetAddress).safeTransferFrom(
msg.sender, // from
reserve.reserveRTokenAddress, // to
amount // amount
);
// Mint RToken to the depositor (scaling handled inside RToken)
(bool isFirstMint, uint256 amountScaled, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).mint(
address(this), // caller
depositor, // onBehalfOf
amount, // amount
reserve.liquidityIndex // index
);
amountMinted = amountScaled;
// Update the total liquidity and interest rates
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.

// SPDX-License-Identifier: MIT
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();
// Deploy crvUSD
const CrvUSDToken = await ethers.getContractFactory("crvUSDToken");
crvusd = await CrvUSDToken.deploy(owner.address);
await crvusd.setMinter(owner.address);
// Deploy mock CurveVault
const CurveVault = await ethers.getContractFactory("CurveVault");
curveVault = await CurveVault.deploy(crvusd.target);
// Deploy other stuff
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);
// Set curve vault - toggle for error
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);
// FIXME: we are using price oracle and therefore the price should be changed from the oracle.
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.

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.