Core Contracts

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

Borrower's debt is double counted after they borrow more reserve assets

Summary

Borrower's debt is double counted after they borrow more reserve assets.

Vulnerability Details

When user borrows from LendingPool, DebtToken tokens are minted to the user.

LendingPool.sol::borrow()

// Mint DebtTokens to the user (scaled amount)
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);

If it's the first time user borrows, the minted DebtToken amount is calculated based on amount vaule user specified.

DebtToken.sol::mint()

uint256 amountToMint = amount + balanceIncrease;
_mint(onBehalfOf, amountToMint.toUint128());

Because DebtToken overrides ERC20's update(), under the hood, the actual stored value in _balances[user] is amount / usageIndex.

DebtToken.sol::_update()

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);
}

When the user borrows more reserve assets and more DebtTokens tokens will be minted. Protocol calls DebtToken's balanceOf() to retrieve user's current balance, and calculates balanceIncrease based on user balance. The newly minted token amount is amount + balanceIncrease.

DebtToken.sol::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());

It's import to note that DebtToken also overrides ERC20's balanceOf(), and this function essentially returns the user's accrued debt so far.

DebtToken.sol::balanceOf()

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

Assuming user borrowed 100 reserve assets when usageIndex was 1, to repay their debt when usageIndex is 1.1, they need to repay 100 / 1 * 1.1, i.e. 110 reserve assets.

The problem is that, when user borrows more reserve assets, balanceIncrease is minted to the user to reflect the accrued interest, but the user's existing tokens are not burned accordingly.

Suppose the newly borrowed amount is a and balanceIncrease is b, then _balance[user] is (100 / 1) + (a + b) / 1.1, if user repays in the same block, because they do not have to pay the interest for the newly borrowed amount, they are expected to repay 110 + a, however, the actually repaid amount is ((100 / 1) + (a + b) / 1.1)) * 1.1, i.e. 110 + a + b.

Therefore, the debt is double accounted.

Impact

User has to pay more funds to repay their debt.

POC

- user.scaledDebtBalance += scaledAmount;
+ user.scaledDebtBalance += amountMinted;
  • Change DebtToken.sol#L155 as below to fix the issue that balanceIncrease is not correctly calculated:

- balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
+ balanceIncrease = scaledBalance - scaledBalance.rayDiv(index).rayMul(_userState[onBehalfOf].index);
  • Run forge test --mt testAudit_DebtIsDoubleCounted:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console, stdError} from "forge-std/Test.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
contract Audit is Test {
using WadRayMath for uint256;
using SafeCast for uint256;
address owner = makeAddr("Owner");
LendingPool lendingPool;
RAACHousePrices raacHousePrices;
crvUSDToken crvUSD;
RToken rToken;
DebtToken debtToken;
RAACNFT raacNft;
function setUp() public {
raacHousePrices = new RAACHousePrices(owner);
crvUSD = new crvUSDToken(owner);
rToken = new RToken("RToken", "RToken", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
raacNft = new RAACNFT(address(crvUSD), address(raacHousePrices), owner);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNft),
address(raacHousePrices),
0.1e27
);
lendingPool.transferOwnership(owner);
vm.startPrank(owner);
raacHousePrices.setOracle(owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.label(address(crvUSD), "crvUSD");
vm.label(address(rToken), "RToken");
vm.label(address(debtToken), "DebtToken");
vm.label(address(raacNft), "RAAC NFT");
vm.label(address(lendingPool), "LendingPool");
}
function testAudit_DebtIsDoubleCounted() public {
vm.prank(owner);
lendingPool.setProtocolFeeRate(0.1e27);
address bob = makeAddr("Bob");
crvUSD.mint(bob, 2000e18);
// Deposit
vm.startPrank(bob);
crvUSD.approve(address(lendingPool), 2000e18);
lendingPool.deposit(2000e18);
vm.stopPrank();
// Set house price
vm.prank(owner);
raacHousePrices.setHousePrice(1, 2000e18);
address alice = makeAddr("Alice");
crvUSD.mint(alice, 3000e18);
// Borrow
vm.startPrank(alice);
crvUSD.approve(address(raacNft), 2000e18);
raacNft.mint(1, 2000e18);
raacNft.approve(address(lendingPool), 1);
lendingPool.depositNFT(1);
lendingPool.borrow(1000e18);
vm.stopPrank();
// Calculate expected debt in one year
uint256 expectedDebtIn1Year;
{
(, , , , , uint128 usageIndex, , ) = getPoolReserve();
(, uint256 currentUsageRate, , , , , , ) = getPoolRateData();
{
uint256 interestFactor = calculateCompoundedInterest(
currentUsageRate,
365 days
);
uint128 usageIndexIn1Year = uint256(usageIndex)
.rayMul(interestFactor)
.toUint128();
uint256 debtTokenBalance = debtToken.balanceOf(alice);
expectedDebtIn1Year = debtTokenBalance.rayMul(
usageIndexIn1Year
);
}
}
vm.warp(block.timestamp + 365 days);
uint256 crvUsdBalanceBeforeRepay = crvUSD.balanceOf(alice);
// Borrow and Repay in the same block
vm.startPrank(alice);
// Borrow
lendingPool.borrow(1);
crvUSD.approve(address(lendingPool), 2000e18);
// Repay
lendingPool.repay(type(uint256).max);
vm.stopPrank();
uint256 crvUsdBalanceAfterRepay = crvUSD.balanceOf(alice);
uint256 actualDebt = crvUsdBalanceBeforeRepay - crvUsdBalanceAfterRepay;
// User paid more debt than expected
assertEq(expectedDebtIn1Year + 1, 1074521020541182900729);
assertEq(actualDebt, 1149042041082365801455);
}
function getPoolReserve()
public
view
returns (
address reserveRTokenAddress,
address reserveAssetAddress,
address reserveDebtTokenAddress,
uint256 totalLiquidity,
uint256 totalUsage,
uint128 liquidityIndex,
uint128 usageIndex,
uint40 lastUpdateTimestamp
)
{
return lendingPool.reserve();
}
function getPoolRateData()
public
view
returns (
uint256 currentLiquidityRate,
uint256 currentUsageRate,
uint256 primeRate,
uint256 baseRate,
uint256 optimalRate,
uint256 maxRate,
uint256 optimalUtilizationRate,
uint256 protocolFeeRate
)
{
return lendingPool.rateData();
}
function calculateCompoundedInterest(
uint256 rate,
uint256 timeDelta
) internal pure returns (uint256) {
if (timeDelta < 1) {
return WadRayMath.RAY;
}
uint256 SECONDS_PER_YEAR = 31536000;
uint256 ratePerSecond = rate.rayDiv(SECONDS_PER_YEAR);
uint256 exponent = ratePerSecond.rayMul(timeDelta);
// Will use a taylor series expansion (7 terms)
return WadRayMath.rayExp(exponent);
}
}

Tools Used

Manual Review

Recommendations

If balanceIncrease is non-zero, burn some DebtToken tokens based on the current usageIndex.

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

Lead Judging Commences

inallhonesty Lead Judge 5 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.