Core Contracts

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

RToken.sol::calculateDustAmount() unable to to execute due to incorrect implementation, resulting in dust stuck in contract indefinitely

Summary

The calculateDustAmount() function in the RToken contract performs inconsistent scaling operations when comparing CRVUSD balances to RToken supply, leading to incorrect dust calculations. The function scales down the actual CRVUSD balance while simultaneously double-scaling the RToken supply due to an existing scaling in totalSupply(). As a result, The dust accumulated in the contract can never be retrieved.

Vulnerability Details

The dust calculation attempts to compare the contract's CRVUSD balance with the total RToken supply to determine excess funds. However, the implementation contains two critical scaling issues:

// Incorrectly scales down actual CRVUSD balance
uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
// Gets already-scaled total supply
uint256 currentTotalSupply = totalSupply();
// Applies another scaling, resulting in double scaling
uint256 totalRealBalance = currentTotalSupply.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
  1. It compares the scaled down CRVUSD balance to the scaled up RToken balance.

  2. It double scales the RToken balance as the implementation totalSupply() scales the totalSupply by the liquidityIndex and then the function itself scales this value by the liquidityIndex again

The function checks: contractBalance <= totalRealBalance ? 0 : contractBalance - totalRealBalance

but the current implementation always evaluates this to:

Which will always return true, thus the function return 0.

transferAccruedDust() will always revert with noDust():

uint256 poolDustBalance = calculateDustAmount();
@> if(poolDustBalance == 0) revert NoDust();

Additionally, rescueToken() cannot be used to collect the dust due to this check as CRVUSD is the main asset:

function rescueToken(address tokenAddress, address recipient, uint256 amount) external onlyReservePool {
if (recipient == address(0)) revert InvalidAddress();
@> if (tokenAddress == _assetAddress) revert CannotRescueMainAsset();

Impact

High - Dust accumulation is completely irretrievable.

Likelihood

High - Current implementation means it will always happen.

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 file:

// 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 testIncorrectDustCalculation() public {
// We'll start by having a lender deposit a precise amount that will make the math clear
uint256 depositAmount = 1000e18; // 1000 CRVUSD
vm.startPrank(lender2);
mockCrvUSD.mint(lender2, depositAmount * 2);
mockCrvUSD.approve(address(lendingPool), depositAmount);
lendingPool.deposit(depositAmount);
vm.stopPrank();
// Record initial state
uint256 initialLiquidityIndex = lendingPool.getNormalizedIncome();
console.log("Initial Liquidity Index:", initialLiquidityIndex);
// Setup borrower with NFT and loan
vm.startPrank(borrower);
nft.mint(1, 100_000e18); // Mint NFT
lendingPool.depositNFT(1); // Deposit as collateral
lendingPool.borrow(500e18); // Borrow 500 CRVUSD (50% of deposit)
vm.stopPrank();
// Transfer some "dust" CRVUSD directly to rToken
uint256 dustAmount = 100e18;
vm.startPrank(lender2);
mockCrvUSD.transfer(address(rToken), dustAmount);
vm.stopPrank();
// Let time pass for interest to accrue
vm.warp(block.timestamp + 365 days);
// Force an interest rate update
lendingPool.updateState();
uint256 currentLiquidityIndex = lendingPool.getNormalizedIncome();
console.log("Current Liquidity Index:", currentLiquidityIndex);
uint256 actualBalance = mockCrvUSD.balanceOf(address(rToken));
console.log("Actual CRVUSD Balance:", actualBalance);
uint256 rTokenSupply = rToken.totalSupply();
console.log("rToken Total Supply:", rTokenSupply);
uint256 calculatedDust = rToken.calculateDustAmount();
console.log("Calculated Dust:", calculatedDust);
console.log("Actual Dust Should Be:", dustAmount);
}
  1. Run forge test -vvvv

  2. See the traces to confirm, output:

Logs:
Initial Liquidity Index: 1000000000000000000000000000
Current Liquidity Index: 1000250434759224066836387106
Actual CRVUSD Balance: 500600000000000000000000
rToken Total Supply: 501125467814371257485030
Calculated Dust: 0
Actual Dust Should Be: 100000000000000000000

Recommendations

  1. Ensure either the raw CRVUSD is being compared to the RToken * Liquidity supply or the inverse.

  2. Fix the double scaling

Updates

Lead Judging Commences

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

RToken::calculateDustAmount incorrectly applies liquidity index, severely under-reporting dust amounts and permanently trapping crvUSD in contract

Support

FAQs

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