"""
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
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"
MOCK_FEED = "src/mocks/MockV3Aggregator.vy"
E18 = 10**18
MAXU = 2**256 - 1
def px(dollars):
return int(round(dollars * 10**8))
def m_usd(amount, price8):
return (price8 * 10**10 * amount) // E18
def m_tok(usd, price8):
return (usd * E18) // (price8 * 10**10)
def m_hf(debt, coll_usd):
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"
dsc = boa.load(DSC_SRC)
weth = boa.load(MOCK_TOKEN)
wbtc = boa.load(MOCK_TOKEN)
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]
return f.error_detail
liq = open_position("liquidator", 400 * E18, 50_000 * E18)
with boa.env.prank(liq):
dsc.approve(engine.address, MAXU)
bob = open_position("bob", E18, 1_000 * E18)
alice = open_position("alice", E18, 1_000 * E18)
carol = open_position("carol", E18 // 2, 1_000 * E18, wbtc_amt=E18 // 2)
dave = open_position("dave", E18 // 2, 500 * E18)
for u in (bob, alice, carol, dave):
assert engine.health_factor(u) == E18
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
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
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)
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
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
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
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
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
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
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
print("== 5. what is NOT true / narrower ==")
with boa.env.anchor():
with boa.env.prank(alice):
dsc.approve(engine.address, MAXU)
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.burn_dsc(474 * E18)
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
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"
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)")
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.