Summary
dust amount accrued inside RToken
is supposed to be transferred out by calling transferAccruedDust
. but there are flaw when calculating dust amount inside calculateDustAmount
function, leading to incorrectly state that there are no dust amount when actually there are discrepancy in balance between the asset
and the RToken
normalized balance.
Vulnerability Details
function calculateDustAmount
is used to check if
there are dust amount inside the RToken contract.
RToken.sol#L317-L328
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;
}
the function would compare if the contract's asset
balance would be greater thanRToken
total supply, if that the case then there are dust amount inside the contract that can be transferred out.
notice that the contract's asset balance is divided by the index, so we should compare it with scaled amount version of the rToken balance.
if we check the function totalSupply the amount returned already multiplied by current index income (normalized).
the issue is when calculating the totalRealBalance
where the amount of currentTotalSupply
that already multiplied by the index income are getting multiplied again, this would make the totalRealBalance
inflated so much that it makes the comparison of contractBalance <= totalRealBalance
would result to true, and making the dust amount reported by this function to be 0.
to run PoC provided, use the detailed step below:
Run npm i --save-dev @nomicfoundation/hardhat-foundry
in the terminal to install the hardhat-foundry plugin.
Add require("@nomicfoundation/hardhat-foundry");
to the top of the hardhat.config.cjs
file.
Run npx hardhat init-foundry in the terminal.
rename ReserveLibraryMock.sol
to ReserveLibraryMock.sol.bak
inside test/unit/libraries
folder so it does not throw error.
Create a file “Test.t.sol” in the “test/” directory and paste the provided PoC.
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/mocks/core/tokens/ERC20Mock.sol";
import "../contracts/libraries/math/WadRayMath.sol";
contract MockLendingPool {
RToken public rToken;
uint256 normalizedIncome;
constructor(address _rToken) {
rToken = RToken(_rToken);
normalizedIncome = 1e27;
}
function setNormalizedIncome(uint256 _normalizedIncome) external {
normalizedIncome = _normalizedIncome;
}
function getNormalizedIncome() external view returns (uint256) {
return normalizedIncome;
}
function mockMint(address caller, address onBehalfOf, uint256 amount) external returns (bool, uint256, uint256, uint256) {
return rToken.mint(caller, onBehalfOf, amount, normalizedIncome);
}
}
contract TestRToken is Test {
ERC20Mock public assetToken;
RToken public rToken;
MockLendingPool public lendingPool;
using WadRayMath for uint256;
address owner = makeAddr("owner");
address borrower = makeAddr("borrower");
address lender = makeAddr("lender");
address alice = makeAddr("alice");
function setUp() public {
vm.startPrank(owner);
assetToken = new ERC20Mock("Asset Token", "ATKN");
rToken = new RToken("RToken", "RTKN", owner, address(assetToken));
lendingPool = new MockLendingPool(address(rToken));
rToken.setReservePool(address(lendingPool));
assetToken.mint(address(borrower), 1000 ether);
assetToken.mint(address(lender), 1000 ether);
assetToken.mint(address(alice), 1000 ether);
vm.stopPrank();
}
function test_dustAmountNotCorrectlyCompared() public {
uint256 index = 1.1e27;
lendingPool.setNormalizedIncome(index);
uint256 amount = 100 ether;
lendingPool.mockMint(address(lendingPool), lender, amount);
vm.prank(lender);
assetToken.transfer(address(rToken), amount);
uint256 dustAmount = 10 ether;
vm.startPrank(lender);
assetToken.transfer(address(rToken), dustAmount);
vm.stopPrank();
uint256 scaledDustAmount = dustAmount.rayDiv(index);
uint256 calculatedDust = rToken.calculateDustAmount();
console.log("dust: ", calculatedDust);
assertEq(calculatedDust, scaledDustAmount);
}
}
run the following command forge t --mt test_dust -vv
the result would fail:
Ran 1 test for test/RToken.t.sol:TestRToken
[FAIL: assertion failed: 0 != 10000000000000000000] test_dustAmountNotCorrectlyCompared() (gas: 151616)
Logs:
asset token dust amount in contract: 0
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 1.17ms (179.45µs CPU time)
Ran 1 test suite in 8.70ms (1.17ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/RToken.t.sol:TestRToken
[FAIL: assertion failed: 0 != 10000000000000000000] test_dustAmountNotCorrectlyCompared() (gas: 151616)
Impact
the asset dust amount would be stuck inside the contract and cant be transferred out unless the dust amount is larger than total asset balance times index^2
Tools Used
manual review
Recommendations
because this function return value are getting used as the transfer amount of assetToken, then we should normalize all the value inside it before comparing it:
@@ -316,13 +325,13 @@ contract RToken is ERC20, ERC20Permit, IRToken, Ownable {
*/
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());
+ uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this));
// 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());
+ uint256 totalRealBalance = currentTotalSupply;
// All balance, that is not tied to rToken are dust (can be donated or is the rest of exponential vs linear)
return contractBalance <= totalRealBalance ? 0 : contractBalance - totalRealBalance;
}
verify the function by run the test again, the result should pass:
Ran 1 test for test/RToken.t.sol:TestRToken
[PASS] test_dustAmountNotCorrectlyCompared() (gas: 149937)
Logs:
asset token dust amount in contract: 10000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.21ms (165.83µs CPU time)
Ran 1 test suite in 8.64ms (1.21ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)