Core Contracts

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

Double Scaling in RToken Implementation Leads to Loss of Funds

Summary

In the RToken contract token amounts are being incorrectly scaled multiple times during transfer operations, leading to value loss during transfers and interactions with the StabilityPool.

Vulnerability Details

Double scaling occurs as amounts are scaled down in both transfer functions and update.

The vulnerability exists in these functions within RToken.sol:

function transfer(address recipient, uint256 amount) public override returns (bool) {
// transfer scales the input amount
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
function _update(address from, address to, uint256 amount) internal override {
// update gets called during super.transfer and scales again
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}

If a user interacts with the StabilityPool to provide RTokens in order to get DeTokens this breaks the invariant that DeTokens can be redeemd in a 1:1 ratio for RTokens because the broken transfer/update logic will lock up RTokens in the StabilityPool.

Below is a Proof of Concept that demonstrates the issue in detail:

  1. Lender deposits 4000 crvUSD into the LendingPool to receive the same amount in RToken

  2. Borrower, borrows 3000 crvUSD from the LendingPool

  3. Some time passes (100 days in the test) and the liquidityIndex (to calculate the interest on the RToken, similar to AAVE) of the underlying reserve updates

  4. The Lenders RToken balance increases to ~4783

  5. Lender then deposits 1000 RToken to the StabilityPool and gets the same amount in DeToken

  6. The StabilityPool now has a balance of 1000 RToken

  7. The Lender wants to withdraw 1000 DeToken => This should redeem all 1000 RToken from the Pool

  8. Instead of receiving all Tokens, he only received 836 RTokens

  9. 163 RTokens are locked in the StabilityPool now

PoC

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {crvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {RAACMinter, IRAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {PercentageMath} from "../../contracts/libraries/math/PercentageMath.sol";
import {ILendingPool} from "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {IStabilityPool} from "../../contracts/interfaces/core/pools/StabilityPool/IStabilityPool.sol";
import {WadRayMath} from "../../contracts/libraries/math/WadRayMath.sol";
contract FoundryTest is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
StabilityPool public stabilityPool;
LendingPool public lendingPool;
RAACMinter public raacMinter;
crvUSDToken public crvusd;
RToken public rToken;
DEToken public deToken;
RAACToken public raacToken;
RAACNFT public raacNFT;
DebtToken public debtToken;
RAACHousePrices public raacHousePrices;
address public owner;
uint256 public constant INITIAL_BALANCE = 1000e18;
uint256 public constant INITIAL_PRIME_RATE = 1e27;
uint256 constant INITIAL_BATCH_SIZE = 3;
uint256 constant HOUSE_PRICE = 100e18;
uint256 constant TOKEN_ID = 1;
function setUp() public {
// Setup accounts
owner = address(this);
// Deploy base tokens
crvusd = new crvUSDToken(owner);
crvusd.setMinter(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy price oracle and set oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner);
// Set initial house prices
raacHousePrices.setHousePrice(TOKEN_ID, HOUSE_PRICE);
// Deploy NFT
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
// Deploy pool tokens
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
// Deploy pools
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
INITIAL_PRIME_RATE
);
stabilityPool = new StabilityPool(owner);
// this is needed otherwise lastEmissionUpdateTimestamp will underflow in the RAACMinter constructor
vm.warp(block.timestamp + 2 days);
// Deploy RAAC minter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
// Setup cross-contract references
lendingPool.setStabilityPool(address(stabilityPool));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
deToken.transferOwnership(address(stabilityPool));
// Initialize Stability Pool
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvusd),
address(lendingPool)
);
// Setup permissions
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function test_RTokenScalingIssue() public {
// Setup
uint256 initialDeposit = 4000e18;
uint256 housePrice = 3000e18;
// Overwrite house price from setup to a higher value
raacHousePrices.setHousePrice(TOKEN_ID, housePrice);
address lender = makeAddr("lender");
address borrower = makeAddr("borrower");
// Mint required crvUSD for lender and borrower
crvusd.mint(lender, initialDeposit);
crvusd.mint(borrower, housePrice);
assertEq(crvusd.balanceOf(lender), initialDeposit);
assertEq(crvusd.balanceOf(borrower), housePrice);
// Lender deposits crvUSD and gets RTokens
vm.startPrank(lender);
crvusd.approve(address(lendingPool), initialDeposit);
lendingPool.deposit(initialDeposit);
vm.stopPrank();
// Record lender initial balances
uint256 rTokenBalanceLender = rToken.balanceOf(lender);
uint256 deTokenBalanceLender = deToken.balanceOf(lender);
console2.log("rTokenBalanceLender after deposit to LendingPool", rTokenBalanceLender);
console2.log("deTokenBalanceLender after deposit to LendingPool", deTokenBalanceLender);
console2.log("-----------------------------------------------------");
assertEq(rTokenBalanceLender, initialDeposit);
// borrower mints NFT and deposits it to the lending pool to borrow crvUSD
vm.startPrank(borrower);
crvusd.approve(address(raacNFT), housePrice);
raacNFT.mint(TOKEN_ID, housePrice);
assertEq(raacNFT.balanceOf(borrower), 1);
raacNFT.approve(address(lendingPool), TOKEN_ID);
lendingPool.depositNFT(TOKEN_ID);
assertEq(raacNFT.balanceOf(address(lendingPool)), 1);
lendingPool.borrow(housePrice);
vm.stopPrank();
// wait some time to accrue interest on RToken
vm.warp(block.timestamp + 100 days);
// updateState to get the real token balance
lendingPool.updateState();
// Record balances after borrow
rTokenBalanceLender = rToken.balanceOf(lender);
uint256 rTokenBalanceLenderSnapshot = rTokenBalanceLender;
deTokenBalanceLender = deToken.balanceOf(lender);
console2.log("rTokenBalanceLender after borrow:", rTokenBalanceLender);
console2.log("deTokenBalanceLender after borrow:", deTokenBalanceLender);
console2.log("-----------------------------------------------------");
assertGt(rTokenBalanceLender, initialDeposit, "Lender should have more RTokens when interest is accrued");
// lender transfers 1000 RTokens to StabilityPool to get deToken
uint256 transferAmount = 1000e18;
vm.startPrank(lender);
rToken.approve(address(stabilityPool), transferAmount);
stabilityPool.deposit(transferAmount);
// Check balances after deposit
rTokenBalanceLender = rToken.balanceOf(lender);
uint256 stabilityPoolRTokenBalance = rToken.balanceOf(address(stabilityPool));
deTokenBalanceLender = deToken.balanceOf(lender);
// 1 wei difference due to rounding
assertApproxEqRel(stabilityPoolRTokenBalance, transferAmount, 1);
console2.log("rTokenBalanceLender after deposit to StabilityPool:", rTokenBalanceLender);
console2.log("stabilityPoolRTokenBalance after deposit to StabilityPool:", stabilityPoolRTokenBalance);
console2.log("deTokenBalanceLender after deposit to StabilityPool:", deTokenBalanceLender);
console2.log("-----------------------------------------------------");
// withdraw all deToken from StabilityPool
deToken.approve(address(stabilityPool), deTokenBalanceLender);
stabilityPool.withdraw(deTokenBalanceLender);
vm.stopPrank();
// check balances after withdraw
rTokenBalanceLender = rToken.balanceOf(lender);
stabilityPoolRTokenBalance = rToken.balanceOf(address(stabilityPool));
deTokenBalanceLender = deToken.balanceOf(lender);
console2.log("rTokenBalanceLender after withdraw from StabilityPool:", rTokenBalanceLender);
console2.log("stabilityPoolRTokenBalance after withdraw from StabilityPool:", stabilityPoolRTokenBalance);
console2.log("deTokenBalanceLender after withdraw from StabilityPool:", deTokenBalanceLender);
console2.log("-----------------------------------------------------");
// The Lender ends up with less RTokens than before the deposit to StabilityPool
assertLt(rTokenBalanceLender, rTokenBalanceLenderSnapshot, "Should have less RTokens after withdraw");
// The Lender ends up with 0 deToken
assertEq(deTokenBalanceLender, 0);
// The StabilityPool should have 0 RTokens but instead it has the missing RTokens from the Lender
assertGt(stabilityPoolRTokenBalance, 0);
// Borrower repays his debt + interest
uint256 debt = debtToken.balanceOf(borrower);
vm.startPrank(borrower);
crvusd.mint(borrower, debt);
crvusd.approve(address(lendingPool), debt);
lendingPool.repay(debt);
vm.stopPrank();
assertEq(debtToken.balanceOf(borrower), 0);
// Lender wants to withdraw his RTokens for his initial crvUSD deposit
// we can pass in uint256.max and the withdraw function will withdraw the users
// total balance
vm.startPrank(lender);
rToken.approve(address(lendingPool), type(uint256).max);
lendingPool.withdraw(type(uint256).max);
vm.stopPrank();
// Check balances after withdraw
rTokenBalanceLender = rToken.balanceOf(lender);
deTokenBalanceLender = deToken.balanceOf(lender);
stabilityPoolRTokenBalance = rToken.balanceOf(address(stabilityPool));
uint256 crvusdBalanceLender = crvusd.balanceOf(lender);
uint256 crvusdStabilityPoolBalance = crvusd.balanceOf(address(stabilityPool));
uint256 missingCrvusd = rTokenBalanceLenderSnapshot - crvusdBalanceLender;
console2.log("rTokenBalanceLender after withdraw from LendingPool:", rTokenBalanceLender);
console2.log("deTokenBalanceLender after withdraw from LendingPool:", deTokenBalanceLender);
console2.log("crvusdBalanceLender after withdraw from LendingPool:", crvusdBalanceLender);
console2.log("crvusdStabilityPoolBalance after withdraw from LendingPool:", crvusdStabilityPoolBalance);
console2.log("stabilityPoolRTokenBalance after withdraw from LendingPool:", stabilityPoolRTokenBalance);
console2.log("-----------------------------------------------------");
console2.log("missingCrvusd:", missingCrvusd);
assertLt(crvusdBalanceLender, rTokenBalanceLenderSnapshot);
assertEq(missingCrvusd, stabilityPoolRTokenBalance);
assertEq(crvusdStabilityPoolBalance, 0);
assertEq(rTokenBalanceLender, 0);
assertEq(deTokenBalanceLender, 0);
}
}

If we look at the Logs we can see that the Lender who deposited RTokens to the StabilityPool ends up with less tokens because of the double scaling issue. The result is that the RTokens are locked in the StabilityPool without a way to transfer / rescue them.

Logs:
rTokenBalanceLender after deposit to LendingPool 4000000000000000000000
deTokenBalanceLender after deposit to LendingPool 0
-----------------------------------------------------
+ rTokenBalanceLender after borrow: 4783390410958904109589
deTokenBalanceLender after borrow: 0
-----------------------------------------------------
rTokenBalanceLender after deposit to StabilityPool: 3783390410958904109590
stabilityPoolRTokenBalance after deposit to StabilityPool: 999999999999999999999
deTokenBalanceLender after deposit to StabilityPool: 1000000000000000000000
-----------------------------------------------------
- rTokenBalanceLender after withdraw from StabilityPool: 4619617366391157555087
stabilityPoolRTokenBalance after withdraw from StabilityPool: 163773044567746554502
deTokenBalanceLender after withdraw from StabilityPool: 0
-----------------------------------------------------
rTokenBalanceLender after withdraw from LendingPool: 0
deTokenBalanceLender after withdraw from LendingPool: 0
crvusdBalanceLender after withdraw from LendingPool: 4619617366391157555087
crvusdStabilityPoolBalance after withdraw from LendingPool: 0
stabilityPoolRTokenBalance after withdraw from LendingPool: 163773044567746554502
-----------------------------------------------------
- missingCrvusd: 163773044567746554502

Impact

  • Direct Value Loss for the user

  • Trapped Value in the StabilityPool in the form of RToken

  • The problem compounds with multiple transfers and higher values

Tools Used

  • Foundry

  • Manual Review

Recommendations

Instead of trying to fix individual scaling issues, the RToken should be completely refactored to follow Aave's battle-tested token design. I can highly recommend reading this Documentation as it gives you a more detailed step by step guide (docs)[]. There is no need to reinvent the wheel here so just use the existing structure of the AToken.

Updates

Lead Judging Commences

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.