Summary
The RToken
contract performs double ray division on transfer amounts, once in the transfer/transferFrom
functions and again in the internal _update
function, leading to severely reduced transfer amounts.
Vulnerability Details
In RToken.sol, both the transfer
and transferFrom
functions perform a ray division on the input amount:
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
function transferFrom(address sender, address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
return super.transferFrom(sender, recipient, scaledAmount);
}
However, the internal _update
function also performs the same ray division:
function _update(address from, address to, uint256 amount) internal override {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}
This results in the amount being divided twice by the normalized income, reducing the actual transfer amount.
Impact
The double division causes all transfers to move significantly fewer tokens than intended. For example, if a user attempts to transfer 100 tokens with a normalized income of 2 RAY:
First division: 100 / 2 = 50
Second division: 50 / 2 = 25
The recipient receives only 25 tokens instead of the intended 100 tokens, effectively losing 75% of the value. This affects all token transfers in the system, making the token unusable for its intended purpose.
Tools Used
Manual review
Proof of Concept
Add the following test case to the test/unit/core/tokens/RToken.test.js
file inside the Basic functionality
describe block:
it("Transfer double scaled amount", async function () {
const balancebeforeUser1 = await rToken.balanceOf(user1.address);
const balancebeforeUser2 = await rToken.balanceOf(user2.address);
const newIndex = RAY + RAY;
await mockLendingPool.mockGetNormalizedIncome(newIndex);
const transferAmount = balancebeforeUser1 / 2n;
await rToken.connect(user1).transfer(user2.address, transferAmount);
const user1BalanceAfterTransfer = await rToken.balanceOf(user1.address);
const user2BalanceAfterTransfer = await rToken.balanceOf(user2.address);
expect(user1BalanceAfterTransfer).to.equal(balancebeforeUser1 - transferAmount / 2n);
expect(user2BalanceAfterTransfer).to.equal(balancebeforeUser2 + transferAmount / 2n);
});
Recommendations
Remove the ray division from the transfer
and transferFrom
functions, keeping it only in the _update
function:
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
- uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
- return super.transfer(recipient, scaledAmount);
+ return super.transfer(recipient, amount);
}
function transferFrom(address sender, address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
- uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
- return super.transferFrom(sender, recipient, scaledAmount);
+ return super.transferFrom(sender, recipient, amount);
}