Core Contracts

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

Partial Liquidation Bypasses Bad Debt Accounting

Summary

When a borrower under liquidation repays only a partial amount of their debt, the remaining debt may be too small to incentivize liquidators. As a result, the bad debt (the portion of the debt that is not repaid) may not be properly accounted for or liquidated, allowing borrowers to bypass bad debt accounting and leave the protocol with unresolved undercollateralized positions.

Vulnerability Details

Root Cause:

  • The liquidation mechanism does not enforce full repayment of debt when a borrower is under liquidation.

  • Borrowers can repay a partial amount of their debt, leaving a small remaining debt that is not worth liquidating due to low rewards for liquidators.

  • The protocol does not have a mechanism to force the liquidation of the remaining bad debt.

Impact

  • Bad Debt Accumulation: Small, unresolved bad debts can accumulate in the protocol, increasing the risk to the system.

  • Loss of Funds: The protocol may incur losses if the bad debt is not liquidated and the collateral is insufficient to cover the debt.

  • Inefficient Liquidation: Liquidators may avoid small positions, leaving them un-liquidated and increasing the risk to the protocol.

PoC

  1. A borrower's health factor falls below the liquidation threshold, and liquidation is initiated.

  2. The borrower repays a partial amount of their debt, reducing the remaining debt to a very small amount (e.g., 1 wei).

  3. The remaining debt is too small to incentivize liquidators, so it remains un-liquidated.

  4. The protocol is left with unresolved bad debt, increasing the risk to the system.

Tools Used

Manual Review

Recommendations

1. Force Full Repayment During Liquidation:

  • Require borrowers under liquidation to repay their entire debt before they can stop the liquidation process.

  • Example:

function closeLiquidation() external nonReentrant whenNotPaused {
address userAddress = msg.sender;
UserData storage user = userData[userAddress];
if (!user.underLiquidation) revert NotUnderLiquidation();
// Update reserve state
ReserveLibrary.updateReserveState(reserve, rateData);
// Check if the user's debt has been fully repaid
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
// Close liquidation
user.underLiquidation = false;
user.liquidationStartTime = 0;
emit LiquidationClosed(userAddress);
}

2. Automatically Liquidate Remaining Debt:

  • If a borrower repays a partial amount of their debt, automatically liquidate the remaining debt if it is below a certain threshold.

  • Example:

function repay(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (amount == 0) revert InvalidAmount();
UserData storage user = userData[msg.sender];
if (user.scaledDebtBalance == 0) revert UserNotFound();
// Update reserve state before repayment
ReserveLibrary.updateReserveState(reserve, rateData);
// Calculate the user's debt
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(msg.sender);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
// If amount is greater than userDebt, cap it at userDebt
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
// Check if repayment amount is above dust threshold
if (actualRepayAmount < DUST_THRESHOLD) revert RepayAmountBelowDustThreshold();
// Burn DebtTokens from the user
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(msg.sender, 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);
// If the user is under liquidation and the remaining debt is below the dust threshold, force liquidation
if (user.underLiquidation && user.scaledDebtBalance.rayMul(reserve.usageIndex) <= DUST_THRESHOLD) {
_finalizeLiquidation(userAddress);
}
emit Repay(msg.sender, msg.sender, actualRepayAmount);
}

3. Increase Incentives for Small Liquidations:

  • Provide additional incentives for liquidators to liquidate small positions, ensuring that all bad debt is resolved.

  • Example:

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
if (userAddress == address(0)) revert InvalidAddress();
UserData storage user = userData[userAddress];
if (!user.underLiquidation) revert NotUnderLiquidation();
// Update reserve state
ReserveLibrary.updateReserveState(reserve, rateData);
// Check if the grace period has expired
if (block.timestamp <= user.liquidationStartTime + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
// Check if the user's debt has been fully repaid
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
if (userDebt <= DUST_THRESHOLD) revert DebtAlreadyRepaid();
// Calculate liquidation incentive
uint256 liquidationIncentive = calculateLiquidationIncentive(userDebt);
// Finalize liquidation
user.underLiquidation = false;
user.liquidationStartTime = 0;
// Transfer NFTs to Stability Pool
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
delete user.nftTokenIds;
// Burn DebtTokens from the user
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
// Transfer reserve assets from Stability Pool to cover the debt
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
// Transfer liquidation incentive to the Stability Pool
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, stabilityPool, liquidationIncentive);
// Update user's scaled debt balance
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

closeLiquidation allows users to exit liquidation with debt under DUST_THRESHOLD (1e6), potentially accumulating bad debt across multiple users over time

The dust amount remains as debt of the user. This continues to accrue interest and will block complete NFT withdrawals if left unpaid.

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

closeLiquidation allows users to exit liquidation with debt under DUST_THRESHOLD (1e6), potentially accumulating bad debt across multiple users over time

The dust amount remains as debt of the user. This continues to accrue interest and will block complete NFT withdrawals if left unpaid.

Support

FAQs

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