Summary
The RToken
contract's transfer function incorrectly scale amounts twice by dividing by the liquidity index in both the transfer() function and the internal _update() function, resulting in users receiving half of the intended transfer amount.
Vulnerability Details
The RToken::transfer function attempt to scale the transfer amount by dividing it by the liquidity index to convert from virtual token amount to the actual scaled token units. However, this function call super.transfer()
, which internally call the overridden _update() function that also performs the same scaling operation.
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 _update(address from, address to, uint256 amount) internal override {
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}
Proof of Concept:
Add the following test to the RToken.test.js file:
describe("Incorrect transfer/transferFrom when liquidity index is not 1", function () {
beforeEach(async function () {
await mockLendingPool.setRToken(rToken.target);
});
it("should not work correctly when transferring with a different liquidity index", async function () {
const mintAmount = ethers.parseEther("100");
const mintOnBehalfOf = user1.address;
const index = RAY;
await expect(mockLendingPool.mockMint(reservePool.address, mintOnBehalfOf, mintAmount, index))
.to.emit(rToken, "Mint")
.withArgs(reservePool.address, mintOnBehalfOf, mintAmount, index);
const user1BalanceBeforeLiquidityIndexIncrease = await rToken.balanceOf(user1.address);
expect(user1BalanceBeforeLiquidityIndexIncrease).to.equal(ethers.parseEther("100"));
await mockLendingPool.mockGetNormalizedIncome(ethers.parseUnits("2", 27));
const user1Balance = await rToken.balanceOf(user1.address);
expect(user1Balance).to.equal(ethers.parseEther("200"));
const user2Balance = await rToken.balanceOf(user2.address);
expect(user2Balance).to.equal(0);
await rToken.connect(user1).transfer(user2.address, user1Balance);
const user1BalanceAfter = await rToken.balanceOf(user1.address);
const user2BalanceAfter = await rToken.balanceOf(user2.address);
expect(user1BalanceAfter).to.equal(0);
expect(user2BalanceAfter).to.equal(user1Balance);
});
});
The test above will fail, because the user1BalanceAfter
is 100 ether, instead of 0, and the user2BalanceAfter
is 100 ether, instead of 200 ether. This can be seen by the logs in the console.
Impact
Recommendations
Remove scaling from transfer functions and rely only on _update
:
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);
}