Core Contracts

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

User cannot fully repay their debt if they borrow multiple times

Summary

User cannot fully repay their debt if they borrow multiple times, due to incorrectly update to user's scaledDebtBalance.

Vulnerability Details

When a user borrows some reserve assets from LendingPool for the first time, assuming the borrowed amount is amount0 and reserve.usageIndex is usageIndex0, then the user's scaledDebtBalance is updated to amount0 / usageIndex0.

LendingPool.sol::borrow():

// Update user's scaled debt balance
@> 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;

Some DebtToken tokens are minted to the user, the minted amount is amount0 (balanceIncrease is 0).

DebtToken.sol::mint():

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

It is worth noting that DebtToken overrides ERC20's update() which is called by _mint(), and balances[user] is stored with amount0 / usageIndex0, same as the user's scaledDebtBalance.

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

As time passes and interest accrues, reserve.usageIndex becomes higher than usageIndex0 and is updated to usageIndex1 (through ReserveLibrary.updateReserveInterests()).

When user repays their debt, user's DebtToken tokens are burned and user's scaledDebtBalance is reduced by the burned DebtToken token amount.

LendingPool.sol::_repay():

// Burn DebtTokens from the user whose debt is being repaid (onBehalfOf)
// is not actualRepayAmount because we want to allow paying extra dust and we will then cap there
(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;

In the burn() function of DebtToken, the returned burned amount is calculated as user debtToken balance / usage index (assuming the user repay fully).

uint256 userBalance = balanceOf(from);
...
if(amount > userBalance){
@> amount = userBalance;
}
@> uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
_burn(from, amount.toUint128());
emit Burn(from, amountScaled, index);
return (amount, totalSupply(), amountScaled, balanceIncrease);

User's DebtToken balance increases over time as ERC20's balanceOf() is overriden.

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

Recall that balances[user] is amount0 / usageIndex0, then we have userBalance is (amount0 / usageIndex0) * usageIndex1, and amountScaled is userBalance / usageIndex1, the burned amount returned by burn() is amount0 / usageIndex0, the same as user's scaledDebtBalance, at last, user's scaledDebtBalance is set to 0.

Everything works fine if user borrows then repays, however, if user choose to borrow more reserve assets before repay, things would go wrong.

Assuming the borrowed amount is amount1, then:

  • The user's scaledDebtBalance is updated to amount0 / usageIndex0 + amount1 / usageIndex1;

  • The minted DebtToken token amount includes balanceIncrease which reflects the interests accured by the first borrow amount, therefore balances[user] is updated to amount0 / usageIndex0 + (amount1 + balanceIncrease) / usageIndex1.

If later when user then repays their debt (reserve.usageIndex is usageIndex2), then we have:

  • userBalance = (amount0 / usageIndex0 + (amount1 + balanceIncrease) / usageIndex1) * usageIndex2

  • amountScaled = userBalance / usageIndex2

    = amount0 / usageIndex0 + (amount1 + balanceIncrease) / usageIndex1

Because user's scaledDebtBalance is amount0 / usageIndex0 + amount1 / usageIndex1, less than the returned burned amount (i.e. amountBurned), and the repay transaction will revert due to underflow error.

LendingPool.sol::_repay()

user.scaledDebtBalance -= amountBurned;

Impact

User won't be able to fully repay their debt, and their deposited NFTs are locked forever.

POC

Please run forge test --mt testAudit_UserCannotRepay.

// 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";
contract Audit is Test {
address owner = makeAddr("Owner");
address curveUSDVault;
LendingPool lendingPool;
RAACHousePrices raacHousePrices;
crvUSDToken crvUSD;
RToken rToken;
DebtToken debtToken;
RAACNFT raacNft;
function setUp() public {
// vm.createSelectFork("https://eth.llamarpc.com");
// curveUSDVault = 0x0655977FEb2f289A4aB78af67BAB0d17aAb84367;
// crvUSD = 0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E;
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(curveUSDVault, "Curve USD Vault");
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_UserCannotRepay() public {
address bob = makeAddr("Bob");
crvUSD.mint(bob, 1000e18);
vm.startPrank(bob);
// Deposit
crvUSD.approve(address(lendingPool), 1000e18);
lendingPool.deposit(1000e18);
vm.stopPrank();
// Set house price
vm.prank(owner);
raacHousePrices.setHousePrice(1, 1000e18);
address alice = makeAddr("Alice");
crvUSD.mint(alice, 2000e18);
vm.startPrank(alice);
crvUSD.approve(address(raacNft), 1000e18);
raacNft.mint(1, 1000e18);
raacNft.approve(address(lendingPool), 1);
lendingPool.depositNFT(1);
lendingPool.borrow(100e18);
vm.stopPrank();
vm.warp(block.timestamp + 1 days);
vm.prank(alice);
lendingPool.borrow(100e18);
vm.warp(block.timestamp + 1 days);
// Cannot Repay
vm.startPrank(alice);
crvUSD.approve(address(lendingPool), 1000e18);
vm.expectRevert(stdError.arithmeticError);
lendingPool.repay(type(uint256).max);
}
}

Tools Used

Manual Review

Recommendations

When borrows, user's scaledDebtBalance should be updated with minted DebtToken token amount including balanceIncrease.

// Update user's scaled debt balance
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;
+ user.scaledDebtBalance += amountMinted;
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

Support

FAQs

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