Algo Ssstablecoinsss

AI First Flight #2
Beginner FriendlyDeFi
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Users between 100%-110% collateralization can never be liquidated — bad debt is permanent

Description

liquidate() in src/dsc_engine.vy pays liquidators a 10% bonus while requiring the user's health factor to strictly improve after liquidation, else the whole call reverts with DSCEngine__HealthFactorNotImproved. For any user with collateral ratio C/D in (100%, 110%], every liquidation strictly decreases the health factor (the bonus destroys more collateral value than the debt reduction saves), so liquidation always reverts. Once a price crash pushes a position into this dead zone, it can never be liquidated — permanent bad debt with no recovery path. The code comments admit the design flaw: "A known bug would be if the protocol was only 100% collateralized".

Root cause

# src/dsc_engine.vy, liquidate()
token_amount_from_debt_covered: uint256 = self._get_token_amount_from_usd(collateral, debt_to_cover)
bonus_collateral: uint256 = (token_amount_from_debt_covered * LIQUIDATION_BONUS) // LIQUIDATION_PRECISION # 10%
self._redeem_collateral(collateral, token_amount_from_debt_covered + bonus_collateral, user, msg.sender) # user loses 1.1x debt value
self._burn_dsc(debt_to_cover, user, msg.sender) # debt reduced by 1x
ending_user_health_factor: uint256 = self._health_factor(user)
assert ending_user_health_factor > starting_user_health_factor, "DSCEngine__HealthFactorNotImproved"

Math: HF = (C × 50%) / D. After covering d (USD): C' = C − 1.1d, D' = D − d. HF' > HF ⟺ C/D > 1.1. So liquidation can only pass the assert when C/D > 1.1 — users at 100%–110% (underwater, HF < 1, exactly who should be liquidated) revert on every attempt, at any cover amount.

Impact

  • A price crash pushing users into (100%, 110%] creates permanently unliquidatable positions: bad debt sits in the protocol forever, DSC becomes effectively undercollateralized.

  • No alternative recovery path exists (no global settlement, no bad-debt auction).

  • Needs no attacker — ordinary market volatility manufactures these positions.

Risk

Likelihood: Medium — requires a large-enough price drop to push users into the band (common in crypto collateral markets; reproduced here with ETH $2000→$200). Once inside, no on-chain action can rescue the position.

Proof of Concept

Python/titanoboa test against the actual Vyper contracts (ran green):

import boa
feed = boa.loads(open("src/mocks/MockV3Aggregator.vy").read(), 8, 2000*10**8, filename="src/mocks/MockV3Aggregator.vy")
weth = boa.loads(open("src/mocks/mock_token.vy").read(), filename="src/mocks/mock_token.vy")
wbtc = boa.loads(open("src/mocks/mock_token.vy").read(), filename="src/mocks/mock_token.vy")
dsc = boa.loads(open("src/decentralized_stable_coin.vy").read(), filename="src/decentralized_stable_coin.vy")
engine = boa.loads(open("src/dsc_engine.vy").read(), [weth.address, wbtc.address],
[feed.address, feed.address], dsc.address, filename="src/dsc_engine.vy")
dsc.set_minter(engine.address, True)
E18 = 10**18
user = boa.env.generate_address("user"); victim2 = boa.env.generate_address("victim2")
liquidator = boa.env.generate_address("liquidator")
for a in (user, victim2, liquidator): boa.env.set_balance(a, 100*E18)
with boa.env.prank(user):
weth.mock_mint(); weth.approve(engine.address, 10*E18)
engine.deposit_collateral_and_mint_dsc(weth.address, 10*E18, 1900*E18) # victim1
with boa.env.prank(victim2):
weth.mock_mint(); weth.approve(engine.address, 10*E18)
engine.deposit_collateral_and_mint_dsc(weth.address, 10*E18, 1200*E18) # control
with boa.env.prank(liquidator):
weth.mock_mint(); weth.approve(engine.address, 10*E18)
engine.deposit_collateral_and_mint_dsc(weth.address, 10*E18, 100*E18)
dsc.approve(engine.address, 100*E18)
feed.updateAnswer(200*10**8) # crash ETH to $200
assert engine.health_factor(user) < E18 # victim1: HF=0.526, C/D=1.0526, in dead zone
with boa.env.prank(liquidator):
try:
engine.liquidate(weth.address, user, 100*E18)
raise AssertionError("should have reverted")
except Exception as e:
assert "HealthFactorNotImproved" in str(e) # reverts for the RIGHT reason
with boa.env.prank(liquidator):
engine.liquidate(weth.address, victim2, 100*E18) # control (C/D=1.667) liquidates fine
print("M-1 reproduced")

Output: victim1 liquidate REVERTED as expected (DSCEngine__HealthFactorNotImproved) / control OK: victim2 liquidated successfully / ALL ASSERTIONS PASSED.

Recommended Mitigation

The bonus (paid from the user's collateral) and the strict-improvement assert are incompatible. Two options:

ending_user_health_factor: uint256 = self._health_factor(user)
assert (
- ending_user_health_factor > starting_user_health_factor
+ ending_user_health_factor >= MIN_HEALTH_FACTOR
), "DSCEngine__HealthFactorNotImproved"

Option A (minimal): accept liquidation that reaches a healthy HF instead of demanding strict monotonic improvement. Option B (cleaner): pay the bonus from a protocol insurance fund rather than the user's collateral, making C' = C − d so every liquidation of a C/D > 1 user strictly improves HF and the dead zone disappears. Rationale: the assert exists to stop griefing liquidations, but in the dead zone the real risk is no one being able to liquidate at all — bricking insolvent positions leaves DSC permanently undercollateralized.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!