"""
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)
_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
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")
dsc, weth, eth_feed, engine = deploy()
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}")
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
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")
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}")
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)")
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")
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)")
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)
assert engine.user_to_dsc_minted(alice) == 0
assert seized_v == 999_999_999_999_999_999
assert alice_weth_left_v == 1
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}")
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)
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)")
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)")
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
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
assert alice_weth_left_f == 400_000_000_000_000_001
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")