Core Contracts

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

Multiple issues from unnecessary balance increase calculation in DebtToken.mint

Summary

When DebtToken is minted, it checks user's accrued debt interest from previous loan and increases mint amount by accrued debt.

However, this calculation is not necessary because accrued debt is already reflected on usageIndex increase.

This unnecessary calculation brings multiple issues including the following:

  • Second-time borrowers will get unfairly large accrued debt

  • Second-time borrowers won't be able to fully repay their debt

Vulnerability Details

Root Cause Analysis

Debt Inflation

Let's check how accrued debt interest is calculated in DebtToken.mint:

uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
_mint(onBehalfOf, amountToMint.toUint128());

Also do notice that balanceOfis calculated as the following:

function balanceOf(address account) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}

And when DebtToken is minted, ERC20's underlying balance is calculated as the follows:

function _update(address from, address to, uint256 amount) internal virtual override {
if (from != address(0) && to != address(0)) {
revert TransfersNotAllowed(); // Only allow minting and burning
}
uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
super._update(from, to, scaledAmount);
emit Transfer(from, to, amount);
}

To avoid mixed usage of scaledBalanceand scaledAmount, let's agree on some terms here:

  • underlyingBalanceis ERC20's native balance, i.e. underlyingBalance = DebtToken.balanceOf(user) / usageIndex

  • scaledBalance is DebtToken specific balance, i.e. scaledBalance = DebtToken.balanceOf(user)

Consider the following scenario:

  • Current usageIndex is 1

  • A user borrows 1000 USD from LendingPool

    • balanceIncreaseis 0

    • amountToMintis 1000

    • underlyingBalance is 1000 / usageIndex= 1000

    • Debt Token's user balance is underlyingBalance * usageIndex = 1000

  • usageIndex increased to 2

  • Same user borrows 1000 USD again from LendingPool

    • scaledBalanceis 1000 * usageIndex = 2000

    • balanceIncrease is scaledBalance * usageIndex - scaledBalance * previousUsageIndex = 2000 * 2 - 2000 * 1 = 2000

    • amountToMint is amount + balanceIncrease = 1000 + 2000 = 3000

    • Increased underlyingBalance is amountToMint / usageIndex = 3000 / 2 = 1500

    • New underlyingBalance is 1000 + 1500 = 2500

    • User's DebtToken balance is underlyingBalance * usageIndex = 2500 * 2 = 5000

Now check what this means in real world scenario:

  • The user initially had 1000 USD debt

  • After usageIndex increased to 2, the user now has 2000 USD debt

  • The user borrowed 1000 USD debt again

  • The user's total debt amount is 3000 USD

But according to DebtToken balance, the user's debt is 5000 USD.

You may ask, does DebtToken balance represents user's debt in USD? Yes, it is.

Check LendingPool._repay function:

(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// Transfer reserve assets from the caller (msg.sender) to the reserve
@> IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);

The LendingPool burns amount of DebtToken in change of transfeerring amountScaled to RToken.

So what is this amountScaled value?

Check DebtToken.burn implementation and you can see amountScaled is just the amount of asset user transferred to LendingPool:

function burn(
address from,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
if (from == address(0)) revert InvalidAddress();
if (amount == 0) {
return (0, totalSupply(), 0, 0);
}
uint256 userBalance = balanceOf(from);
uint256 balanceIncrease = 0;
if (_userState[from].index != 0 && _userState[from].index < index) {
uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
balanceIncrease = userBalance.rayMul(borrowIndex) - userBalance.rayMul(_userState[from].index);
@> amount = amount; // @audit amount doesn't change
}
_userState[from].index = index.toUint128();
if(amount > userBalance){
@> amount = userBalance; // @audit amount is capped by userBalance but it means userBalance represents user's actual debt in USD
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
_burn(from, amount.toUint128());
emit Burn(from, amountScaled, index);
@> return (amount, totalSupply(), amountScaled, balanceIncrease); // @audit amount unchanged
}

So the conclusion is user's debt is inflated when user borrows for the second time.

This brings another side effect: second-time borrowers can never repay their full debt.

Repay Failure

When user borrows from LendingPool, the pool will increase user's unscaledDebtBalance:

uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
// Mint DebtTokens to the user (scaled amount)
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
// Transfer borrowed amount to user
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;

When user repays their debt, unscaledDebtBalance is decreased by burnt amount:

(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// Transfer reserve assets from the caller (msg.sender) to the reserve
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
user.scaledDebtBalance -= amountBurned; // @audit underflow for second-time borrower

The problem arises if the user is a second-time borrower.

If we take a look into DebtToken.mintfunction:

uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
_mint(onBehalfOf, amountToMint.toUint128());

For a second-time borrower, current usageIndex is greater than usageIndex at borrowed time. So actual amountToMintwill be increased by accured debt interest.

In this case, DebtToken's unscaled balance will be greater than LendingPool's unscaledDebtBalance.

In order to repay full debt, the borrower needs to burn more unscaled balance than LendingPool's unscaledDebtBalance

Thus, the following line will revert with underflow error

user.scaledDebtBalance -= amountBurned;

POC

Secnario

  • Borrower takes a loan of 10_000 USD

  • 10 days pass

  • Borrower debt is around 10_009 USD

  • Borrower takes another loan of 10_000 USD

  • Borrower debt is 20_018 USD. However correct amount is 20_009USD

  • Borrower tries to repay their full debt (20_018) anyway, but it reverts with underflow

How to run POC

pragma solidity ^0.8.19;
import "../lib/forge-std/src/Test.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 {RAACToken} from "../contracts/core/tokens/RAACToken.sol";
import {RAACNFT} from "../contracts/core/tokens/RAACNFT.sol";
import {LendingPool} from "../contracts/core/pools/LendingPool/LendingPool.sol";
import {StabilityPool} from "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {RAACMinter} from "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {crvUSDToken} from "../contracts/mocks/core/tokens/crvUSDToken.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract RAACHousePricesMock {
mapping(uint256 => uint256) public prices;
function getLatestPrice(uint256 tokenId) external view returns (uint256, uint256) {
return (prices[tokenId], block.timestamp);
}
function setTokenPrice(uint256 tokenId, uint256 price) external {
prices[tokenId] = price;
}
function tokenToHousePrice(uint256 tokenId) external view returns (uint256) {
return prices[tokenId];
}
}
contract DebtTokenTest is Test {
RToken rtoken;
DebtToken debtToken;
RAACToken raacToken;
DEToken deToken;
RAACNFT raacNft;
RAACMinter raacMinter;
crvUSDToken asset;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACHousePricesMock housePrice;
address depositor = makeAddr("depositor");
address borrower = makeAddr("borrower");
address user = makeAddr("user");
uint256 tokenId = 1;
uint256 userAssetAmount = 10_000e18;
uint256 nftPrice = 50_000e18;
uint256 initialBurnTaxRate = 50;
uint256 initialSwapTaxRate = 100;
uint256 initialPrimeRate = 0.1e27;
function setUp() external {
vm.warp(1e9); // warp time stamp to avoid underflow in RAACMinter constructor
asset = new crvUSDToken(address(this));
housePrice = new RAACHousePricesMock();
debtToken = new DebtToken("DebtToken", "DTK", address(this));
rtoken = new RToken("RToken", "RTK", address(this), address(asset));
raacNft = new RAACNFT(address(asset), address(housePrice), address(this));
lendingPool = new LendingPool(
address(asset), address(rtoken), address(debtToken), address(raacNft), address(housePrice), 0.1e27
);
rtoken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken = new DEToken("DEToken", "DET", address(this), address(rtoken));
raacToken = new RAACToken(address(this), initialSwapTaxRate, initialBurnTaxRate);
raacToken.setMinter(address(this));
stabilityPool = new StabilityPool(address(this));
stabilityPool.initialize(
address(rtoken), address(deToken), address(raacToken), address(this), address(asset), address(lendingPool)
);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(this));
stabilityPool.setRAACMinter(address(raacMinter));
deToken.setStabilityPool(address(stabilityPool));
// depositor deposits 10_000 USD
uint256 depositorAmount = userAssetAmount * 10;
deal(address(asset), depositor, depositorAmount);
vm.startPrank(depositor);
asset.approve(address(lendingPool), depositorAmount);
lendingPool.deposit(depositorAmount);
uint256 rtokenBalance = rtoken.balanceOf(depositor);
// depositor deposits received rToken to stability pool
rtoken.approve(address(stabilityPool), rtokenBalance);
stabilityPool.deposit(rtokenBalance);
vm.stopPrank();
// borrower mints an NFT worth 50_000 USD
housePrice.setTokenPrice(tokenId, nftPrice);
deal(address(asset), borrower, nftPrice);
vm.startPrank(borrower);
asset.approve(address(raacNft), nftPrice);
raacNft.mint(tokenId, nftPrice);
raacNft.approve(address(lendingPool), tokenId);
// borrower deposits minted NFT to lending pool
lendingPool.depositNFT(tokenId);
// borrower borrows 10_000 USD from pool
lendingPool.borrow(userAssetAmount);
vm.stopPrank();
}
function testUsageIndex() external {
skip(10 days);
lendingPool.updateState();
emit log_named_decimal_uint("user debt", debtToken.balanceOf(borrower), 18);
vm.startPrank(borrower);
lendingPool.borrow(userAssetAmount);
vm.stopPrank();
emit log_named_decimal_uint("user debt", debtToken.balanceOf(borrower), 18);
uint256 debt = debtToken.balanceOf(borrower);
deal(address(asset), borrower, debt);
vm.startPrank(borrower);
asset.approve(address(lendingPool), debt);
vm.expectRevert(stdError.arithmeticError);
lendingPool.repay(debt);
vm.stopPrank();
}
}

Impact

  • Users who borrows from LendingPool for multiple times will get unfair accrued debt than linear rate.

  • Second-time borrowers won't be able to pay their full debt amount, due to underflow

  • LendingPool.getUserDebt()will return different value than DebtToken.balanceOf(user)

Tools Used

Manual Review, Foundry

Recommendations

balanceIncrease calculation is not necessary, as how it is ignored in DebtToken.burn function:

diff --git a/contracts/core/tokens/DebtToken.sol b/contracts/core/tokens/DebtToken.sol
index e70313d..a47e8ff 100644
--- a/contracts/core/tokens/DebtToken.sol
+++ b/contracts/core/tokens/DebtToken.sol
@@ -150,11 +150,6 @@ contract DebtToken is ERC20, ERC20Permit, IDebtToken, Ownable {
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
- if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
- balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
- }
-
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;

Rationale on why accrued debt calculation is unnecessary

Because accrued debt is already reflected on usageIndex.

If that's hard to understand, let's do another thought experiment. This time, we will not consider balanceIncrease:

  • Current usageIndex is 1

  • A user borrows 1000 USD from LendingPool

    • amountToMintis 1000

    • underlyingBalance is 1000 / usageIndex= 1000

    • Debt Token's user balance is underlyingBalance * usageIndex = 1000

  • usageIndex increased to 2

  • Same user borrows 1000 USD again from LendingPool

    • amountToMint is 1000

    • Increased underlyingBalance is amountToMint / usageIndex = 1000 / 2 = 500

    • New underlyingBalance is 1000 + 500 = 1500

    • User's DebtToken balance is underlyingBalance * usageIndex = 1500 * 2 = 3000

This tallys perfectly with real-world scenario calculation.

Updates

Lead Judging Commences

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

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

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

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

Support

FAQs

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