Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

Incorrect implementation of transfer() and transferFrom() in RToken.sol

Summary

The amount param which is in underlying asset units (i.e. crvUSD) gets scaled down twice when a user calls transfer() or transferFrom() --> once inside transfer()/transferFrom() and again inside the overriden _update():

File: contracts/core/tokens/RToken.sol
207: /**
208: * @dev Overrides the ERC20 transfer function to use scaled amounts
209: * @param recipient The recipient address
210: * @param amount The amount to transfer (in underlying asset units)
211: */
212: function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
213:@---> uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
214: return super.transfer(recipient, scaledAmount);
215: }
216:
217: /**
218: * @dev Overrides the ERC20 transferFrom function to use scaled amounts
219: * @param sender The sender address
220: * @param recipient The recipient address
221: * @param amount The amount to transfer (in underlying asset units)
222: */
223: function transferFrom(address sender, address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
224:@---> uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
225: return super.transferFrom(sender, recipient, scaledAmount);
226: }

and

File: contracts/core/tokens/RToken.sol
301: /**
302: * @dev Internal function to handle token transfers, mints, and burns
303: * @param from The sender address
304: * @param to The recipient address
305: * @param amount The amount of tokens
306: */
307: function _update(address from, address to, uint256 amount) internal override {
308: // Scale amount by normalized income for all operations (mint, burn, transfer)
309:@---> uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
310: super._update(from, to, scaledAmount);
311: }

Description & Impact

1. Impact on both transfer functions : Reduced tokens transferred

  • If liquidity index is 1.025, then a transfer of amount = 200 would result in only a transfer of 200 / (1.025 * 1.025) which is 190.362 in scaledBalance terms or 195.121 in balance terms. This should have been 195.121 in scaledBalance terms or 200 in balance terms.

2. Impact on transferFrom() : Funds can be stolen

  • If transferFrom() is used, then there are additional concerns:

    • First, Alice gives Bob an allowance of 200 as she wants to transfer these many underlying asset units to Charlie

    • Bob calls transferFrom(sender = alice, recipient = bob, amount = 200)

    • transferFrom() calculates scaledAmount = 200 / 1.025 = 195.121 and internally calls super.transferFrom(sender = alice, recipient = bob, amount = 195.121)

    • This calls ERC20.sol's transferFrom() which is:

    function transferFrom(address from, address to, uint256 value) public virtual returns (bool) {
    address spender = _msgSender();
    @---> _spendAllowance(from, spender, value);
    _transfer(from, to, value);
    return true;
    }
    • Bob's allowance is decreased by 195.121 via the internal call to _spendAllowance() leaving him with an unspent allowance of 200 - 195.121 = 4.9 approx

    • Additionally, the actual transfer is handled by the overridden _update() which scales this down again and transfers 195.121 / 1.025 = 190.362 tokens.

Bob can now use his leftover 4.9 allowance to steal & transfer these tokens to an address owned by him.

  • It's important to note that even if a fix is made inside RToken::transferFrom() and a call is made without scaling like so: return super.transferFrom(sender, recipient, amount);, the allowance reduction inside _spendAllowance() will reduce it by 200 but only transfer 195.121. Thus Bob won't be able to steal any tokens but can't retry a transfer now because his allowance is exhausted. Alice will have to provide additional allowance by calculating underlyingAmountToBeTransferred * liquidityIndex.

Proof of Concept

Add this inside test/unit/core/pools/LendingPool/LendingPool.test.js and run to see it pass with the following output:

it("should detect incorrect RToken transfers between users", async function () {
// log initial values
const rTokenInitialBalance = await rToken.balanceOf(user2.address);
const crvUSDInitialBalance = await crvusd.balanceOf(user2.address);
console.log("initial rToken balance =", ethers.formatEther(rTokenInitialBalance));
console.log("initial rToken balance (scaled) =", ethers.formatEther(await rToken.scaledBalanceOf(user2.address)));
expect(rTokenInitialBalance).to.equal(ethers.parseEther("1000"));
console.log("old liquidity index =", ethers.formatUnits(await lendingPool.getNormalizedIncome(), 27));
expect(await lendingPool.getNormalizedIncome()).to.equal(ethers.parseUnits("1", 27));
// Simulate index update to 1.025 (2.5% increase):
// User1 deposits NFT and borrows to increase liquidity index
const tokenId2 = 2;
const amountToPay2 = ethers.parseEther("600");
await raacHousePrices.setOracle(owner.address);
await raacHousePrices.setHousePrice(tokenId2, amountToPay2);
await token.mint(user1.address, amountToPay2);
await token.connect(user1).approve(raacNFT.target, amountToPay2);
await raacNFT.connect(user1).mint(tokenId2, amountToPay2);
await raacNFT.connect(user1).approve(lendingPool.target, tokenId2);
await lendingPool.connect(user1).depositNFT(tokenId2);
const borrowAmount = ethers.parseEther("400");
await lendingPool.connect(user1).borrow(borrowAmount);
// skip time
console.log("\nskipping time after borrow...\n");
await ethers.provider.send("evm_increaseTime", [365 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
await lendingPool.connect(user2).updateState();
let newLiquidityIndex = await lendingPool.getNormalizedIncome();
console.log("new liquidity index =", ethers.formatUnits(newLiquidityIndex, 27));
expect(newLiquidityIndex).to.be.closeTo(ethers.parseUnits("1.025", 27), ethers.parseUnits("1", 18));
console.log("mid rToken balance =", ethers.formatEther(await rToken.balanceOf(user2.address)));
console.log("mid rToken balance (scaled) =", ethers.formatEther(await rToken.scaledBalanceOf(user2.address)));
expect(await crvusd.balanceOf(user2.address)).to.equal(crvUSDInitialBalance);
expect(await rToken.balanceOf(user2.address)).to.be.closeTo(ethers.parseEther("1025"), ethers.parseEther("1"));
// do the transfer now
const rTokenBalance_BeforeTransfer_User1 = await rToken.balanceOf(user1.address);
const rTokenBalance_BeforeTransfer_User2 = await rToken.balanceOf(user2.address);
const transferAmount = ethers.parseEther("200");
await rToken.connect(user2).transfer(user1.address, transferAmount)
const rTokenBalance_AfterTransfer_User1 = await rToken.balanceOf(user1.address);
const rTokenBalance_AfterTransfer_User2 = await rToken.balanceOf(user2.address);
const balanceIncrease = rTokenBalance_AfterTransfer_User1 - rTokenBalance_BeforeTransfer_User1;
console.log("User1 rToken balance increased by =", ethers.formatEther(balanceIncrease));
console.log("User2 rToken balance decreased by =", ethers.formatEther(rTokenBalance_BeforeTransfer_User2 - rTokenBalance_AfterTransfer_User2));
expect(balanceIncrease).to.equal(rTokenBalance_BeforeTransfer_User2 - rTokenBalance_AfterTransfer_User2);
// ❌ @audit-issue - shows how `transferAmount` got scaled down once more:
expect(balanceIncrease).to.be.closeTo((transferAmount * BigInt(RAY)) / newLiquidityIndex, 1);
});

Output:

LendingPool
initial rToken balance = 1000.0
initial rToken balance (scaled) = 1000.0
old liquidity index = 1.0
skipping time after borrow...
new liquidity index = 1.025
mid rToken balance = 1025.0
mid rToken balance (scaled) = 1000.0
User1 rToken balance increased by = 195.121951219512195122 ❌ <----- should have been 200
User2 rToken balance decreased by = 195.121951219512195122 ❌ <----- should have been 200
✔ should detect incorrect RToken transfers between users (305ms)
1 passing (11s)

Mitigation

While the simple way to mitigate this would be to remove the scaledAmount calculation from inside RToken's transfer() and transferFrom() and pass amount while calling super.transfer() and super.transferFrom(), this won't solve the allowance issue in transferFrom().
It would either require a redesign of the approach so that it's easier for the owner to understand the allowance they should be giving the spender, or block the transferFrom() functionality altogether.

Updates

Lead Judging Commences

inallhonesty Lead Judge
6 months ago
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RToken::transfer and transferFrom double-scale amounts by dividing in both external functions and _update, causing users to transfer significantly less than intended

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RToken::transfer and transferFrom double-scale amounts by dividing in both external functions and _update, causing users to transfer significantly less than intended

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.