Core Contracts

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

Incorrect Debt Token Accounting Due to Multiple Scaling Issues

Summary

The DebtToken contract and LendingPool interaction contains multiple arithmetic calculation errors related to scaling balances. These issues occur in three core functions: borrow(), mint(), _update() and repay(). The combination of these issues leads to incorrect debt accounting, where users' debt balances are improperly calculated and scaled, affecting the entire lending protocol's accounting system.

The user can not clear his full debt amount (because the _repay() function will revert) and accumulates more and more Debt tokens with each time he borrows.

The totalUsage of the LendingPool will accumulate over time resulting in incorrect rate calculations.

Vulnerability Details

The vulnerability stems from four interconnected issues in the debt token accounting system:

  • In LendingPool.borrow():

function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
// Sends unscaled amount to mint
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
// Updates with different scaled amount
user.scaledDebtBalance += scaledAmount;
}
  • In DebtToken.mint():

function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
// Incorrectly uses overridden balanceOf which applies scaling already
uint256 scaledBalance = balanceOf(onBehalfOf);
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
// Calculates balance increase using already scaled balance
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
// double scaled amount gets added to compounded amount
uint256 amountToMint = amount + balanceIncrease;
// mint calls _update
_mint(onBehalfOf, amountToMint.toUint128());
}
  • In DebtToken._update():

function _update(address from, address to, uint256 amount) internal virtual override {
// scales down amount here
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
super._update(from, to, scaledAmount);
// And emits event with unscaled amount
emit Transfer(from, to, amount);
}

In LendingPool.repay() (for completion the burn function is below):

function _repay(uint256 amount, address onBehalfOf) internal {
// we can pass in type(uint256).max as amount because the burn function caps it
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// user.scaledDebtBalance is lower (see borrow function),
// than amountBurned => panic revert underflow
user.scaledDebtBalance -= amountBurned;
}
function burn(
address from,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
uint256 userBalance = balanceOf(from);
// cap amount to user balance if amount > balance
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayDiv(index);
// _update function gets unscaled amount but scales it internally...
_burn(from, amount.toUint128());
// amountScaled = amountBurned in _repay function above
return (amount, totalSupply(), amountScaled, balanceIncrease);
}

The issue can be reproduced using the test provided below and by checking the printed out logs below the Test:

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";
import {stdError} from "forge-std/StdError.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_ScalingIssues() public {
// Setup
uint256 initialDeposit = 2000e18;
uint256 housePrice = 2000e18;
uint256 amountToBorrow = housePrice / 2;
// 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);
assertEq(crvusd.balanceOf(address(rToken)), 0);
// Lender deposits crvUSD to the pool
vm.startPrank(lender);
crvusd.approve(address(lendingPool), initialDeposit);
lendingPool.deposit(initialDeposit);
// crvUsd gets transferred to the rToken contract when deposited
assertEq(crvusd.balanceOf(address(rToken)), initialDeposit);
vm.stopPrank();
uint256 usageIndexStart = lendingPool.getNormalizedDebt();
// print reserve state
_printReserveState("at the beginning");
// 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(crvusd.balanceOf(borrower), 0);
assertEq(raacNFT.balanceOf(borrower), 1);
raacNFT.approve(address(lendingPool), TOKEN_ID);
lendingPool.depositNFT(TOKEN_ID);
assertEq(raacNFT.balanceOf(address(lendingPool)), 1);
// borrow half of the house price = 1000
lendingPool.borrow(amountToBorrow);
assertEq(crvusd.balanceOf(borrower), amountToBorrow);
_printReserveState("after first borrowing");
_printAddressBalance(borrower, "borrower");
// wait some time to accrue interest on DebtToken
vm.warp(block.timestamp + 10 days);
lendingPool.updateState();
// get new usage index
uint256 newUsageIndex = lendingPool.getNormalizedDebt();
console2.log("\nNew Usage Index");
console2.log("newUsageIndex", newUsageIndex);
uint256 scaledBalance = debtToken.scaledBalanceOf(borrower);
// Same as AAVE
uint256 expectedNewBalanceIncrease = scaledBalance.rayMul(newUsageIndex) -
scaledBalance.rayMul(usageIndexStart);
console2.log("\nExpected New Balance Increase");
console2.log("expectedNewBalanceIncrease", expectedNewBalanceIncrease);
// expected amount to mint
uint256 expectedAmountToMint = amountToBorrow + expectedNewBalanceIncrease;
console2.log("\nExpected Amount To Mint");
console2.log("expectedAmountToMint", expectedAmountToMint);
uint256 debtTokenBalanceBefore = debtToken.balanceOf(borrower);
// borrow again
lendingPool.borrow(amountToBorrow);
vm.stopPrank();
// expected scaled debt token balance after
uint256 expectedDebtTokenScaledBalanceAfter = debtTokenBalanceBefore + expectedAmountToMint;
console2.log("\nExpected Debt Token Scaled Balance After");
console2.log("expectedDebtTokenScaledBalanceAfter", expectedDebtTokenScaledBalanceAfter);
// actual scaled debt token balance after
uint256 actualDebtTokenScaledBalanceAfter = debtToken.scaledBalanceOf(borrower);
console2.log("\nActual Debt Token Scaled Balance After");
console2.log("actualDebtTokenScaledBalanceAfter", actualDebtTokenScaledBalanceAfter);
uint256 actualScaledDebtInLendingPool = _getUserScaledDebtBalanceFromStorage(borrower);
console2.log("\nActual Scaled Debt In Lending Pool");
console2.log("actualScaledDebtInLendingPool", actualScaledDebtInLendingPool);
assertNotEq(actualDebtTokenScaledBalanceAfter, expectedDebtTokenScaledBalanceAfter);
assertNotEq(actualScaledDebtInLendingPool, expectedDebtTokenScaledBalanceAfter);
uint256 differenceInDebtTokenContract = expectedDebtTokenScaledBalanceAfter - actualDebtTokenScaledBalanceAfter;
console2.log("\nDifference In Debt Token Contract");
console2.log("differenceInDebtTokenContract", differenceInDebtTokenContract);
uint256 differenceInLendingPool = expectedDebtTokenScaledBalanceAfter - actualScaledDebtInLendingPool;
console2.log("\nDifference In Lending Pool");
console2.log("differenceInLendingPool", differenceInLendingPool);
_printReserveState("after second borrowing");
// try to repay the debt
vm.startPrank(borrower);
// just mint directly to the borrower to ensure we have enough to repay
crvusd.mint(borrower, 10000e18);
// we can do type(uint256).max because the burn function will cap the amount to the users balance
crvusd.approve(address(lendingPool), type(uint256).max);
//! Expect arithmetic panic
// This call will revert because how the _repay() & burn() functions are implemented
vm.expectRevert(stdError.arithmeticError);
lendingPool.repay(type(uint256).max);
// now we repay the amount that's stored in the lending pool which is lower than the actual DebtToken balance of the borrower
uint256 userDebt = lendingPool.getUserDebt(borrower);
lendingPool.repay(userDebt);
vm.stopPrank();
// userScaledDebtBalance in LendingPool is 0 now but the DebtTokenBalance isn't
// so the next time the user borrows, the DebtToken mint() function will use this amount
// to calculate the balanceIncrease which will be higher and higher after each time
// the user borrows and repays his debt
// The total usage of the Liquidity pool is not 0 even though the user has repaid his accounted debt
// The user still has debt in the DebtToken contract though which he can't clear or burn
_printReserveState("after repayment");
_printAddressBalance(borrower, "borrower");
}
function _getUserScaledDebtBalanceFromStorage(address userAddress) internal view returns (uint256) {
// 1. You can run "forge inspect LendingPool storage" to get the storage slot of the userData mapping
bytes32 mapSlot = bytes32(uint256(18));
// 2. For mappings, we need to hash the key with the slot number
bytes32 slot = keccak256(abi.encode(userAddress, mapSlot));
// 3. The scaledDebtBalance is at slot 0 in the UserData struct
bytes32 scaledDebtBalanceSlot = bytes32(uint256(slot));
// 4. Load the value from storage
bytes32 value = vm.load(address(lendingPool), scaledDebtBalanceSlot);
return uint256(value);
}
function _printAddressBalance(address addr, string memory label) internal view {
console2.log("\nPrinting address balance for:", label);
console2.log("crvUSD balance", crvusd.balanceOf(addr));
console2.log("Balance of DebtToken", debtToken.balanceOf(addr));
console2.log("scaledBalance DebtToken", debtToken.scaledBalanceOf(addr));
uint256 userScaledDebtBalance = _getUserScaledDebtBalanceFromStorage(addr);
console2.log("\nUser Scaled Debt Balance in LendingPool:");
console2.log("userScaledDebtBalance", userScaledDebtBalance);
console2.log("================================================");
}
function _printReserveState(string memory label) internal view {
(
address reserveRTokenAddr,
address reserveAssetAddr,
address reserveDebtTokenAddr,
uint256 totalLiquidity,
uint256 totalUsage,
uint128 liquidityIndex,
uint128 usageIndex,
uint40 lastUpdateTimestamp
) = lendingPool.reserve();
uint256 underlyingCrvUsdReserve = crvusd.balanceOf(address(rToken));
console2.log("\nReserve Data:", label);
console2.log("totalLiquidity", totalLiquidity);
console2.log("totalUsage", totalUsage);
console2.log("liquidityIndex", liquidityIndex);
console2.log("usageIndex", usageIndex);
console2.log("lastUpdateTimestamp", lastUpdateTimestamp);
console2.log("underlyingCrvUsdReserve", underlyingCrvUsdReserve);
console2.log("================================================");
}
}
Logs:
Reserve Data: at the beginning
totalLiquidity 2000000000000000000000
totalUsage 0
liquidityIndex 1000000000000000000000000000
usageIndex 1000000000000000000000000000
lastUpdateTimestamp 172801
underlyingCrvUsdReserve 2000000000000000000000
================================================
Reserve Data: after first borrowing
totalLiquidity 1000000000000000000000
totalUsage 1000000000000000000000
liquidityIndex 1000000000000000000000000000
usageIndex 1000000000000000000000000000
lastUpdateTimestamp 172801
underlyingCrvUsdReserve 1000000000000000000000
================================================
Printing address balance for: borrower
crvUSD balance 1000000000000000000000
Balance of DebtToken 1000000000000000000000
scaledBalance DebtToken 1000000000000000000000
User Scaled Debt Balance in LendingPool:
userScaledDebtBalance 1000000000000000000000
================================================
New Usage Index
newUsageIndex 1019886942862361165769120316
Expected New Balance Increase
expectedNewBalanceIncrease 19886942862361165769
Expected Amount To Mint
expectedAmountToMint 1019886942862361165769
Expected Debt Token Scaled Balance After
expectedDebtTokenScaledBalanceAfter 2039773885724722331538
Actual Debt Token Scaled Balance After
actualDebtTokenScaledBalanceAfter 2000387778762321296953
Actual Scaled Debt In Lending Pool
actualScaledDebtInLendingPool 1980500835899960131185
Difference In Debt Token Contract
- differenceInDebtTokenContract 39386106962401034585
Difference In Lending Pool
- differenceInLendingPool 59273049824762200353
Reserve Data: after second borrowing
+ totalLiquidity 0
totalUsage 1961381889200520545996
liquidityIndex 1009845890410958904109589041
usageIndex 1019886942862361165769120316
lastUpdateTimestamp 1036801
underlyingCrvUsdReserve 0
================================================
Reserve Data: after repayment
totalLiquidity 2019886942862361165770
- totalUsage 19499164100039868814 // Should be 0 because no Liquidity is used anymore
liquidityIndex 1009845890410958904109589041
usageIndex 1019886942862361165769120316
lastUpdateTimestamp 1036801
underlyingCrvUsdReserve 2019886942862361165770
================================================
Printing address balance for: borrower
crvUSD balance 9980113057137638834230
Balance of DebtToken 20282433358771983485
- scaledBalance DebtToken 19886942862361165768
User Scaled Debt Balance in LendingPool:
- userScaledDebtBalance 0
================================================

Impact

This affects the whole accounting functionality of the protocol including but not limited to:

  • Incorrect debt token balances for users

  • Inconsistent accounting between LendingPool and DebtToken contracts

  • Wrong calculation of rates

  • User can't repay his full debt amoun

Tools Used

  • Manual Review

  • Foundry

Recommendations

Handle the scaling in the DebtToken mint/burn function and remove it from the _update function. Use correct balance in the mint function and use the actual scaled balance to store in the user.scaledDebtBalance mapping. Review all of the operations where user.scaledDebtBalance is used.

Updates

Lead Judging Commences

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

DebtToken::mint miscalculates debt by applying interest twice, inflating borrow amounts and risking premature liquidations

LendingPool::borrow tracks debt as user.scaledDebtBalance += scaledAmount while DebtToken mints amount+interest, leading to accounting mismatch and preventing full debt repayment

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

DebtToken::mint miscalculates debt by applying interest twice, inflating borrow amounts and risking premature liquidations

LendingPool::borrow tracks debt as user.scaledDebtBalance += scaledAmount while DebtToken mints amount+interest, leading to accounting mismatch and preventing full debt repayment

Support

FAQs

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