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) {
uint256 contractBalance =
IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
uint256 currentTotalSupply = totalSupply();
uint256 totalRealBalance = currentTotalSupply.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
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();
@> if(poolDustBalance == 0) revert NoDust();
uint256 transferAmount = (amount < poolDustBalance) ? amount : poolDustBalance;
IERC20(_assetAddress).safeTransfer(recipient, transferAmount);
emit DustTransferred(recipient, transferAmount);
}
Proof of Concept
Let's walk through an example with concrete numbers:
-
Assume:
Contract's actual balance = 1000 tokens
Normalized income = 1.1 (10% interest accrued)
Base total supply = 1000 tokens
Base total supply = 1000 tokens
-
In calculateDustAmount:
contractBalance = 1000 rayDiv 1.1 = 909.09
currentTotalSupply = 1000 rayMul 1.1 = 1100
totalRealBalance = 1100 rayMul 1.1 = 1210
-
Therefore:
The error occurs because:
contractBalance is incorrectly scaled down by dividing by normalized income
totalSupply() already includes one scaling multiplication
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;
}
}
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;
vm.mockCall(
address(assestaddress), abi.encodeWithSelector(assestaddress.balanceOf.selector), abi.encode(initialBalance)
);
uint256 dust = rToken.calculateDustAmount();
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
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;
}