Algo Ssstablecoinsss

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

burn_dsc and redeem_collateral_for_dsc require HF >= 1 after a repayment that can only raise HF, so an underwater borrower cannot partially repay and pays the 10% liquidation bonus on debt they tried to repay

Root + Impact

Description

  • burn_dsc lets a borrower repay DSC debt, and redeem_collateral_for_dsc combines a repayment with a collateral withdrawal. For a borrower who is underwater, repaying is the natural way to reduce liquidation exposure.

  • _burn_dsc only lowers user_to_dsc_minted (dsc_engine.vy:263) and burns the caller's DSC. Collateral is untouched, so on this path the health factor can only rise. burn_dsc still ends with _revert_if_health_factor_is_broken(msg.sender) (line 151), which requires HF >= 1e18 (lines 271-273). As a result, every repayment that improves HF but leaves it below 1 reverts. redeem_collateral_for_dsc(token, 0, amount) ends with the same check (line 103). Self-liquidation from the borrower's own address is blocked too, because liquidate checks msg.sender's HF at the end (line 135).

# src/dsc_engine.vy:148-151
@external
def burn_dsc(amount_dsc_to_burn: uint256):
self._burn_dsc(amount_dsc_to_burn, msg.sender, msg.sender)
@> self._revert_if_health_factor_is_broken(msg.sender)
​
# src/dsc_engine.vy:99-103 (redeem_collateral_for_dsc) - same check even when no collateral is withdrawn
self._burn_dsc(amount_dsc_to_burn, msg.sender, msg.sender)
self._redeem_collateral(
token_collateral_address, amount_collateral, msg.sender, msg.sender
)
@> self._revert_if_health_factor_is_broken(msg.sender)
​
# src/dsc_engine.vy:263-265 (_burn_dsc) - the only effects: debt down, DSC burned
self.user_to_dsc_minted[on_behalf_of] -= amount_dsc_to_burn
# Note, we are not checking success here
extcall DSC.burn_from(dsc_from, amount_dsc_to_burn)
​
# src/dsc_engine.vy:271-273 (_revert_if_health_factor_is_broken)
assert (
@> user_health_factor >= MIN_HEALTH_FACTOR
), "DSCEngine__BreaksHealthFactor"

Risk

Likelihood:

  • This affects any borrower whose position falls below HF 1 while they hold less DSC than needed to restore HF >= 1 and have no spare collateral. A typical case is a borrower who sold part of the DSC they minted, which is the normal use of a stablecoin loan.

  • It needs no attacker: every such repayment attempt reverts.

Impact:

  • In the PoC, Alice has 1 WETH at $1,100, a debt of 1,000 DSC and 400 DSC in hand (HF 0.55).

    • burn_dsc of 1, 100 or 400 DSC reverts DSCEngine__BreaksHealthFactor, as does redeem_collateral_for_dsc(weth, 0, 400 DSC). Burning 400 would have raised her HF to 0.9167.

    • A liquidator then covers the full 1,000 and seizes all of her WETH, leaving her $400 of equity.

    • With only line 151 removed, she repays 400, the liquidator covers the remaining 600, and she keeps 0.4 WETH ($440). The blocked repayment costs her $40, the 10% bonus on the 400 she tried to repay.

  • No protocol funds are at risk. Workarounds exist: repay in full (debt 0 gives HF max_value), add collateral, or, when collateral exceeds 1.1x the debt, liquidate herself from a second, debt-free address. The PoC shows that this last route succeeds where the same call from her own address is blocked by line 135.

Proof of Concept

The PoC deploys the real dsc_engine.vy and decentralized_stable_coin.vy with the repo mocks. Section 5 compiles the same engine source in memory with only line 151 removed, to measure the counterfactual.

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

"""
PoC AS-07: burn_dsc / redeem_collateral_for_dsc end with a health-factor (HF)
post-check, so an underwater borrower cannot partially repay.
​
dsc_engine.vy:148-151
def burn_dsc(amount_dsc_to_burn: uint256):
self._burn_dsc(amount_dsc_to_burn, msg.sender, msg.sender)
self._revert_if_health_factor_is_broken(msg.sender) # HF >= 1e18
​
_burn_dsc only lowers user_to_dsc_minted, so HF can only rise on this path. The
check guards nothing; it only rejects repayments that improve HF but leave it
below 1. redeem_collateral_for_dsc(token, 0, x) has the same tail (line 103),
and liquidate() checks msg.sender's HF at the end (line 135), which blocks
self-liquidation by an underwater user.
​
Deploys the real in-scope contracts (dsc_engine.vy, decentralized_stable_coin.vy)
with the repo mocks (mock_token.vy, MockV3Aggregator.vy). The "fixed" engine in
section 5 is the same source compiled in memory with only line 151 removed.
​
Run (from the repo root): .venv/Scripts/python poc/poc_AS-07.py
"""
import subprocess
import sys
​
# Windows non-UTF-8 locales 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
​
ENGINE = "src/dsc_engine.vy"
DSC_SRC = "src/decentralized_stable_coin.vy"
MOCK_TOKEN = "src/mocks/mock_token.vy"
MOCK_FEED = "src/mocks/MockV3Aggregator.vy"
​
E18 = 10**18
MAX = 2**256 - 1
BHF = "DSCEngine__BreaksHealthFactor"
NOT_IMPROVED = "DSCEngine__HealthFactorNotImproved"
​
​
def usd(x):
return x / E18
​
​
def deploy(engine_src=None):
dsc = boa.load(DSC_SRC)
weth = boa.load(MOCK_TOKEN)
wbtc = boa.load(MOCK_TOKEN)
eth_feed = boa.load(MOCK_FEED, 8, 2_000 * 10**8)
btc_feed = boa.load(MOCK_FEED, 8, 100_000 * 10**8)
args = ([weth.address, wbtc.address], [eth_feed.address, btc_feed.address], dsc.address)
if engine_src is None:
engine = boa.load(ENGINE, *args)
else:
engine = boa.loads(engine_src, *args, name="dsc_engine_fixed", filename="src/dsc_engine_fixed.vy")
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
return dsc, weth, eth_feed, engine
​
​
def open_alice(dsc, weth, eth_feed, engine, alice, buyer, price_usd):
"""Alice: 1 WETH @ $2,000, mints 1,000 DSC, sells 600, keeps 400; ETH -> price_usd."""
with boa.env.prank(alice):
weth.mint_amount(E18)
weth.approve(engine.address, E18)
engine.deposit_collateral_and_mint_dsc(weth.address, E18, 1_000 * E18)
dsc.transfer(buyer, 600 * E18)
eth_feed.updateAnswer(price_usd * 10**8)
​
​
def fund_liquidator(dsc, weth, engine, liz, mint):
with boa.env.prank(liz):
weth.mint_amount(100 * E18)
weth.approve(engine.address, 100 * E18)
engine.deposit_collateral_and_mint_dsc(weth.address, 100 * E18, mint)
dsc.approve(engine.address, mint)
​
​
alice = boa.env.generate_address("alice")
alice2 = boa.env.generate_address("alice_second_wallet")
buyer = boa.env.generate_address("dsc_buyer")
liz = boa.env.generate_address("liquidator")
​
# ================================================================== vulnerable engine
dsc, weth, eth_feed, engine = deploy()
​
# ------------------------------------------------------------------ 0. baseline
print("== 0. baseline: a healthy user can partially repay ==")
with boa.env.anchor():
with boa.env.prank(alice):
weth.mint_amount(E18)
weth.approve(engine.address, E18)
engine.deposit_collateral_and_mint_dsc(weth.address, E18, 1_000 * E18)
dsc.approve(engine.address, 100 * E18)
engine.burn_dsc(100 * E18)
assert engine.user_to_dsc_minted(alice) == 900 * E18
print(f"ETH $2,000: alice burns 100 of 1,000 DSC -> debt 900, HF {engine.health_factor(alice) / E18:.4f}")
​
# ------------------------------------------------------------------ 1. the scenario
print("== 1. scenario (finding's example): ETH $2,000 -> $1,100 ==")
open_alice(dsc, weth, eth_feed, engine, alice, buyer, 1_100)
debt0, coll0 = engine.get_account_information(alice)
hf0 = engine.health_factor(alice)
assert (debt0, coll0) == (1_000 * E18, 1_100 * E18)
assert hf0 == 55 * 10**16 # 0.55 -> underwater, liquidatable
assert dsc.balanceOf(alice) == 400 * E18
print(f"alice: debt {usd(debt0):,.0f} DSC, collateral ${usd(coll0):,.0f}, HF {hf0 / E18}, holds {usd(dsc.balanceOf(alice)):,.0f} DSC")
​
# ------------------------------------------------------------------ 2. the bug
print("== 2. bug: every HF-improving partial repayment that leaves HF < 1 reverts ==")
hf_if_burned = engine.calculate_health_factor(600 * E18, coll0)
assert hf_if_burned == 916_666_666_666_666_666 and hf_if_burned > hf0
with boa.env.prank(alice):
dsc.approve(engine.address, 400 * E18)
for amt in (1, 100, 400):
with boa.reverts(BHF):
engine.burn_dsc(amt * E18)
print(f"burn_dsc({amt} DSC) -> reverts {BHF} (would move HF {hf0 / E18} -> "
f"{engine.calculate_health_factor(debt0 - amt * E18, coll0) / E18:.4f})")
with boa.reverts(BHF):
engine.redeem_collateral_for_dsc(weth.address, 0, 400 * E18)
print(f"redeem_collateral_for_dsc(weth, 0, 400 DSC) -> reverts {BHF}")
# self-liquidation from her own address (finding's 110% edge: blocked earlier, at line 133)
with boa.reverts(NOT_IMPROVED):
engine.liquidate(weth.address, alice, 400 * E18)
print(f"liquidate(weth, alice, 400) by alice herself -> reverts {NOT_IMPROVED} (C == 1.1 D edge)")
# nothing changed
assert engine.get_account_information(alice) == (debt0, coll0)
assert engine.health_factor(alice) == hf0
assert dsc.balanceOf(alice) == 400 * E18
print("state unchanged: debt 1,000, HF 0.55, alice still holds 400 DSC")
​
# contrast: the gate is on the final HF, not on whether the repayment helps
with boa.env.anchor():
with boa.env.prank(buyer):
dsc.transfer(alice, 50 * E18)
with boa.env.prank(alice):
dsc.approve(engine.address, 450 * E18)
engine.burn_dsc(450 * E18)
assert engine.health_factor(alice) == E18
print("contrast: with 50 more DSC, burn_dsc(450) succeeds (lands at HF exactly 1.0)")
with boa.env.anchor():
with boa.env.prank(buyer):
dsc.transfer(alice, 600 * E18)
with boa.env.prank(alice):
dsc.approve(engine.address, 1_000 * E18)
engine.burn_dsc(1_000 * E18)
assert engine.user_to_dsc_minted(alice) == 0 and engine.health_factor(alice) == MAX
print("contrast: full repayment burn_dsc(1,000) succeeds (debt 0 -> HF max_value, lines 324-325)")
​
# ------------------------------------------------------------------ 3. consequence
print("== 3. consequence: liquidator covers the full, unreduced 1,000 debt ==")
fund_liquidator(dsc, weth, engine, liz, 1_000 * E18)
liz_weth0 = weth.balanceOf(liz)
with boa.env.prank(liz):
engine.liquidate(weth.address, alice, 1_000 * E18)
seized_v = weth.balanceOf(liz) - liz_weth0
alice_weth_left_v = engine.get_collateral_balance_of_user(alice, weth.address)
alice_equity_v = dsc.balanceOf(alice) + engine.get_usd_value(weth.address, alice_weth_left_v) # DSC valued at $1
assert engine.user_to_dsc_minted(alice) == 0
assert seized_v == 999_999_999_999_999_999 # ~1 WETH: all of her collateral
assert alice_weth_left_v == 1 # 1 wei rounding dust
assert dsc.balanceOf(alice) == 400 * E18
print(f"liquidator seized {seized_v / E18:.18f} WETH (${usd(engine.get_usd_value(weth.address, seized_v)):,.2f}) for 1,000 DSC")
print(f"alice ends with 400 DSC + {alice_weth_left_v} wei WETH -> equity ${usd(alice_equity_v):,.2f}")
​
# ------------------------------------------------------------------ 4. self-liquidation, C > 1.1 D
print("== 4. self-liquidation at ETH $1,200 (C > 1.1 D): line 135 blocks it ==")
d2, w2, f2, e2 = deploy()
open_alice(d2, w2, f2, e2, alice, buyer, 1_200)
hf_start = e2.health_factor(alice)
assert hf_start == 6 * 10**17
with boa.env.prank(alice):
d2.approve(e2.address, 400 * E18)
with boa.reverts(BHF):
e2.burn_dsc(300 * E18) # would give 600/700 = 0.857
with boa.reverts(BHF):
e2.liquidate(w2.address, alice, 400 * E18)
print(f"HF {hf_start / E18}: burn_dsc(300) -> {BHF}; liquidate(weth, alice, 400) by alice -> {BHF} (line 135, msg.sender's HF)")
# The same liquidation from a debt-free second wallet passes, so the self-liquidation
# block is specifically the msg.sender post-check (and also the available workaround).
with boa.env.anchor():
with boa.env.prank(alice):
d2.transfer(alice2, 400 * E18)
with boa.env.prank(alice2):
d2.approve(e2.address, 400 * E18)
e2.liquidate(w2.address, alice, 400 * E18)
hf_after = e2.health_factor(alice)
assert hf_start < hf_after < E18
assert e2.user_to_dsc_minted(alice) == 600 * E18
print(f"same 400-DSC liquidation sent from alice's debt-free 2nd wallet succeeds: HF {hf_start / E18} -> {hf_after / E18:.4f} (< 1)")
​
# ------------------------------------------------------------------ 5. fixed engine
print("== 5. counterfactual: same engine with only the burn_dsc post-check removed ==")
src = open(ENGINE, encoding="utf-8").read()
old = (
" self._burn_dsc(amount_dsc_to_burn, msg.sender, msg.sender)\n"
" self._revert_if_health_factor_is_broken(msg.sender)\n"
"\n"
"\n"
"# ------------------------------------------------------------------\n"
"# PURE AND VIEW EXTERNAL FUNCTIONS"
)
assert src.count(old) == 1
fixed_src = src.replace(old, old.replace(" self._revert_if_health_factor_is_broken(msg.sender)\n", ""))
d3, w3, f3, e3 = deploy(fixed_src)
open_alice(d3, w3, f3, e3, alice, buyer, 1_100)
assert e3.health_factor(alice) == hf0
with boa.env.prank(alice):
d3.approve(e3.address, 400 * E18)
e3.burn_dsc(400 * E18)
assert e3.user_to_dsc_minted(alice) == 600 * E18
assert e3.health_factor(alice) == hf_if_burned # 0.9167: improved, still liquidatable
print(f"fixed: burn_dsc(400) succeeds, debt 600, HF {hf0 / E18} -> {e3.health_factor(alice) / E18:.4f}")
fund_liquidator(d3, w3, e3, liz, 600 * E18)
liz3_0 = w3.balanceOf(liz)
with boa.env.prank(liz):
e3.liquidate(w3.address, alice, 600 * E18)
seized_f = w3.balanceOf(liz) - liz3_0
alice_weth_left_f = e3.get_collateral_balance_of_user(alice, w3.address)
alice_equity_f = d3.balanceOf(alice) + e3.get_usd_value(w3.address, alice_weth_left_f)
assert e3.user_to_dsc_minted(alice) == 0
assert seized_f == 599_999_999_999_999_999 # ~0.6 WETH
assert alice_weth_left_f == 400_000_000_000_000_001 # ~0.4 WETH kept
print(f"fixed: liquidator covers 600, seizes {seized_f / E18:.6f} WETH; alice keeps {alice_weth_left_f / E18:.6f} WETH "
f"(${usd(e3.get_usd_value(w3.address, alice_weth_left_f)):,.2f}) -> equity ${usd(alice_equity_f):,.2f}")
​
loss = alice_equity_f - alice_equity_v
print(f"alice's extra loss from the blocked repayment: ${usd(loss):,.2f} (= 10% bonus on the 400 she tried to repay)")
assert 39_999 * 10**15 < loss <= 40 * E18 + 10**6
assert alice_equity_v < alice_equity_f
​
print("PASS: AS-07 proven - burn_dsc/redeem_collateral_for_dsc(…,0,…) reject HF-improving partial repayments "
"that leave HF < 1; self-liquidation from the borrower's own address is blocked by line 135")

The script ends with: PASS: AS-07 proven - burn_dsc/redeem_collateral_for_dsc(…,0,…) reject HF-improving partial repayments that leave HF < 1; self-liquidation from the borrower's own address is blocked by line 135

Recommended Mitigation

A pure repayment cannot lower the health factor, so drop the check from burn_dsc. In redeem_collateral_for_dsc, keep the check only when collateral actually leaves.

--- a/src/dsc_engine.vy
+++ b/src/dsc_engine.vy
@@ def redeem_collateral_for_dsc @@
self._burn_dsc(amount_dsc_to_burn, msg.sender, msg.sender)
self._redeem_collateral(
token_collateral_address, amount_collateral, msg.sender, msg.sender
)
- self._revert_if_health_factor_is_broken(msg.sender)
+ if amount_collateral > 0:
+ self._revert_if_health_factor_is_broken(msg.sender)
@@ def burn_dsc @@
def burn_dsc(amount_dsc_to_burn: uint256):
self._burn_dsc(amount_dsc_to_burn, msg.sender, msg.sender)
- self._revert_if_health_factor_is_broken(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!