Core Contracts

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

Accrued leftover amounts can prevent borrowers from closing their liquidation positions, leaving their collateral at risk

Summary

By design a scaledDebtBalance is introduced which is used to keep track the borrowers "scaledBalance" (debt + any accrued interest on it). This variable updates everytime when a user borrows, repay or gets liquidated. However since the user's debt is calculated by multiplying it by the usageIndex (debt index), which fluctuates when users are borrowing and repaying, there will always be leftover amounts, which can prevent users from closing their liquidation positions or getting prematurely liquidated.

Vulnerability Details

Let's see where this variable is used in the LendingPool:

function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
...
// 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;
function _repay(uint256 amount, address onBehalfOf) internal {
...
// 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;

The initial usageIndex is set to 1e27 in RAY, but it changes when there are interactions with the pool. Consider the following scenario:

  1. A lender provides liquidity to the Lending pool, deposits 1000e18

  2. Bob deposits collateral worth of 1000e18 and after 1 hour he decides to borrow 500e18 tokens

  3. The usageIndex will increase from 1e27 to 1.000002853885350861862757133e27 (1000002853885350861862757133)

  4. His debt with accrued interest will increase from 500e18 (5e20) to (5.00001426942675430931e20 500001426942675430931)

  5. Below is a simple test in Foundry replicating the written above

    • Install Foundry

    • Run forge init --force in the terminal

    • Run forge test --mt testIncreaseTheUsageIndex -vv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console2} from "../lib/forge-std/src/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {LendingPool} from "../contracts/core/pools/LendingPool/LendingPool.sol";
import {ILendingPool} from "../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {RToken} from "../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../contracts/core/tokens/DebtToken.sol";
import {RAACHousePrices} from "../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../contracts/core/tokens/RAACNFT.sol";
import {IRAACNFT} from "../contracts/interfaces/core/tokens/IRAACNFT.sol";
import {WadRayMath} from "../contracts/libraries/math/WadRayMath.sol";
contract CrvUSD is IERC20, ERC20 {
constructor() ERC20("crvUSD", "CRVUSD") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
contract Tester is Test {
LendingPool lendingPool;
CrvUSD crvUSD;
RToken rToken;
DebtToken debtToken;
RAACHousePrices housePrices;
RAACNFT nft;
uint256 initialPrimeRate = 1e26;
address owner = makeAddr("owner");
address bob = makeAddr("bob");
address lender = makeAddr("lender");
function setUp() external {
vm.startPrank(owner);
crvUSD = new CrvUSD();
rToken = new RToken("RToken", "RT", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
housePrices = new RAACHousePrices(owner);
nft = new RAACNFT(address(crvUSD), address(housePrices), owner);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(nft),
address(housePrices),
initialPrimeRate
);
housePrices.setOracle(owner); // owner is set as oracle for simplicity
housePrices.setHousePrice(1, 1000e18); // tokenID = 1 will cost 1000e18 crvUSD
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
vm.stopPrank();
// Lender provides liquidity
vm.startPrank(lender);
crvUSD.mint(lender, 1000e18);
crvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(1000e18);
vm.stopPrank();
// Bob setup
vm.startPrank(bob);
crvUSD.mint(bob, 2000e18);
crvUSD.approve(address(lendingPool), type(uint256).max);
crvUSD.approve(address(nft), type(uint256).max);
nft.mint(1, 1000e18);
nft.setApprovalForAll(address(lendingPool), true);
vm.stopPrank();
}
function testIncreaseTheUsageIndex() public {
// Setup to increase the usageIndex (debt index)
vm.startPrank(bob);
console2.log("Initial usage index: %s", lendingPool.getNormalizedDebt()); // in RAY 1e27
lendingPool.depositNFT(1); // tokenId 1 is worth 1000e18
vm.warp(block.timestamp + 3600); // 1 hour passes
lendingPool.borrow(500e18);
console2.log("Current usage index: %s", lendingPool.getNormalizedDebt());
vm.stopPrank();
// Now let's calculate Bob's debt, which is: Debt token balance * usageIndex
uint256 currentDebt = WadRayMath.rayMul(debtToken.balanceOf(bob), lendingPool.getNormalizedDebt());
console2.log("Bob's debt (with accrued interest): %s", currentDebt);
}

Now let's track the update of scaledDebtBalance variable from borrowing to repaying process. When Bob borrows the scaledDebtBalance will increment by the borrowed amount (500e18):

Add this test to the setup above and run `forge test --mt testScaledAmountWhenBorrow` to confirm
function testScaledAmountWhenBorrow() public pure {
uint256 amount = 500e18;
uint256 usageIndex = 1e27;
assert(WadRayMath.rayDiv(amount, usageIndex) == 500e18);
}

Now let's say Bob wants to repay his debt, the LendingPool::_repay() will be invoked, which will decrement the scaledDebtBalance by the amountBurned returned from DebtToken::burn() function, which is Bob's debt.rayDiv(usageIndex) == 500001426942675430931.rayDiv(1000002853885350861862757133) = 500e18:

function burn(address from, uint256 amount, uint256 index) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
...
@> uint256 amountScaled = amount.rayDiv(index);
...
_burn(from, amount.toUint128());
emit Burn(from, amountScaled, index);
@> return (amount, totalSupply(), amountScaled, balanceIncrease);

amountBurned will be equal to 500e18 (inittially borrowed amount):

function testScaledAmountWhenBurn() public pure {
uint256 amount = 500001426942675430931; // debt amount with accrued interest
uint256 usageIndex = 1000002853885350861862757133; // current usageIndex
assert(WadRayMath.rayDiv(amount, usageIndex) == 500e18);
}

So scaledDebtBalance (500e18) will be decremented by 500e18, which is ideal scenario. But there will be other interactions from other users, and since the protocol will be deployed on Mainnet also, the tx's will be executed on highest fee order the repay call can be frontrunned by several tx's intentionally or unintentionally. That means the usageIndex will slightly change (increase when users are borrowing). If the index increases Bob's debt will be slightly less and the opposite:

Run `forge test --mt testScaledAmount -vv` to confirm
function testScaledAmount() public pure {
uint256 amount = 500001426942675430931;
uint256 usageIndex = 1000002853885350861862757133 + 2853885350861862757133;
assert(WadRayMath.rayDiv(amount, usageIndex) < 500e18);
uint256 leftoverAmount = 500e18 - WadRayMath.rayDiv(amount, usageIndex);
console2.log(leftoverAmount); // 1426934530815823 - this will be the the `scaledDebtBalance`
}

In case there is a liquidation initiated for Bob's position, even if he repays his debt, he won't be able to call LendingPool::closeLiquidation(), because his debt will be greater than the DUST_THRESHOLD (1e6):

function closeLiquidation() external nonReentrant whenNotPaused {
...
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
@> if (userDebt > DUST_THRESHOLD) revert DebtNotZero();

Impact

  • Impact: High, his collateral still can be seized even after repayment

  • Likelihood: High, happens with normal interactions

  • Overall: High

Tools Used

Manual Review

Recommendations

Non-trivial fix

Updates

Lead Judging Commences

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

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.

Give us feedback!