Core Contracts

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

Incorrect dust amount calculation leads to stuck funds in RToken contract

Summary

The RToken::calculateDustAmount() function incorrectly scales balances by the liquidity index, leading to wrong dust calculations that prevent legitimate dust from being claimed via transferAccruedDust().

Vulnerability Details

The dust calculation in RToken::calculateDustAmount() contains two critical errors:

1) The contract balance is incorrectly scaled down by the liquidity index:

uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this))
.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());

The contract's balance represents actual underlying tokens and should not be scaled by the liquidity index.

2) The total supply is scaled up twice:

uint256 currentTotalSupply = totalSupply(); // Already scaled by liquidity index
uint256 totalRealBalance = currentTotalSupply
.rayMul(ILendingPool(_reservePool).getNormalizedIncome()); // Scaled again

The totalSupply() function already returns a value scaled by the liquidity index, so scaling it again results in an inflated total balance.

Proof of Concept:

Add the following test to the RToken.test.js file:

describe("Incorrect Dust Amount Calculation, when the liquidity index is not 1", function () {
beforeEach(async function () {
// Set rToken in mock
await mockLendingPool.setRToken(rToken.target);
});
it("should not work correctly when calculating the dust amount", async function () {
// Simulate minting of 100 ether rTokens - transfer 100 ether of assets to the rToken and mint 100 ethers rTokens (Liquidity index is 1)
await mockAsset.mint(rToken.target, ethers.parseEther("100"));
await expect(mockLendingPool.mockMint(reservePool.address, user1.address, ethers.parseEther("100"), RAY))
.to.emit(rToken, "Mint")
.withArgs(reservePool.address, user1.address, ethers.parseEther("100"), RAY);
let assetBalance = await mockAsset.balanceOf(rToken.target);
let scaledSupply = await rToken.scaledTotalSupply();
let currentTotalSupply = await rToken.totalSupply();
let normalizedIncome = await mockLendingPool.getNormalizedIncome();
expect(assetBalance).to.equal(ethers.parseEther("100"));
expect(scaledSupply).to.equal(ethers.parseEther("100"));
expect(currentTotalSupply).to.equal(ethers.parseEther("100"));
expect(normalizedIncome).to.equal(RAY);
// Set the normalized income (liquidity index) to 2, so we have provided 100 ether assets as yield
await mockLendingPool.mockGetNormalizedIncome(ethers.parseUnits("2", 27));
await mockAsset.mint(rToken.target, ethers.parseEther("100"));
// Validate, that the yield is accounted for correctly
assetBalance = await mockAsset.balanceOf(rToken.target);
currentTotalSupply = await rToken.totalSupply();
normalizedIncome = await mockLendingPool.getNormalizedIncome();
scaledSupply = await rToken.scaledTotalSupply();
expect(assetBalance).to.equal(ethers.parseEther("200"));
expect(scaledSupply).to.equal(ethers.parseEther("100"));
expect(currentTotalSupply).to.equal(ethers.parseEther("200")); // The total supply
expect(normalizedIncome).to.equal(ethers.parseUnits("2", 27));
//Mint some dust
await mockAsset.mint(rToken.target, ethers.parseEther("1"));
expect(await rToken.calculateDustAmount()).to.not.equal(ethers.parseEther("1"));
});
})

Impact

  • Dust calculations will be incorrect, preventing the owner from claiming legitimate dust via transferAccruedDust

  • Funds can become permanently stuck in the contract

  • The accounting system becomes unreliable for tracking dust amounts

Recommendations

Option 1: Remove incorrect scaling

function calculateDustAmount() public view returns (uint256) {
- uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this))
- .rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
+ uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this));
uint256 currentTotalSupply = totalSupply();
- uint256 totalRealBalance = currentTotalSupply
- .rayMul(ILendingPool(_reservePool).getNormalizedIncome());
- return contractBalance <= totalRealBalance ? 0 : contractBalance - totalRealBalance;
+ return contractBalance <= currentTotalSupply ? 0 : contractBalance - currentTotalSupply;
}

Option 2: Use scaled balances consistently

function calculateDustAmount() public view returns (uint256) {
uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this));
- uint256 currentTotalSupply = totalSupply();
+ uint256 scaledTotalSupply = scaledTotalSupply();
- uint256 totalRealBalance = currentTotalSupply
- .rayMul(ILendingPool(_reservePool).getNormalizedIncome());
- return contractBalance <= totalRealBalance ? 0 : contractBalance - totalRealBalance;
+ uint256 index = ILendingPool(_reservePool).getNormalizedIncome();
+ return contractBalance <= scaledTotalSupply.rayMul(index) ? 0 : contractBalance - scaledTotalSupply.rayMul(index);
}
Updates

Lead Judging Commences

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