Algo Ssstablecoinsss

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

Hardcoded 18-decimal assumption makes 8-decimal collateral (WBTC) worth ~$0, bricking deposits and forcing wrongful liquidations

Hardcoded 18-decimal assumption makes 8-decimal collateral (WBTC) worth ~$0, bricking deposits and forcing wrongful liquidations

Severity: High · Impact: High · Likelihood: High

Description

  • The engine converts a collateral amount to USD assuming the token uses 18 decimals: it scales the 8-decimal Chainlink price up by ADDITIONAL_FEED_PRECISION (1e10) and divides by PRECISION (1e18), which is only correct when amount itself has 18 decimals.

  • WBTC — a named, in-scope collateral (README: "Tokens: WETH and WBTC") — has 8 decimals on Ethereum and ZKsync Era. A WBTC amount is therefore 1e10 times smaller than the formula expects, so the computed USD value is understated by 1e10 (e.g. $60,000 of WBTC is valued at $0.000006).

@internal
@view
def _get_usd_value(token: address, amount: uint256) -> uint256:
...
return (
@> (convert(price, uint256) * ADDITIONAL_FEED_PRECISION) * amount
@> ) // PRECISION # assumes `amount` has 18 decimals; WBTC has 8

Risk

Likelihood:

  • Occurs for every WBTC deposit, because real WBTC is fixed at 8 decimals — the mispricing is deterministic, not conditional.

  • Occurs for any non-18-decimal asset in a fork, which the protocol explicitly invites ("swap out WETH & WBTC for any basket of assets they like").

Impact:

  • WBTC depositors receive ~$0 of collateral credit, so they cannot mint any DSC — the protocol's core function is broken for a primary collateral.

  • Any position backed by WBTC has a health factor of ~0 and is instantly liquidatable, letting liquidators seize WBTC (plus the 10% bonus) from users who are actually massively overcollateralized — direct loss of user funds.

Proof of Concept

Save the block below as tests/poc_h1.py inside the cloned repo and run mox test tests/poc_h1.py. The 8-decimal collateral (real WBTC) is compiled inline via boa.loads; everything else uses the contest's own files. A 1 BTC deposit ($60k) is valued at under $1, and the depositor cannot mint even 1 DSC.

import boa
from eth_utils import to_wei
from src import dsc_engine, decentralized_stable_coin
from src.mocks import mock_token, MockV3Aggregator
# A minimal 8-decimal ERC20 (like real WBTC), compiled inline.
WBTC8_SRC = """
# pragma version 0.4.0
from ethereum.ercs import IERC20
implements: IERC20
balanceOf: public(HashMap[address, uint256])
allowance: public(HashMap[address, HashMap[address, uint256]])
totalSupply: public(uint256)
@external
def transfer(_to: address, _v: uint256) -> bool:
self.balanceOf[msg.sender] -= _v
self.balanceOf[_to] += _v
return True
@external
def transferFrom(_f: address, _t: address, _v: uint256) -> bool:
self.allowance[_f][msg.sender] -= _v
self.balanceOf[_f] -= _v
self.balanceOf[_t] += _v
return True
@external
def approve(_s: address, _v: uint256) -> bool:
self.allowance[msg.sender][_s] = _v
return True
@external
@view
def decimals() -> uint8:
return 8
@external
def mint_amount(_v: uint256):
self.balanceOf[msg.sender] += _v
self.totalSupply += _v
"""
def test_8dec_collateral_undervalued_by_1e10():
dsc = decentralized_stable_coin.deploy()
wbtc8 = boa.loads(WBTC8_SRC) # 8-decimal collateral
weth = mock_token.deploy() # 18-decimal
btc_usd = MockV3Aggregator.deploy(8, 60_000 * 10**8) # $60,000
eth_usd = MockV3Aggregator.deploy(8, 2_000 * 10**8)
engine = dsc_engine.deploy(
[wbtc8.address, weth.address], [btc_usd.address, eth_usd.address], dsc
)
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
# 1 whole WBTC = 1e8 units @ $60,000.
one_wbtc = 10**8
usd = engine.get_usd_value(wbtc8.address, one_wbtc)
correct = to_wei(60_000, "ether") # $60,000 in 18-decimal USD
assert usd == correct // 10**10 # actual value is 1e10x too small
assert usd < to_wei(1, "ether") # a $60k deposit reads as under $1
# Consequence: deposit 1 WBTC and you cannot mint even 1 DSC.
user = boa.env.generate_address("wbtc_user")
with boa.env.prank(user):
wbtc8.mint_amount(one_wbtc)
wbtc8.approve(engine.address, one_wbtc)
engine.deposit_collateral(wbtc8.address, one_wbtc)
with boa.reverts(): # $0 collateral can't back any DSC
engine.mint_dsc(to_wei(1, "ether"))

Recommended Mitigation

Normalise each collateral amount to 18 decimals using the token's own decimals() before valuing it, rather than assuming 18.

+ # fetch and cache each collateral token's decimals at registration time
+ # (e.g. token_address_to_decimals: HashMap[address, uint8])
def _get_usd_value(token: address, amount: uint256) -> uint256:
...
+ decimals: uint256 = convert(staticcall IERC20Detailed(token).decimals(), uint256)
+ normalized_amount: uint256 = amount * (10 ** (18 - decimals))
return (
- (convert(price, uint256) * ADDITIONAL_FEED_PRECISION) * amount
+ (convert(price, uint256) * ADDITIONAL_FEED_PRECISION) * normalized_amount
) // PRECISION

Apply the inverse normalisation in _get_token_amount_from_usd so redemptions and liquidation payouts return the correct token quantity.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours 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!