Core Contracts

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

User can repay their debt after grace period ends and liquidation has been initiated which reduces liquidation risk for borrowers

Summary

Users with unhealthy positions can repay their debt after the liquidation process has been initiated, allowing them to avoid liquidation. This undermines the protocol's liquidation mechanism and poses risks to the stability of the system.

Vulnerability Details

When a user borrows via LendingPool::borrow , they are allowed to repay their debt using LendingPool::_repay. See function below:

/**
* @notice Internal function to repay borrowed reserve assets
* @param amount The amount to repay
* @param onBehalfOf The address of the user whose debt is being repaid. If address(0), msg.sender's debt is repaid.
* @dev This function allows users to repay their own debt or the debt of another user.
* The caller (msg.sender) provides the funds for repayment in both cases.
* If onBehalfOf is set to address(0), the function defaults to repaying the caller's own debt.
*/
function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
UserData storage user = userData[onBehalfOf];
// Update reserve state before repayment
ReserveLibrary.updateReserveState(reserve, rateData);
// Calculate the user's debt (for the onBehalfOf address)
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(onBehalfOf);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
// If amount is greater than userDebt, cap it at userDebt
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
// 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;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit Repay(msg.sender, onBehalfOf, actualRepayAmount);
}

There is a health factor implemented in RAAC which calculates the health of a user's position and 2 factors are used to determine if a user can be liquidated. The first is the health factor being below a target set by RAAC. If the user's health factor is below target, there is a grace period also configured by RAAC that gives the user an amount of time to get their position back healthy. If the user defaults on this and the grace period passes, LendingPool::initiateLiquidation can be called by any user which begins the liquidation process for the user with the unhealthy position. See function below:

/**
* @notice Allows anyone to initiate the liquidation process if a user's health factor is below threshold
* @param userAddress The address of the user to liquidate
*/
function initiateLiquidation(
address userAddress
) external nonReentrant whenNotPaused {
if (isUnderLiquidation[userAddress])
revert UserAlreadyUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
UserData storage user = userData[userAddress];
uint256 healthFactor = calculateHealthFactor(userAddress);
if (healthFactor >= healthFactorLiquidationThreshold)
revert HealthFactorTooLow();
isUnderLiquidation[userAddress] = true;
liquidationStartTime[userAddress] = block.timestamp;
emit LiquidationInitiated(msg.sender, userAddress);
}

The issue occurs where any user with any unhealthy position can still repay their debt after LendingPool::initiateLiquidation is called. LendingPool::initiateLiquidation is the initialization process of a liquidation. Once this function is called, the stability pool then calls a function to liquidate the user at any point. At any time between these 2 functions, the user with the unhealthy position could simply repay their debt and stop themselves from being liquidated which shouldn't be the case as once a user's position is to be liquidated, they should not be able to repay.

Proof Of Code (POC)

This test was run in LendingPool.test.js in the "borrow and repay" describe block

it("user can repay their debt after grace period ends and liquidation has been initiated", async function () {
//c for testing purposes
//c for testing purposes
const userCollateral = await lendingPool.getUserCollateralValue(
user1.address
);
console.log("userCollateral", userCollateral);
//c user borrows 90% of their collateral which puts their health factor below 1 and qualifies them for liquidation
const borrowAmount = ethers.parseEther("90");
console.log("borrowAmount", borrowAmount);
await lendingPool.connect(user1).borrow(borrowAmount);
//c calculate user's health factor
const healthFactor = await lendingPool.calculateHealthFactor(
user1.address
);
console.log("healthFactor", healthFactor);
assert(
healthFactor <
(await lendingPool.BASE_HEALTH_FACTOR_LIQUIDATION_THRESHOLD())
);
//c allow grace period to pass
await time.increase(4 * 24 * 60 * 60);
const reservedata1 = await lendingPool.getAllUserData(user1.address);
const userscaleddebtprerepay = reservedata1.scaledDebtBalance;
console.log(`userscaleddebt`, userscaleddebtprerepay);
//c someone initiates liquidation
await lendingPool.connect(user2).initiateLiquidation(user1.address);
//c since grace period has passed and user has not been liquidated, they can repay their debt
await lendingPool.connect(user1).repay(borrowAmount);
const reservedata = await lendingPool.getAllUserData(user1.address);
const userscaleddebt = reservedata.scaledDebtBalance;
console.log(`userscaleddebt`, userscaleddebt);
assert(userscaleddebt < userscaleddebtprerepay);
});

Impact

Undermined Liquidation Mechanism: Users can bypass liquidation by repaying their debt after the process has been initiated, reducing the effectiveness of the protocol's risk management system.

Increased Protocol Risk: If users are allowed to avoid liquidation, the protocol may accumulate bad debt, leading to financial instability.

Unfair Advantage: Users with unhealthy positions can manipulate the system to avoid penalties, disadvantaging other participants.

Tools Used

Manual Review, Hardhat

Recommendations

To address this issue, the _repay function should be modified to prevent users from repaying their debt once the liquidation process has been initiated. Here’s how this can be implemented:

Updated _repay Function
Add a check to ensure that the user is not under liquidation:

function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
// Prevent repayment if the user is under liquidation
if (isUnderLiquidation[onBehalfOf]) {
revert CannotRepayUnderLiquidation();
}
UserData storage user = userData[onBehalfOf];
// Update reserve state before repayment
ReserveLibrary.updateReserveState(reserve, rateData);
// Calculate the user's debt (for the onBehalfOf address)
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(onBehalfOf);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
// If amount is greater than userDebt, cap it at userDebt
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
// Burn DebtTokens from the user whose debt is being repaid (onBehalfOf)
(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;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit Repay(msg.sender, onBehalfOf, actualRepayAmount);
}

Additional Recommendations

Grace Period Enforcement: Ensure that the grace period is strictly enforced and cannot be bypassed by repayments.

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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