Core Contracts

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

Curve CrvUSD Vault Functionality in `LendingPool` does not work, because crvUSD is held in RToken

Summary

In the LendingPool contract the curveVault is used to deposit the excess crvUSD tokens, or withdraw if there is a shortage. However these tokens are held in the RToken contract instead of the LendingPool. This makes the curveVault unusable.

Vulnerability Details

When the curveVault variable is set in the LendingPool via the setCurveVault the following functionality becomes available. When users deposit the _rebalanceLiquidity function is called. This function calculates the excess or shortage tokens based on the totaLiquidity, currentBuffer and desiredBuffer. If the currentBuffer > desiredBuffer, it calculates the excess tokens and tries to deposit them into the curveVault. The issue arises, because it tries to deposit them from address(this) aka. the LendingPool contract, when the crvUSD are held in the RToken contract.
This effectively DoS-es the entire LendingPool::deposit function in cases where there is excess or shortage of tokens, because the crvUSD will always be in the RToken contract.
As a result the LendingPool::withdraw function will also not work, because totalVaultDeposits will never be more than 0 and it will underflow.

/**
* @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;
}

Impact

  1. LendingPool::deposit DoS in cases of excess or shortage (very likely)

  2. Cruve Vault Functionality is unusable

Tools Used

Manual Review, Foundry Test

PoC

  1. Run foundryup

  2. Run foundry init

  3. Run forge install OpenZeppelin/openzeppelin-contracts --no-git and forge install https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable --no-git

  4. Paste this in foundry.toml - remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts", "@openzeppelin/contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts"]

  5. Then create a folder in src called RAACProtocol.

  6. In the RAACProtocol create two folders called: interfaces, libraries and tokens

  7. Into the interfaces paste the following contracts:

Interfaces:
ICurveCrvUSDVault.sol,
IDebtToken.sol,
IDEToken.sol,
ILendingPool.sol,
IRAACHousePrices.sol,
IRAACMinter.sol,
IRAACNFT.sol,
IRAACToken.sol,
IRToken.sol,
IStabilityPool.sol

  1. Into the libraries paste the following contracts:

Libraries:
PercentageMath.sol,
ReserveLibrary.sol,
WadRayMath.sol

  1. Into the tokens paste the following contracts:

Tokens:
DebtToken.sol,
DEToken.sol,
RAACNFT.sol,
RAACToken.sol,
RToken.sol

  1. Create files in the test folder for the mocks and paste them.

    • CrvUSDMock.sol for the crvUSD mock

    • CurveVaultMock for the curve Vault Mock

    • OracleMock for the mock oracle

  2. Create file in the test folder called RAACProtocolTest.t.sol.

  3. Paste the test setup.

  4. Paste the test and the helper functions inside the StabilityAndLendingPoolTest contract.

Set Up

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../src/RAACProtocol/StabilityPool.sol";
import {LendingPool} from "../src/RAACProtocol/LendingPool.sol";
import {RToken} from "../src/RAACProtocol/tokens/RToken.sol";
import {DebtToken} from "../src/RAACProtocol/tokens/DebtToken.sol";
import {DEToken} from "../src/RAACProtocol/tokens/DEToken.sol";
import {CrvUSDMock} from "./CrvUSDMock.sol";
import {RAACNFT} from "../src/RAACProtocol/tokens/RAACNFT.sol";
import {RAACToken} from "../src/RAACProtocol/tokens/RAACToken.sol";
import {RAACMinter} from "../src/RAACProtocol/RAACMinter.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {RAACHousePrices} from "./OracleMock.sol";
import {WadRayMath} from "../src/RAACProtocol/libraries/WadRayMath.sol";
import {CurveVaultMock} from "./CurveVaultMock.sol";
contract StabilityAndLendingPoolTest is Test {
LendingPool public lendingPool;
StabilityPool public stabilityPool;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
CrvUSDMock public crvUSD;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
RAACToken public raacToken;
RAACMinter public raacMinter;
CurveVaultMock public curveVault;
address deployer = makeAddr("deployer");
address Alice = makeAddr("Alice");
address Bob = makeAddr("Bob");
address Eve = makeAddr("Eve");
address Steve = makeAddr("Steve");
function setUp() public {
vm.warp(10 days);
vm.roll(140000);
uint256 initialPrimeRate = 1 * 1e26;
uint256 initialTax = 500;
vm.startPrank(deployer);
// Deploy base contracts
crvUSD = new CrvUSDMock("CrvUSD Token", "CrvUSD");
rToken = new RToken("RToken", "R", deployer, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", deployer);
deToken = new DEToken("DEToken", "DE", deployer, address(rToken));
raacHousePrices = new RAACHousePrices();
raacNFT = new RAACNFT(address(crvUSD), address(raacHousePrices), deployer);
raacToken = new RAACToken(deployer, initialTax, initialTax);
curveVault = new CurveVaultMock(address(crvUSD));
// Deploy LendingPool
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
// Deploy StabilityPool implementation
StabilityPool stabilityPoolImplementation = new StabilityPool(deployer);
// Initialize StabilityPool with temporary minter
bytes memory initData = abi.encodeWithSelector(
StabilityPool.initialize.selector,
address(rToken),
address(deToken),
address(raacToken),
address(makeAddr("RAACMinter")),
address(crvUSD),
address(lendingPool)
);
// Deploy proxy with implementation
ERC1967Proxy proxy = new ERC1967Proxy(address(stabilityPoolImplementation), initData);
stabilityPool = StabilityPool(address(proxy));
// Deploy RAACMinter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), deployer);
// Set up relationships between contracts
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
lendingPool.setStabilityPool(address(stabilityPool));
stabilityPool.setRAACMinter(address(raacMinter));
raacToken.setMinter(address(raacMinter));
// Whitelist key addresses to bypass RAAC token tax
raacToken.manageWhitelist(address(stabilityPool), true);
raacToken.manageWhitelist(address(raacMinter), true);
vm.stopPrank();
}
}

PoC

function testCurveCrvUSDVaultFunctionalityNotWorking() public {
// Deployer sets the curveVault address
vm.startPrank(deployer);
lendingPool.setCurveVault(address(curveVault));
vm.stopPrank();
vm.startPrank(Bob);
// The amount of crvUSD Bob is going to Deposit
uint256 bobDepositAmount = 5_000 ether;
// Minting Bob this amount
crvUSD.mint(Bob, bobDepositAmount);
// Bob approving the lendingPool for the 5_000 crvUSD
crvUSD.approve(address(lendingPool), bobDepositAmount);
// Expecting the call to revert
// If this line is commented it reverts with `arithmetic underflow or overflow` (because of crvUSD)
// implementation, but this suggest that the `LendingPool` is trying to transfer tokens it does not
// have
vm.expectRevert();
// Bob tries to deposit, but is not able to
lendingPool.deposit(bobDepositAmount);
vm.stopPrank();
}

Mocks:

  1. CrvUSD Mock

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title crvUSD Stablecoin
* @author Curve.Fi (Vyper implementation) - Converted to Solidity
* @notice Implementation of the crvUSD stablecoin contract
*/
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
interface IERC1271 {
function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4);
}
contract CrvUSDMock is IERC20 {
uint8 public constant decimals = 18;
string public constant version = "v1.0.0";
bytes4 private constant ERC1271_MAGIC_VAL = 0x1626ba7e;
bytes32 private constant EIP712_TYPEHASH =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)");
bytes32 private constant EIP2612_TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 private immutable VERSION_HASH;
bytes32 private immutable NAME_HASH;
uint256 private immutable CACHED_CHAIN_ID;
bytes32 private immutable CACHED_DOMAIN_SEPARATOR;
bytes32 public immutable salt;
string public name;
string public symbol;
mapping(address => mapping(address => uint256)) public allowance;
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
mapping(address => uint256) public nonces;
event SetMinter(address indexed minter);
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
NAME_HASH = keccak256(bytes(_name));
VERSION_HASH = keccak256(bytes(version));
CACHED_CHAIN_ID = block.chainid;
salt = blockhash(block.number - 1);
CACHED_DOMAIN_SEPARATOR =
keccak256(abi.encode(EIP712_TYPEHASH, NAME_HASH, VERSION_HASH, block.chainid, address(this), salt));
emit SetMinter(msg.sender);
}
function _approve(address owner, address spender, uint256 value) internal {
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
function _burn(address from, uint256 value) internal {
balanceOf[from] -= value;
totalSupply -= value;
emit Transfer(from, address(0), value);
}
function _transfer(address from, address to, uint256 value) internal {
require(to != address(this) && to != address(0), "Invalid recipient");
balanceOf[from] -= value;
balanceOf[to] += value;
emit Transfer(from, to, value);
}
function _domainSeparator() internal view returns (bytes32) {
if (block.chainid != CACHED_CHAIN_ID) {
return keccak256(abi.encode(EIP712_TYPEHASH, NAME_HASH, VERSION_HASH, block.chainid, address(this), salt));
}
return CACHED_DOMAIN_SEPARATOR;
}
function transferFrom(address from, address to, uint256 value) external override returns (bool) {
uint256 currentAllowance = allowance[from][msg.sender];
if (currentAllowance != type(uint256).max) {
_approve(from, msg.sender, currentAllowance - value);
}
_transfer(from, to, value);
return true;
}
function transfer(address to, uint256 value) external override returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
function approve(address spender, uint256 value) external override returns (bool) {
_approve(msg.sender, spender, value);
return true;
}
function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
external
returns (bool)
{
require(owner != address(0) && block.timestamp <= deadline, "Invalid permit");
uint256 nonce = nonces[owner];
bytes32 digest = keccak256(
abi.encodePacked(
"\x19\x01",
_domainSeparator(),
keccak256(abi.encode(EIP2612_TYPEHASH, owner, spender, value, nonce, deadline))
)
);
if (Address.isContract(owner)) {
bytes memory signature = abi.encodePacked(r, s, v);
require(IERC1271(owner).isValidSignature(digest, signature) == ERC1271_MAGIC_VAL, "Invalid signature");
} else {
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress == owner, "Invalid signature");
}
nonces[owner] = nonce + 1;
_approve(owner, spender, value);
return true;
}
function increaseAllowance(address spender, uint256 addedValue) external returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
uint256 newAllowance = currentAllowance + addedValue;
// Check for overflow
if (newAllowance < currentAllowance) {
newAllowance = type(uint256).max;
}
if (newAllowance != currentAllowance) {
_approve(msg.sender, spender, newAllowance);
}
return true;
}
function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
uint256 newAllowance;
if (subtractedValue >= currentAllowance) {
newAllowance = 0;
} else {
newAllowance = currentAllowance - subtractedValue;
}
if (newAllowance != currentAllowance) {
_approve(msg.sender, spender, newAllowance);
}
return true;
}
function burnFrom(address from, uint256 value) external returns (bool) {
uint256 currentAllowance = allowance[from][msg.sender];
if (currentAllowance != type(uint256).max) {
_approve(from, msg.sender, currentAllowance - value);
}
_burn(from, value);
return true;
}
function burn(uint256 value) external returns (bool) {
_burn(msg.sender, value);
return true;
}
function mint(address to, uint256 value) external returns (bool) {
require(to != address(this) && to != address(0), "Invalid recipient");
balanceOf[to] += value;
totalSupply += value;
emit Transfer(address(0), to, value);
return true;
}
function DOMAIN_SEPARATOR() external view returns (bytes32) {
return _domainSeparator();
}
}
library Address {
function isContract(address account) internal view returns (bool) {
uint256 size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
}
  1. Curve Vault Mock

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ICurveCrvUSDVault} from "../src/RAACProtocol/interfaces/ICurveCrvUSDVault.sol";
import {console} from "forge-std/Test.sol";
/**
* @title MockCurveVault
* @notice A simplified mock implementation of Curve's crvUSD vault for testing
*/
contract CurveVaultMock is ICurveCrvUSDVault {
using SafeERC20 for IERC20;
// State variables
IERC20 public immutable underlying;
uint256 public totalSupply;
bool public override isShutdown;
// User balances for shares
mapping(address => uint256) public balances;
// Mock yield rate (in basis points, e.g., 500 = 5%)
uint256 public mockYieldRate = 500;
// Last update timestamp for mock yield calculation
uint256 public lastUpdateTimestamp;
/**
* @notice Constructor
* @param _underlying Address of the underlying asset (e.g., crvUSD)
*/
constructor(address _underlying) {
require(_underlying != address(0), "Invalid underlying asset");
underlying = IERC20(_underlying);
lastUpdateTimestamp = block.timestamp;
}
/**
* @notice Deposits assets into the vault
* @param assets Amount of assets to deposit
* @param receiver Address to receive the shares
* @return shares Amount of shares minted
*/
function deposit(uint256 assets, address receiver) external override returns (uint256 shares) {
// Transfer assets from sender
underlying.transferFrom(msg.sender, address(this), assets);
emit Deposit(msg.sender, receiver, assets, shares);
return shares;
}
/**
* @notice Withdraws assets from the vault
* @param assets Amount of assets to withdraw
* @param receiver Address to receive the assets
* @param owner Owner of the shares
* @param maxLoss Maximum acceptable loss (ignored in mock)
* @param strategies Optional specific strategies (ignored in mock)
* @return shares Amount of shares burned
*/
function withdraw(uint256 assets, address receiver, address owner, uint256 maxLoss, address[] calldata strategies)
external
override
returns (uint256 shares)
{
// Transfer assets to receiver
underlying.safeTransfer(receiver, assets);
return shares;
}
/**
* @notice Gets the address of the underlying asset
* @return Address of the underlying asset
*/
function asset() external view override returns (address) {
return address(underlying);
}
/**
* @notice Gets the total assets in the vault
* @return Total assets including mock yield
*/
function totalAssets() external view override returns (uint256) {
return _calculateTotalAssetsWithYield();
}
/**
* @notice Gets the current price per share
* @return Price per share (1:1 in mock)
*/
function pricePerShare() external pure override returns (uint256) {
return 1e18; // 1:1 ratio for simplicity
}
/**
* @notice Gets total idle assets
* @return All assets (no strategies in mock)
*/
function totalIdle() external view override returns (uint256) {
return underlying.balanceOf(address(this));
}
/**
* @notice Gets total debt
* @return 0 (no strategies in mock)
*/
function totalDebt() external pure override returns (uint256) {
return 0;
}
// MOCK-SPECIFIC FUNCTIONS
/**
* @notice Sets the mock yield rate
* @param _yieldRate New yield rate in basis points
*/
function setMockYieldRate(uint256 _yieldRate) external {
mockYieldRate = _yieldRate;
}
/**
* @notice Sets the shutdown state
* @param _isShutdown New shutdown state
*/
function setShutdown(bool _isShutdown) external {
isShutdown = _isShutdown;
}
/**
* @notice Internal function to calculate total assets with mock yield
* @return Total assets including simulated yield
*/
function _calculateTotalAssetsWithYield() internal view returns (uint256) {
uint256 timeElapsed = block.timestamp - lastUpdateTimestamp;
uint256 baseAmount = underlying.balanceOf(address(this));
// Calculate yield: principal * rate * time / (10000 * 365 days)
uint256 yield = (baseAmount * mockYieldRate * timeElapsed) / (10000 * 365 days);
return baseAmount + yield;
}
}
  1. Oracle Mock

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract RAACHousePrices {
function tokenToHousePrice(uint256 tokenId) public pure returns (uint256 price) {
price = 10_000 ether;
}
function getLatestPrice(uint256 tokenId) public returns (uint256 price, uint256 lastUpdateTimestamp) {
price = 10_000 ether;
lastUpdateTimestamp = block.timestamp;
}
}

Recommendations

  1. Transfer the tokens from the RToken contract to the curveVault, instead of the LendingPool.

  2. RToken contract apprvoes the LendingPool to use it's crvUSD tokens. (there needs to be an extra added function)

Updates

Lead Judging Commences

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

Give us feedback!