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) {
...
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
@>>> user.scaledDebtBalance += scaledAmount;
function _repay(uint256 amount, address onBehalfOf) internal {
...
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
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:
A lender provides liquidity to the Lending pool, deposits 1000e18
Bob deposits collateral worth of 1000e18 and after 1 hour he decides to borrow 500e18 tokens
The usageIndex will increase from 1e27 to 1.000002853885350861862757133e27 (1000002853885350861862757133)
His debt with accrued interest will increase from 500e18 (5e20) to (5.00001426942675430931e20 500001426942675430931)
Below is a simple test in Foundry replicating the written above
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);
housePrices.setHousePrice(1, 1000e18);
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.startPrank(lender);
crvUSD.mint(lender, 1000e18);
crvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(1000e18);
vm.stopPrank();
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 {
vm.startPrank(bob);
console2.log("Initial usage index: %s", lendingPool.getNormalizedDebt());
lendingPool.depositNFT(1);
vm.warp(block.timestamp + 3600);
lendingPool.borrow(500e18);
console2.log("Current usage index: %s", lendingPool.getNormalizedDebt());
vm.stopPrank();
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;
uint256 usageIndex = 1000002853885350861862757133;
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);
}
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