Algo Ssstablecoinsss

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

liquidate() always seizes debt + an uncapped 10% bonus and requires a strictly higher HF, so positions at or below 110% collateralisation cannot be liquidated in any meaningful way and drift into bad debt

Root + Impact

Description

  • liquidate lets anyone repay part or all of a position whose health factor is below 1. In return the liquidator receives the collateral equivalent of the covered debt plus a 10% bonus, and the call must leave the user's HF strictly higher than before. Liquidation is the protocol's only third-party mechanism for keeping DSC backed.

  • For collateral value C and debt D, covering d of debt removes 1.1d of collateral value. (C - 1.1d)/(D - d) > C/D holds only when C > 1.1D, whatever d is. Once total collateral is at or below 110% of the debt (HF <= 0.55), the outcome depends on the cover size:

    • Every partial cover fails the strict check at dsc_engine.vy:132-134.

    • Any cover whose 1.1x seizure exceeds the chosen token's deposit, including a full cover of the debt, underflows at dsc_engine.vy:249-251.

    The bonus is never capped at what the user holds, and there is no seizure path without a bonus. The position stays open while it is still solvent, and liquidators cannot reduce it.

# src/dsc_engine.vy:121-134 (liquidate)
@> bonus_collateral: uint256 = (
@> token_amount_from_debt_covered * LIQUIDATION_BONUS
@> ) // LIQUIDATION_PRECISION
self._redeem_collateral(
collateral,
@> token_amount_from_debt_covered + bonus_collateral,
user,
msg.sender,
)
self._burn_dsc(debt_to_cover, user, msg.sender)
ending_user_health_factor: uint256 = self._health_factor(user)
assert (
@> ending_user_health_factor > starting_user_health_factor
), "DSCEngine__HealthFactorNotImproved"
​
# src/dsc_engine.vy:249-251 (_redeem_collateral) - checked subtraction, reverts when the seizure exceeds the deposit
self.user_to_token_address_to_amount_deposited[_from][
token_collateral_address
@> ] -= amount_collateral

Risk

Likelihood:

  • A position reaches the band when the collateral price falls from the 200% line to 110% or below (about a 45% move) before any liquidator acts. That can happen through a fast gap down, liquidators short of DSC during a crash, or prices resuming after the engine was frozen on a stale feed.

  • Liquidation is profitable and allowed all the way from HF 1.0 down to about HF 0.55, so a position only gets stuck after crossing that whole window unliquidated.

Impact:

  • Liquidation switches off exactly where it is needed most. In the PoC, at 105% CR (1 WETH at $1,050 against 1,000 DSC, HF 0.525):

    • All 239 sampled cover sizes from 1 wei to 1,000 DSC revert: 225 with DSCEngine__HealthFactorNotImproved and 14 with an underflow at dsc_engine.vy:251.

    • A brute force over 1..2,000,000 wei on an exact integer model, cross-checked against every on-chain call, finds no passing cover.

    • The same holds at 101%, 108%, 109% and 110% CR, and on both legs of a two-collateral position.

    • At 111%, 115% and 150% CR, liquidation works.

  • A further price fall turns the stuck position into bad debt: at $900 the position holds $900 of collateral against 1,000 DSC and is still unliquidatable, so DSC is left under-backed. This acts while positions are still solvent (100-110% CR), so it is a distinct cause from the README's known issue about recovering from insolvency.

  • Scope of the claim:

    • The borrower can still resolve the position: burn_dsc of 475 DSC restores HF to 1.0, and full repayment or adding collateral also works. Only liquidators are locked out.

    • Liquidation reopens if the price recovers above 110%.

    • For smaller debts, a wei-sized cover can pass the strict check. On a 500 DSC position a 953-wei cover seizes 0 collateral and raises HF by 1 wei, which is economically meaningless.

Proof of Concept

The PoC deploys the real dsc_engine.vy and decentralized_stable_coin.vy. Both collateral slots use the repo's 18-decimal mock_token, so the separate decimals finding does not affect any number. Each revert is classified from the last frame: either the assert reason, or safesub for the compiler-inserted underflow check, which is also asserted to sit at dsc_engine:251.

Run from the repo root: .venv/Scripts/python poc/poc_AS-03.py

"""
PoC AS-03: positions between 100% and 110% collateralisation cannot be liquidated.
​
dsc_engine.vy liquidate():
- seizes token_amount(debt_to_cover) + a fixed, uncapped 10% bonus (118-129)
- _redeem_collateral does a checked `deposit -= amount` (249-251)
- then requires ending HF > starting HF, strictly (132-134)
​
For collateral C and debt D (USD): (C - 1.1d)/(D - d) > C/D <=> C > 1.1 D,
whatever d is. A full cover (d = D) needs 1.1 D of collateral, which is more
than the user holds when C < 1.1 D, so it underflows.
​
Sections
0. baseline: at 120% CR partial and full liquidations work
1. the bug: at 105% CR every cover size reverts (NotImproved or safesub underflow);
an exact integer model of the engine maths, cross-checked against every
on-chain call, is brute-forced over d = 1..2,000,000 wei as well
2. ratio sweep: <= 110% blocked, >= 111% liquidatable (d = 100 DSC)
3. two-collateral position at 105%: blocked on either token
4. price falls further to $900: the stuck position becomes bad debt (C < D),
still unliquidatable
5. what the finding overstates: the borrower CAN still burn DSC (limb 4 is
false), the band reopens if price recovers, and for small debts a wei-dust
liquidation can pass the strict check (economically meaningless)
​
Run (from the repo root): .venv/Scripts/python poc/poc_AS-03.py
"""
import random
import subprocess
import sys
​
# Windows non-UTF-8 locales (e.g. gbk) cannot read snekmate's UTF-8 sources with
# the default open() codec; re-run this script once in Python UTF-8 mode.
if not sys.flags.utf8_mode:
sys.exit(subprocess.call([sys.executable, "-X", "utf8", *sys.argv]))
​
import boa
from boa.contracts.base_evm_contract import BoaError
​
ENGINE = "src/dsc_engine.vy"
DSC_SRC = "src/decentralized_stable_coin.vy"
MOCK_TOKEN = "src/mocks/mock_token.vy" # 18 decimals
MOCK_FEED = "src/mocks/MockV3Aggregator.vy"
​
E18 = 10**18
MAXU = 2**256 - 1
​
​
def px(dollars): # 8-decimal feed answer
return int(round(dollars * 10**8))
​
​
# ------------------------------------------------------------------ exact integer model of the engine maths
def m_usd(amount, price8): # _get_usd_value
return (price8 * 10**10 * amount) // E18
​
​
def m_tok(usd, price8): # _get_token_amount_from_usd
return (usd * E18) // (price8 * 10**10)
​
​
def m_hf(debt, coll_usd): # _calculate_health_factor
if debt == 0:
return MAXU
return ((coll_usd * 50) // 100) * E18 // debt
​
​
def m_liquidate(dep, debt, price8, d):
"""single-collateral liquidate(): returns outcome string"""
start = m_hf(debt, m_usd(dep, price8))
if start >= E18:
return "DSCEngine__HealthFactorOk"
t = m_tok(d, price8)
seize = t + (t * 10) // 100
if seize > dep or d > debt:
return "safesub"
end = m_hf(debt - d, m_usd(dep - seize, price8))
if end <= start:
return "DSCEngine__HealthFactorNotImproved"
return "ok"
​
​
# ------------------------------------------------------------------ deploy
dsc = boa.load(DSC_SRC)
weth = boa.load(MOCK_TOKEN)
wbtc = boa.load(MOCK_TOKEN) # second 18-dec collateral (keeps AS-01's decimals bug out of the picture)
eth_feed = boa.load(MOCK_FEED, 8, px(2000))
btc_feed = boa.load(MOCK_FEED, 8, px(2000))
engine = boa.load(ENGINE, [wbtc.address, weth.address], [btc_feed.address, eth_feed.address], dsc.address)
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
​
​
def open_position(name, weth_amt, debt, wbtc_amt=0):
u = boa.env.generate_address(name)
with boa.env.prank(u):
weth.mint_amount(weth_amt)
weth.approve(engine.address, weth_amt)
engine.deposit_collateral(weth.address, weth_amt)
if wbtc_amt:
wbtc.mint_amount(wbtc_amt)
wbtc.approve(engine.address, wbtc_amt)
engine.deposit_collateral(wbtc.address, wbtc_amt)
engine.mint_dsc(debt)
return u
​
​
LAST_ERR = {}
​
​
def try_liq(liq, token, user, d):
"""returns 'ok', the assert reason string, or the compiler check name (e.g. 'safesub')"""
try:
with boa.env.prank(liq):
engine.liquidate(token, user, d)
return "ok"
except BoaError as e:
LAST_ERR["text"] = str(e)
f = e.stack_trace.last_frame
if isinstance(f, str):
return f
r = f.pretty_vm_reason or ""
if r.startswith("Revert('") and r.endswith("')"):
return r[len("Revert('"):-2] # assert reason string
return f.error_detail # compiler-inserted check, e.g. 'safesub'
​
​
# all positions opened at ETH = BTC = $2,000
liq = open_position("liquidator", 400 * E18, 50_000 * E18) # well-capitalised liquidator holding DSC
with boa.env.prank(liq):
dsc.approve(engine.address, MAXU)
bob = open_position("bob", E18, 1_000 * E18) # baseline position
alice = open_position("alice", E18, 1_000 * E18) # the finding's attack path
carol = open_position("carol", E18 // 2, 1_000 * E18, wbtc_amt=E18 // 2) # mixed collateral
dave = open_position("dave", E18 // 2, 500 * E18) # smaller debt (dust nuance)
for u in (bob, alice, carol, dave):
assert engine.health_factor(u) == E18 # all opened exactly at HF 1.0 (200% CR)
​
# ------------------------------------------------------------------ 0. baseline at 120% CR
print("== 0. baseline: ETH $1,200 (CR 120%) ==")
eth_feed.updateAnswer(px(1200))
hf0 = engine.health_factor(bob)
print(f"bob HF = {hf0}")
assert hf0 == 6 * 10**17
with boa.env.anchor():
r = try_liq(liq, weth.address, bob, 100 * E18)
hf1 = engine.health_factor(bob)
print(f"liquidate(bob, 100 DSC) -> {r}; HF {hf0} -> {hf1}")
assert r == "ok" and hf1 > hf0
with boa.env.anchor():
r = try_liq(liq, weth.address, bob, 1_000 * E18)
print(f"liquidate(bob, 1000 DSC full cover) -> {r}; debt {engine.user_to_dsc_minted(bob)}, WETH left {engine.get_collateral_balance_of_user(bob, weth.address)}")
assert r == "ok" and engine.user_to_dsc_minted(bob) == 0
​
# ------------------------------------------------------------------ 1. the bug at 105% CR
print("== 1. ETH gaps to $1,050 (CR 105%): alice cannot be liquidated ==")
P = px(1050)
eth_feed.updateAnswer(P)
DEP, DEBT = E18, 1_000 * E18
hf_a = engine.health_factor(alice)
print(f"alice: 1 WETH (${engine.get_account_collateral_value(alice) / E18:,.0f}) vs {DEBT // E18} DSC, HF = {hf_a}")
assert hf_a == 525 * 10**15 and hf_a < E18 # liquidatable per the HF gate
​
# largest cover that does not underflow the deposit
d_max = max(d for d in range(950 * E18, 960 * E18, E18) if m_liquidate(DEP, DEBT, P, d) != "safesub")
print(f"largest whole-DSC cover that fits the deposit = {d_max // E18} DSC "
f"(seizes {m_tok(d_max, P) * 110 // 100 / E18:.4f} WETH)")
assert d_max == 954 * E18
​
named = [1, 10, 1_000, 10**6, 10**9, 10**12, 10**15, E18, 10 * E18, 100 * E18,
500 * E18, 900 * E18, d_max, d_max + E18, 999 * E18, 1_000 * E18]
rng = random.Random(3)
sample = named + [int(10 ** (k / 4)) for k in range(0, 84)] + [rng.randrange(1, DEBT + 1) for _ in range(150)]
sample = sorted(set(sample))
counts = {}
for d in sample:
got = try_liq(liq, weth.address, alice, d)
exp = m_liquidate(DEP, DEBT, P, d)
assert got == exp, (d, got, exp) # model reproduces the contract exactly
assert got != "ok", d
counts[got] = counts.get(got, 0) + 1
for d in named:
if d in (1, E18, 100 * E18, d_max, d_max + E18, 1_000 * E18):
print(f" liquidate(alice, {d:>24} wei = {d // E18:>4} DSC) -> {m_liquidate(DEP, DEBT, P, d)}")
print(f"on-chain sweep of {len(sample)} cover sizes in [1 wei, 1000 DSC]: {counts}")
assert counts.get("ok", 0) == 0
assert counts["DSCEngine__HealthFactorNotImproved"] > 0 and counts["safesub"] > 0
# HF arithmetic of the finding's example (d = 100 DSC)
seize100 = m_tok(100 * E18, P) * 110 // 100
hf100 = m_hf(900 * E18, m_usd(DEP - seize100, P))
print(f"d=100 DSC: seizes {seize100 / E18:.5f} WETH (${m_usd(seize100, P) / E18:,.2f}); HF would go {hf_a} -> {hf100}")
assert hf100 < hf_a
# full cover needs more WETH than alice has
need_full = m_tok(DEBT, P) * 110 // 100
assert try_liq(liq, weth.address, alice, DEBT) == "safesub"
assert 'contract "dsc_engine:251", function "_redeem_collateral"' in LAST_ERR["text"]
print(f"d=1000 DSC (full cover) needs {need_full / E18:.6f} WETH > deposit {DEP / E18:.0f} WETH "
f"-> reverts: compiler safesub in _redeem_collateral, dsc_engine.vy:251")
assert need_full > DEP
​
# brute force on the verified model: no wei-level cover in 1..2,000,000 escapes either
bf = [d for d in range(1, 2_000_001) if m_liquidate(DEP, DEBT, P, d) == "ok"]
print(f"model brute force d = 1..2,000,000 wei: passing covers = {len(bf)}")
assert bf == []
assert engine.user_to_dsc_minted(alice) == DEBT and engine.get_collateral_balance_of_user(alice, weth.address) == DEP
​
# ------------------------------------------------------------------ 2. ratio sweep (D = 1000, d = 100 DSC)
print("== 2. collateral-ratio sweep, cover = 100 DSC ==")
results = {}
for dollars in (1010, 1050, 1080, 1090, 1100, 1110, 1150, 1500):
with boa.env.anchor():
eth_feed.updateAnswer(px(dollars))
s = engine.health_factor(alice)
r = try_liq(liq, weth.address, alice, 100 * E18)
e = engine.health_factor(alice)
results[dollars] = r
print(f" CR {dollars / 10:5.1f}% HF {s} -> {r:<36} HF after {e}")
for dollars, r in results.items():
if dollars <= 1100:
assert r == "DSCEngine__HealthFactorNotImproved", dollars
else:
assert r == "ok", dollars
​
# ------------------------------------------------------------------ 3. two collaterals at 105% total
print("== 3. carol: 0.5 WETH + 0.5 WBTC vs 1000 DSC, both at $1,050 ==")
btc_feed.updateAnswer(P)
hf_c = engine.health_factor(carol)
print(f"carol HF = {hf_c}")
assert hf_c == 525 * 10**15
for tok, nm in ((weth, "WETH"), (wbtc, "WBTC")):
outs = {try_liq(liq, tok.address, carol, d) for d in (10**12, E18, 50 * E18, 100 * E18, 400 * E18, 476 * E18, 1_000 * E18)}
print(f" liquidate on {nm} leg, covers 1e-6..1000 DSC -> {sorted(outs)}")
assert "ok" not in outs
​
# ------------------------------------------------------------------ 4. further fall: stuck position becomes bad debt
print("== 4. ETH falls further to $900: alice is now insolvent and still unliquidatable ==")
with boa.env.anchor():
eth_feed.updateAnswer(px(900))
c = engine.get_account_collateral_value(alice)
print(f"alice collateral ${c / E18:,.0f} < debt {DEBT // E18} DSC (HF {engine.health_factor(alice)})")
assert c < DEBT
outs = {try_liq(liq, weth.address, alice, d) for d in (10**9, E18, 100 * E18, 500 * E18, 818 * E18, 1_000 * E18)}
print(f" liquidate covers 1e-9..1000 DSC -> {sorted(outs)}")
assert "ok" not in outs
​
# ------------------------------------------------------------------ 5. limits of the claim
print("== 5. what is NOT true / narrower ==")
# 5a. the borrower can still shrink the position with burn_dsc (limb 4 is false)
with boa.env.anchor():
with boa.env.prank(alice):
dsc.approve(engine.address, MAXU)
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.burn_dsc(474 * E18) # not enough to reach HF >= 1
engine.burn_dsc(475 * E18)
print(f"5a. alice burn_dsc(475 DSC) succeeds at $1,050: debt {engine.user_to_dsc_minted(alice) // E18}, HF {engine.health_factor(alice)}")
assert engine.health_factor(alice) == E18
# 5b. the band is not permanent: liquidation reopens if price recovers above 110%
with boa.env.anchor():
eth_feed.updateAnswer(px(1111))
r = try_liq(liq, weth.address, alice, 100 * E18)
print(f"5b. price recovers to $1,111 (CR 111.1%): liquidate(alice, 100 DSC) -> {r}")
assert r == "ok"
# 5c. dust nuance: smaller debt at the same 105% ratio, a wei-sized cover can pass
hf_d = engine.health_factor(dave)
DEP_D, DEBT_D = E18 // 2, 500 * E18
assert hf_d == 525 * 10**15
dust = next(d for d in range(1, 200_001) if m_liquidate(DEP_D, DEBT_D, P, d) == "ok")
with boa.env.anchor():
w0 = weth.balanceOf(liq)
r = try_liq(liq, weth.address, dave, dust)
print(f"5c. dave (0.5 WETH / 500 DSC, HF {hf_d}): liquidate({dust} wei DSC) -> {r}; "
f"HF -> {engine.health_factor(dave)}; seized {weth.balanceOf(liq) - w0} wei WETH")
assert r == "ok"
assert m_liquidate(DEP_D, DEBT_D, P, 100 * E18) == "DSCEngine__HealthFactorNotImproved"
assert try_liq(liq, weth.address, dave, 100 * E18) == "DSCEngine__HealthFactorNotImproved"
print(f" clearing 500 DSC at ~{dust} wei per call would take ~{DEBT_D // dust:.2e} calls -> economically meaningless")
​
print("PASS: AS-03 proven -- at CR <= 110% every economically meaningful liquidation reverts "
"(HF not strictly improved for partial covers, safesub underflow for covers >= 955/1000 DSC); "
"liquidation works at >= 111%; the stuck position becomes bad debt on a further fall; "
"borrower self-repay via burn_dsc still works (limb 4 false)")

The script ends with: PASS: AS-03 proven -- at CR <= 110% every economically meaningful liquidation reverts (HF not strictly improved for partial covers, safesub underflow for covers >= 955/1000 DSC); liquidation works at >= 111%; the stuck position becomes bad debt on a further fall; borrower self-repay via burn_dsc still works (limb 4 false)

Recommended Mitigation

Cap the seizure at the user's balance in the chosen token. A full close then always succeeds, because the existing check already passes when the debt reaches 0 (_calculate_health_factor returns max_value(uint256)). The liquidator receives whatever bonus the remaining collateral allows.

--- a/src/dsc_engine.vy
+++ b/src/dsc_engine.vy
@@ def liquidate @@
bonus_collateral: uint256 = (
token_amount_from_debt_covered * LIQUIDATION_BONUS
) // LIQUIDATION_PRECISION
+ total_to_seize: uint256 = token_amount_from_debt_covered + bonus_collateral
+ user_balance: uint256 = self.user_to_token_address_to_amount_deposited[user][
+ collateral
+ ]
+ if total_to_seize > user_balance:
+ total_to_seize = user_balance
self._redeem_collateral(
collateral,
- token_amount_from_debt_covered + bonus_collateral,
+ total_to_seize,
user,
msg.sender,
)
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!