Core Contracts

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

Incorrect Dust Calculation Makes Dust Recovery Impossible

Summary

The RToken contract has a critical arithmetic error in its calculateDustAmount function that causes it to always return 0, making it impossible to recover dust through the LendingPool::transferAccruedDust function.

Vulnerability Details

Source

The issue lies in the double scaling of values in calculateDustAmount:

function calculateDustAmount() public view returns (uint256) {
// Calculate the actual balance of the underlying asset held by this contract
uint256 contractBalance =
IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
// Calculate the total real obligations to the token holders
uint256 currentTotalSupply = totalSupply();
// Calculate the total real balance equivalent to the total supply
uint256 totalRealBalance = currentTotalSupply.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
// All balance, that is not tied to rToken are dust
return contractBalance <= totalRealBalance ? 0 : contractBalance - totalRealBalance;
}
function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
return super.totalSupply().rayMul(ILendingPool(_reservePool).getNormalizedIncome());
}
function transferAccruedDust(address recipient, uint256 amount) external onlyReservePool {
if (recipient == address(0)) revert InvalidAddress();
uint256 poolDustBalance = calculateDustAmount();
//@audit will revert
@> if(poolDustBalance == 0) revert NoDust();
// Cap the transfer amount to the actual dust balance
uint256 transferAmount = (amount < poolDustBalance) ? amount : poolDustBalance;
// Transfer the amount to the recipient
IERC20(_assetAddress).safeTransfer(recipient, transferAmount);
emit DustTransferred(recipient, transferAmount);
}

Proof of Concept

Let's walk through an example with concrete numbers:

  1. Assume:

    • Contract's actual balance = 1000 tokens

    • Normalized income = 1.1 (10% interest accrued)

    • Base total supply = 1000 tokens

    • Base total supply = 1000 tokens

  2. In calculateDustAmount:

    contractBalance = 1000 rayDiv 1.1 = 909.09
    currentTotalSupply = 1000 rayMul 1.1 = 1100
    totalRealBalance = 1100 rayMul 1.1 = 1210
  3. Therefore:

    • contractBalance (909.09) is ALWAYS less than totalRealBalance (1210)

    • Function ALWAYS returns 0

The error occurs because:

  1. contractBalance is incorrectly scaled down by dividing by normalized income

  2. totalSupply() already includes one scaling multiplication

  3. totalRealBalance applies a second scaling multiplication

Proof of code

Set up foundry in hardhat here

Foundry Test

import {Test} from "forge-std/Test.sol";
import {RToken} from "../contracts/core/tokens/RToken.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import {ERC20} from "node_modules/@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {console} from "forge-std/console.sol";
contract MockLendingPool {
function getNormalizedIncome() external view returns (uint256) {
return 1.1e27; // seting the Liquidity index to 1.1 in ray
//
}
}
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
}
contract RTokenPoc is Test {
using WadRayMath for uint256;
RToken rToken;
address owner;
address user1;
address user2;
ERC20 assestaddress;
MockLendingPool lendingPool;
function setUp() public {
lendingPool = new MockLendingPool();
assestaddress = new MockERC20("testassest", "TAA");
owner = address(this);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
rToken = new RToken("RToken", "RT", owner, address(assestaddress));
rToken.setReservePool(address(lendingPool));
vm.prank(address(lendingPool));
rToken.mint(address(lendingPool), user1, 1000e18, WadRayMath.RAY);
}
function testDustCalculationAlwaysZero() public {
uint256 initialBalance = 1000e18;
uint256 normalizedIncome = 1.1e27; // 10% interest
// Mock balances and rates
vm.mockCall(
address(assestaddress), abi.encodeWithSelector(assestaddress.balanceOf.selector), abi.encode(initialBalance)
);
// Calculate dust
uint256 dust = rToken.calculateDustAmount();
// Dust is always 0 regardless of actual dust present
assertEq(dust, 0);
}

Impact

  • Impossible to recover any dust tokens from the contract

  • Lost value for the protocol

  • LendingPool::transferAccruedDust function is effectively useless

Tools Used

  • Manual code review

Recommendations

Fix the double scaling issue in calculateDustAmount:

function calculateDustAmount() public view returns (uint256) {
// Get actual balance without scaling
- uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
+ uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this));
// Get base total supply (not scaled)
- uint256 currentTotalSupply = totalSupply();
+ uint256 currentTotalSupply = super.totalSupply();
// Apply scaling once
uint256 totalRealBalance = currentTotalSupply.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
return contractBalance <= totalRealBalance ? 0 : contractBalance - totalRealBalance;
}
Updates

Lead Judging Commences

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

Give us feedback!