Core Contracts

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

StabilityPool Cannot Execute Liquidations Due to Missing crvUSD Transfer Mechanism

Summary

The StabilityPool.sol contract, which is responsible for liquidating unhealthy positions, lacks any mechanism to obtain or hold crvUSD tokens that are required to execute liquidations. While the StabilityPool can receive rTokens (which represent crvUSD deposits), it cannot access the actual crvUSD tokens needed for the liquidation process. This fundamental disconnect in token flow renders the liquidation mechanism non-functional.

Vulnerability Details

The vulnerability stems from an architectural misalignment between the token flow design and the liquidation requirements. Let's examine the critical path that reveals this issue:
When a position requires liquidation, the LendingPool expects the StabilityPool to provide crvUSD tokens:

// LendingPool.sol
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
// StabilityPool is expected to provide crvUSD here
@> IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
}

However, the token flow in the protocol never provides the StabilityPool with crvUSD. Instead:

  1. Users deposit crvUSD into the LendingPool, which transfers it to the rToken contract

  2. Users receive rTokens representing their deposit

  3. Users can deposit these rTokens into StabilityPool and receive deTokens

  4. The crvUSD remains locked in the rToken contract with no mechanism for the StabilityPool to access it

The StabilityPool contract has no functions that would allow it to:

  • Receive crvUSD directly

  • Convert rTokens to crvUSD

  • Access the crvUSD held by the rToken contract

Impact

High - Unhealthy positions cannot be liquidated and the protocol will build up bad debt and eventually become insolvent

Likelihood

High - Liquidations are an integral part of the system and this will occur every time.

Proof of Concept

  1. Convert the project into a foundry project, ensuring test in foundry.toml points to a designated test directory.

  2. Comment out the forking object from the hardhat.congif.cjs file:

networks: {
hardhat: {
mining: {
auto: true,
interval: 0
},
//forking: {
// url: process.env.BASE_RPC_URL,
//},
  1. Copy the following code into the test folder:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "contracts/core/tokens/DebtToken.sol";
import "contracts/core/tokens/RToken.sol";
import "contracts/core/tokens/RAACNFT.sol";
import "contracts/core/pools/LendingPool/LendingPool.sol";
import "contracts/core/primitives/RAACHousePrices.sol";
import "contracts/core/pools/StabilityPool/StabilityPool.sol";
import "contracts/core/tokens/RAACToken.sol";
import "contracts/core/tokens/DEToken.sol";
import "contracts/core/minters/RAACMinter/RAACMinter.sol";
import "contracts/libraries/math/PercentageMath.sol";
import "contracts/libraries/math/WadRayMath.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
contract MasterTest is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
// Core protocol tokens
DebtToken public debtToken;
RToken public rToken;
RAACNFT public nft;
RAACToken public raacToken;
DEToken public deToken;
// Protocol contracts
RAACHousePrices public priceOracle;
LendingPool public lendingPool;
StabilityPool public stabilityPool;
RAACMinter public raacMinter;
// Mock token
MockERC20 public mockCrvUSD;
// Test addresses
address borrower = address(0x1);
address lender = address(0x2);
address lender2 = address(0x3);
address treasury = address(0x4);
address repairFund = address(0x5);
address protocolOwner = address(0x999);
function setUp() public {
vm.warp(1000 days);
vm.startPrank(protocolOwner);
// Deploy independent contracts
mockCrvUSD = new MockERC20();
priceOracle = new RAACHousePrices(protocolOwner);
raacToken = new RAACToken(
protocolOwner, // initialOwner
100, // initialSwapTaxRate - 1%
50 // initialBurnTaxRate - 0.5%
);
rToken = new RToken(
"RToken",
"RTKN",
protocolOwner,
address(mockCrvUSD)
);
debtToken = new DebtToken(
"DebtToken",
"DEBT",
protocolOwner
);
deToken = new DEToken(
"DEToken",
"DETKN",
protocolOwner,
address(rToken)
);
nft = new RAACNFT(
address(mockCrvUSD),
address(priceOracle),
protocolOwner
);
lendingPool = new LendingPool(
address(mockCrvUSD),
address(rToken),
address(debtToken),
address(nft),
address(priceOracle),
1e27
);
// Deploy StabilityPool
stabilityPool = new StabilityPool(protocolOwner);
// Deploy RAACMinter with StabilityPool address
raacMinter = new RAACMinter(
address(raacToken),
address(stabilityPool),
address(lendingPool),
protocolOwner
);
// Initialize StabilityPool
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(mockCrvUSD),
address(lendingPool)
);
raacMinter = new RAACMinter(
address(raacToken),
address(stabilityPool),
address(lendingPool),
protocolOwner
);
// Set up contract connections as protocol owner
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
raacToken.setMinter(address(raacMinter));
lendingPool.setStabilityPool(address(stabilityPool));
// Fund test accounts
mockCrvUSD.mint(borrower, 100_000e18); // For NFT purchase
mockCrvUSD.mint(lender, 500_000e18); // For lending pool liquidity
vm.stopPrank();
// Set up user approvals
vm.startPrank(borrower);
mockCrvUSD.approve(address(lendingPool), type(uint256).max);
mockCrvUSD.approve(address(nft), type(uint256).max);
nft.setApprovalForAll(address(lendingPool), true);
vm.stopPrank();
// Set up oracle price
vm.startPrank(protocolOwner);
priceOracle.setOracle(protocolOwner);
priceOracle.setHousePrice(1, 100_000e18); // NFT worth 100,000
vm.stopPrank();
// Add liquidity to lending pool
vm.startPrank(lender);
mockCrvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(500_000e18);
vm.stopPrank();
}
function test_LiquidationReverts() public {
// User deposits NFT
vm.startPrank(borrower);
nft.mint(1, 100_000e18);
lendingPool.depositNFT(1);
// User borrows 50,000
uint256 borrowAmount = 50_000e18;
lendingPool.borrow(borrowAmount);
vm.stopPrank();
// Lender deposits 50000 RToken into StabilityPool
vm.startPrank(lender);
IERC20(address(rToken)).approve(address(stabilityPool), type(uint256).max);
stabilityPool.deposit(50000e18);
vm.stopPrank();
// Warp to 10 days
vm.warp(block.timestamp + 10 days);
lendingPool.updateState();
// Make position liquidatable
vm.startPrank(protocolOwner);
priceOracle.setHousePrice(1, 40_000e18);
vm.stopPrank();
// Initiate and attempt liquidation
lendingPool.initiateLiquidation(borrower);
vm.warp(block.timestamp + 4 days);
vm.startPrank(protocolOwner);
vm.expectRevert();
stabilityPool.liquidateBorrower(borrower);
vm.stopPrank();
// User still has NFT and isn't liquidated
assert(nft.balanceOf(borrower) == 1);
}
}
  1. Run forge test -vvvv to see the traces

├─ [0] VM::expectRevert(custom error f4844814:)
│ └─ ← [Return]
├─ [17523] StabilityPool::liquidateBorrower(ECRecover: [0x0000000000000000000000000000000000000001])
│ ├─ [875] LendingPool::getUserDebt(ECRecover: [0x0000000000000000000000000000000000000001]) [staticcall]
│ │ └─ ← [Return] 50473114766116630150770 [5.047e22]
│ ├─ [418] LendingPool::getNormalizedDebt() [staticcall]
│ │ └─ ← [Return] 1009462295322332603015397520 [1.009e27]
│ ├─ [2562] MockERC20::balanceOf(StabilityPool: [0x6E2D1356127cC8e65A42e859c6e02a86931A65C3]) [staticcall]
│ │ └─ ← [Return] 0
│ └─ ← [Revert] InsufficientBalance()

Recommendations

There are multiple different approaches that can be taken to fix this.

  1. Modify the liquidation mechanism to work with rTokens instead of requiring crvUSD

  2. Create a new mechanism for the StabilityPool to redeem rTokens for crvUSD specifically for liquidations

  3. Restructure the protocol's token flow to ensure the StabilityPool can maintain its own crvUSD reserves for liquidations, separate from the rToken system

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

StabilityPool design flaw where liquidations will always fail as StabilityPool receives rTokens but LendingPool expects it to provide crvUSD

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!