Core Contracts

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

Double Usage Index Scaling in StabilityPool Liquidation Inflates Required CRVUSD Balance

Summary

The StabilityPool.sol::liquidateBorrower() function incorrectly scales user debt by the usage index twice when checking against the pool's CRVUSD balance. This occurs because the function multiplies an already-normalized debt value by the normalization factor again, artificially inflating the required CRVUSD balance needed for liquidation.

Vulnerability Details

The scaling error occurs in two steps:

  1. Here, the LendingPool.sol::getUserDebt() function returns the user debt multiplied by the usage index:

uint256 userDebt = lendingPool.getUserDebt(userAddress);
  1. Thereafter, it is scaled again:

uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
  1. It is then used as a check to see if there is enough CRVUSD in the StabilityPool.sol contract to liquidate the user:

// Uses double-scaled value for balance check
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();

What it should be:

Current implementation:

Impact

High - Considering usage index is designed to only go up, liquidating positions is made significantly harder as time passes and eventually, impossible as the funds needed would far surpass the total protocol liquidity.

Likelihood

High - This will occur for every liquidation attempt through the Stability Pool, making it a systematic issue rather than an edge case.

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 test into the directory:

// 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
// mockCrvUSD.mint(address(stabilityPool), 1_000_000e18); // For liquidations
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_doubleScaledLiquidation() public {
// Setup borrower
vm.startPrank(borrower);
nft.mint(1, 100_000e18);
lendingPool.depositNFT(1);
uint256 borrowAmount = 50_000e18;
lendingPool.borrow(borrowAmount);
vm.stopPrank();
// Record initial state
uint256 initialUsageIndex = lendingPool.getNormalizedDebt();
console.log("Initial Usage Index:", initialUsageIndex);
console.log("Initial Borrow Amount:", borrowAmount);
// Important: Set Stability Pool balance to be between correct and double-scaled amount
mockCrvUSD.mint(address(stabilityPool), 80_000e18); // New balance of 80k
// Time passes and interest accrues
vm.warp(block.timestamp + 365 days);
lendingPool.updateState();
uint256 currentUsageIndex = lendingPool.getNormalizedDebt();
uint256 normalizedDebt = lendingPool.getUserDebt(borrower);
uint256 doubleScaledDebt = WadRayMath.rayMul(normalizedDebt, currentUsageIndex);
console.log("Current Usage Index:", currentUsageIndex);
console.log("Normalized Debt (Already Scaled):", normalizedDebt);
console.log("Double-Scaled Debt:", doubleScaledDebt);
console.log("Stability Pool Balance:", mockCrvUSD.balanceOf(address(stabilityPool)));
// Add assertions to verify our understanding
assertTrue(normalizedDebt < mockCrvUSD.balanceOf(address(stabilityPool)),
"Stability Pool should have enough for actual debt");
assertTrue(doubleScaledDebt > mockCrvUSD.balanceOf(address(stabilityPool)),
"Stability Pool should not have enough for double-scaled debt");
// 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);
// Liquidation reverts
vm.startPrank(protocolOwner);
vm.expectRevert();
stabilityPool.liquidateBorrower(borrower);
vm.stopPrank();
}
  1. run forge test -vvvv

  2. confirm through traces, logs:

Ran 1 test for test-foundry/MasterTest.t.sol:MasterTest
[PASS] test_doubleScaledLiquidation() (gas: 647115)
Logs:
Initial Usage Index: 1000000000000000000000000000
Initial Borrow Amount: 50000000000000000000000
Current Usage Index: 1410226029899202994928355252
Normalized Debt (Already Scaled): 70511301494960149746418
Double-Scaled Debt: 99436872770263388970701
Stability Pool Balance: 80000000000000000000000

Recommendations

  • create consistency in usage of scaling, whether you scale through getUserDebt() or within StabilityPool.sol::liquidateBorrower().

Updates

Lead Judging Commences

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

StabilityPool::liquidateBorrower double-scales debt by multiplying already-scaled userDebt with usage index again, causing liquidations to fail

Support

FAQs

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