Core Contracts

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

Repayment Flaws During Liquidation Can Lead to Wasted Funds and NFT Loss

Summary

Users flagged for liquidation can mistakenly make partial repayments, believing they can restore their health factor, only to later lose all repayments when liquidation is finalized. Additionally, even if a user fully repays their debt but forgets to call closeLiquidation(), they can still lose all collateralized NFTs, which the liquidator can claim for free or at an extremely low cost.

Furthermore, repayments continue to be allowed even after the grace period expires, exacerbating user losses. Lastly, even full repayments can leave a small dust amount that grows due to compounding interest, potentially making closeLiquidation() revert at the last moment.

This creates a scenario where users waste their funds on ineffective repayments and/or can still lose their NFTs due to a missing an/or on time finalization step.

Vulnerability Details

Partial Repayment Does Not Prevent Liquidation

A user has been flagged for liquidation by someone via initiateLiquidation():

LendingPool.sol#L459

isUnderLiquidation[userAddress] = true;

Thinking that improving their health factor is sufficient to avoid liquidation, as is commonly practiced by many other protocols, they proceed to making partial repayments via repay() that does not stop them from making this dire mistake.

The user naively queries calculateHealthFactor() and is happy their healthFactor is now greater than or equal to healthFactorLiquidationThreshold (1e18 by default).

LendingPool.sol#L553

return (collateralThreshold * 1e18) / userDebt;

However, healthFactor is no longer relevant once liquidation is initiated. As a matter of fact, the only time calculateHealthFactor() getting triggered in LendingPool.sol is when someone calling initiateLiquidation() to initiate the liquidation process if a user's health factor is below the threshold:

LendingPool.sol#L455-L460

uint256 healthFactor = calculateHealthFactor(userAddress);
if (healthFactor >= healthFactorLiquidationThreshold) revert HealthFactorTooLow();
isUnderLiquidation[userAddress] = true;
liquidationStartTime[userAddress] = block.timestamp;

If the full amount is not repaid, liquidation can still be finalized after the grace period. As soon as the user is aware of this rule, they may not have enough resources to repay the difference. The add on loss incurred will be devastating if the user has made a significant sum of repayment, which will all be put to drain and happily arbitraged by the liquidator.

Full Repayment Without Calling closeLiquidation() Still Results in NFT Loss

Even if a user fully repays their debt, they must manually call closeLiquidation(). If they fail to do so, the liquidator can still finalize liquidation and claim all of the user’s NFTs for free or for as little as 1e6 units of debt.

This is because finalizeLiquidation() does not care whether the debt was already repaid (partially or fully with non-zero dust entailed or healthFactor >= healthFactorLiquidationThreshold), allowing the liquidator to to receive valuable NFTs as enlisted in user.nftTokenIds.

Repayments Are Allowed Even After Grace Period Expires

Users who routinely make small repayments (e.g., weekly, daily or even hourly using a giro automatic schedule) may unknowingly and unnecessarily continue paying down their debt even after the grace period has expired.

These repayments are effectively and exacerbatingly wasted since liquidation will still be finalized at any moment. The repayment will revert only after the liquidator has called finalizeLiquidation() when user.scaledDebtBalance is reduced to 0.

LendingPool.sol#L527-L528

// Update user's scaled debt balance
user.scaledDebtBalance -= amountBurned;

Compounding Interest Can Prevent Liquidation Closure

Even if a user fully repays their debt, a small dust amount may remain.

If the grace period is long (e.g., 7 days as allowed by the upper setting limit), the remaining dust can grow past 1e6 due to exponentially compounding interest, making closeLiquidation() revert at the last moment.

Impact

  • Wasted Repayments: Users may unknowingly make partial repayments, only to lose all funds when liquidation is finalized.

  • Unfair NFT Seizure: Users who fully repay but forget to close liquidation will lose their NFTs for free or at a minimal cost to the liquidator.

  • Bad UX & Loss of Trust: Users may incorrectly assume that repaying enough to restore their health factor prevents liquidation, leading to frustration and lack of confidence in the protocol.

  • Unexpected Liquidation Failures: Dust accumulation due to compounding interest may make closeLiquidation() impossible to execute, preventing users from exiting liquidation even after full repayment.

Tools Used

Manual

Recommendations

Enforce Full Repayment If Under Liquidation

  • Modify _repay() to check isUnderLiquidation[user], and if true, require the user to repay their debt in full (i.e., ensure userDebt <= DUST_THRESHOLD after repayment).

Automatically Call closeLiquidation() After Full Repayment

  • Change closeLiquidation() from external to internal.

  • Modify _repay() to automatically trigger closeLiquidation() if the user is flagged for liquidation and the user’s debt is fully repaid.

Prevent Repayments After Grace Period Expires

  • Modify _repay() to reject repayments if liquidation grace period has expired.

Ensure finalizeLiquidation() Checks for Outstanding Debt

  • Before allowing the liquidator to seize NFTs, verify that the user's debt is nonzero.

  • If the user’s debt is already cleared to zero or near zero and is still flagged for liquidation, modify finalizeLiquidation() to transfer all NFT back to the user.

Updates

Lead Judging Commences

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

LendingPool::finalizeLiquidation() never checks if debt is still unhealthy

A borrower can LendingPool::repay to avoid liquidation but might not be able to call LendingPool::closeLiquidation successfully due to grace period check, loses both funds and collateral

Support

FAQs

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