Summary
The RToken contract contains a critical bug in its token transfer mechanism where amounts are incorrectly scaled twice by the liquidity index, resulting in users receiving fewer tokens than expected during transfers.
Vulnerability Details
The RToken contract implements an interest-bearing token system where token balances are scaled by a liquidity index to represent accrued interest. However, the current implementation contains a double scaling bug in the transfer process:
First scaling occurs in the transfer 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);
}
Second scaling occurs in the _update internal function:
function _update(address from, address to, uint256 amount) internal override {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}
The PoC demonstrates this issue by showing that when transferring 100 tokens with a liquidity index of 2 RAY:
-
Initial balance of user1: 200 tokens (100 * 2)
-
After transfer of 100 tokens to user2:
Expected behavior would be:
Impact
HIGH - The double scaling bug leads to:
Users receiving fewer tokens than they should when receiving transfers
Loss of value for users receiving transferred tokens
Tools Used
Manual Review
Unit test
Recommendations
Remove the scaling operation from the transfer function since scaling is already handled in the _update.
PoC
Put this in RToken.test.js, and run the test npx hardhat test test/unit/core/tokens/RToken.test.js --grep "Transfer amount is not scaled correctly"
describe("Transfer amount is not scaled correctly", function() {
it("Transfer amount is not scaled correctly", async function () {
await mockLendingPool.setRToken(rToken.target);
const mintAmount = ethers.parseEther("100");
const mintOnBehalfOf = user1.address;
const index = RAY;
await mockLendingPool.mockMint(reservePool.address, mintOnBehalfOf, mintAmount, index);
console.log("Balances when index is RAY")
console.log(`RToken balance: user1: ${ethers.formatEther(await rToken.balanceOf(user1.address))}`);
console.log(`RToken balance: user2: ${ethers.formatEther(await rToken.balanceOf(user2.address))}`);
const index2 = RAY * BigInt(2);
await mockLendingPool.mockGetNormalizedIncome(index2);
console.log("Balances when index is RAY * 2")
console.log(`RToken balance: user1: ${ethers.formatEther(await rToken.balanceOf(user1.address))}`);
console.log(`RToken balance: user2: ${ethers.formatEther(await rToken.balanceOf(user2.address))}`);
await rToken.connect(user1).transfer(user2.address, ethers.parseEther("100"));
console.log("After Transfer");
console.log(`RToken balance: user1: ${ethers.formatEther(await rToken.balanceOf(user1.address))}`);
console.log(`RToken balance: user2: ${ethers.formatEther(await rToken.balanceOf(user2.address))}`);
});
});
Output
Balances when index is RAY
RToken balance: user1: 100.0
RToken balance: user2: 0.0
Balances when index is RAY * 2
RToken balance: user1: 200.0
RToken balance: user2: 0.0
After Transfer
RToken balance: user1: 150.0
RToken balance: user2: 50.0