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.
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.
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
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
Normalise every conversion by the token's own decimals, and pin the feed decimals the maths assumes at construction (or normalise them too).
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.