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
LendingPool::deposit DoS in cases of excess or shortage (very likely)
Cruve Vault Functionality is unusable
Tools Used
Manual Review, Foundry Test
PoC
Run foundryup
Run foundry init
Run forge install OpenZeppelin/openzeppelin-contracts --no-git and forge install https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable --no-git
Paste this in foundry.toml - remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts", "@openzeppelin/contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts"]
Then create a folder in src called RAACProtocol.
In the RAACProtocol create two folders called: interfaces, libraries and tokens
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
Into the libraries paste the following contracts:
Libraries:
PercentageMath.sol,
ReserveLibrary.sol,
WadRayMath.sol
Into the tokens paste the following contracts:
Tokens:
DebtToken.sol,
DEToken.sol,
RAACNFT.sol,
RAACToken.sol,
RToken.sol
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
Create file in the test folder called RAACProtocolTest.t.sol.
Paste the test setup.
Paste the test and the helper functions inside the StabilityAndLendingPoolTest contract.
Set Up
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);
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));
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
StabilityPool stabilityPoolImplementation = new StabilityPool(deployer);
bytes memory initData = abi.encodeWithSelector(
StabilityPool.initialize.selector,
address(rToken),
address(deToken),
address(raacToken),
address(makeAddr("RAACMinter")),
address(crvUSD),
address(lendingPool)
);
ERC1967Proxy proxy = new ERC1967Proxy(address(stabilityPoolImplementation), initData);
stabilityPool = StabilityPool(address(proxy));
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), deployer);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
lendingPool.setStabilityPool(address(stabilityPool));
stabilityPool.setRAACMinter(address(raacMinter));
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
raacToken.manageWhitelist(address(raacMinter), true);
vm.stopPrank();
}
}
PoC
function testCurveCrvUSDVaultFunctionalityNotWorking() public {
vm.startPrank(deployer);
lendingPool.setCurveVault(address(curveVault));
vm.stopPrank();
vm.startPrank(Bob);
uint256 bobDepositAmount = 5_000 ether;
crvUSD.mint(Bob, bobDepositAmount);
crvUSD.approve(address(lendingPool), bobDepositAmount);
vm.expectRevert();
lendingPool.deposit(bobDepositAmount);
vm.stopPrank();
}
Mocks:
CrvUSD Mock
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;
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;
}
}
Curve Vault Mock
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;
IERC20 public immutable underlying;
uint256 public totalSupply;
bool public override isShutdown;
mapping(address => uint256) public balances;
uint256 public mockYieldRate = 500;
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) {
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)
{
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;
}
* @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;
}
* @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));
uint256 yield = (baseAmount * mockYieldRate * timeElapsed) / (10000 * 365 days);
return baseAmount + yield;
}
}
Oracle Mock
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
Transfer the tokens from the RToken contract to the curveVault, instead of the LendingPool.
RToken contract apprvoes the LendingPool to use it's crvUSD tokens. (there needs to be an extra added function)