Algo Ssstablecoinsss

AI First Flight #2
Beginner FriendlyDeFi
EXP
View results
Submission Details
Severity: high
Valid

Wrong `MIN_HEALTH_FACTOR` for WBTC

Root+Impact

The function _revert_if_health_factor_is_broken is intended to halt execution whenever a user’s health factor falls below the required minimum. However, due to a scaling mismatch between assets with different decimals - specifically WBTC (8 decimals) versus WETH (18 decimals) - the computed user_health_factor for WBTC positions can be inflated by ~10^10. Because MIN_HEALTH_FACTOR is set to 1e18, the bug may allow under‑collateralized WBTC positions to avoid reverting, undermining liquidation safety checks.

Description

  • The _revert_if_health_factor_is_broken function ensures that a user's health factor remains above the required minimum threshold.

  • Because MIN_HEALTH_FACTOR is set to 10^18 - ten orders of magnitude greater than Bitcoin’s Satoshi precision of 10^8 - the user_health_factor for WBTC can become inflated by a factor exceeding 10^10 relative to its true value.

@> MIN_HEALTH_FACTOR: public(constant(uint256)) = 1 * (10**18)
//....
def _revert_if_health_factor_is_broken(user: address):
user_health_factor: uint256 = self._health_factor(user)
assert (
user_health_factor >= MIN_HEALTH_FACTOR // The constant is only applicable for WETH
), "DSCEngine__BreaksHealthFactor"

Risk

Likelihood: High

  • WBTC deposits are common, and the code path is generic. Any user holding WBTC may hit the faulty branch where WETH scaling/parameters are implicitly applied.

  • Unit mismatches of this kind usually escape simple tests unless there are explicit multi‑decimal invariant tests.

Impact: High

  • Broken safety checks: Under‑collateralized (unsafe) WBTC positions do not revert in places that depend on _revert_if_health_factor_is_broken, allowing bad debt and bypassing liquidation guards.

  • Protocol insolvency risk: Persistently overstated health factors can allow excessive borrowing, impairing the system’s solvency during price moves.

Proof of Concept

  • Goal: Show that a user with an actually unsafe WBTC position (HF < 1.0) still passes the _revert_if_health_factor_is_broken check because the computed HF is inflated by ~10^10.

# -------------------------------
# Setup: Assets and Parameters
# -------------------------------
ASSET = {
"WETH": { decimals: 18, liq_threshold_bps: 8250 }, # 82.5%
"WBTC": { decimals: 8, liq_threshold_bps: 8250 } # 82.5%
}
# Global minimum HF scaled to 1e18 (intended: HF >= 1.0)
MIN_HEALTH_FACTOR = 1e18
# Mock prices in a consistent USD fixed-point (e.g., 1e8 or 1e18—consistency matters)
# For the concept, assume prices are normalized elsewhere to 1e18 (the correct approach).
PRICE = {
"WBTC": 20_000 * 1e18, # 1 WBTC = $20,000
"WETH": 2_000 * 1e18 # 1 WETH = $2,000 (not used in PoC)
}
# -------------------------------
# Correct vs. Buggy Health Factor
# -------------------------------
# Correct computation: normalize token amounts to 1e18 regardless of decimals
function correct_health_factor(position):
# position.collateral_token = "WBTC"
# position.collateral_amount_raw = 1 * 10^8 (1 WBTC in 8 decimals)
# position.debt_value_usd = $16,500 * 1e18
token = ASSET[position.collateral_token]
# scale raw amount to 1e18 units
scale_up = 10^(18 - token.decimals) # for WBTC: 10^(18-8) = 1e10
collateral_amount_1e18 = position.collateral_amount_raw * scale_up
collateral_value_usd = collateral_amount_1e18 * PRICE[position.collateral_token] / 1e18
# Apply liquidation threshold (bps to 1e18 scale)
liq_threshold = token.liq_threshold_bps * 1e14 # 8250 bps -> 0.825 * 1e18
numerator = collateral_value_usd * liq_threshold / 1e18
HF_scaled = numerator * 1e18 / position.debt_value_usd # final HF scaled to 1e18
return HF_scaled
# Buggy computation: forget to normalize WBTC (8 decimals) to 1e18
# or accidentally reuse a WETH path (implicitly assuming 18 decimals).
function buggy_health_factor(position):
token = ASSET[position.collateral_token]
# BUG: miss the scale_up for non-18 decimal tokens.
collateral_amount_incorrect_units = position.collateral_amount_raw # still in 1e8 for WBTC
# Downstream math treats this as if it were 1e18-based, overstating by 1e10.
collateral_value_usd_bug = collateral_amount_incorrect_units * PRICE[position.collateral_token] / 1e18
# Additional potential bug: use WETH threshold for WBTC (optional to demonstrate)
liq_threshold_wrong = ASSET["WETH"].liq_threshold_bps * 1e14
numerator_bug = collateral_value_usd_bug * liq_threshold_wrong / 1e18
HF_scaled_bug = numerator_bug * 1e18 / position.debt_value_usd
return HF_scaled_bug
# -------------------------------
# Scenario: Actually Unsafe, But No Revert
# -------------------------------
position = {
collateral_token: "WBTC",
collateral_amount_raw: 1 * 10^8, # 1 WBTC (8 decimals)
debt_value_usd: 16_500 * 1e18 # debt close to limit, true HF < 1.0
}
HF_true = correct_health_factor(position) # e.g., ~0.99 * 1e18 (unsafe or borderline)
HF_bug = buggy_health_factor(position) # ~HF_true * 1e10 (inflated)
assert HF_true < MIN_HEALTH_FACTOR # TRUE: should revert
assert HF_bug >> MIN_HEALTH_FACTOR # TRUE: passes (no revert)
# The protocol check:
if HF_bug >= MIN_HEALTH_FACTOR:
# BUG: No revert occurs though position is unsafe
proceed()
else:
revert("DSCEngine__BreaksHealthFactor")

Recommended Mitigation

  • Normalize all token amounts to 18 decimals before any risk calculations:

    • Compute scale_up = 10^(18 − token.decimals) for each asset.

    • Convert raw amounts: amount_1e18 = amount_raw * scale_up.

    • Ensure all prices and thresholds are applied in a consistent fixed‑point (preferably 1e18).

  • Never mix per‑asset parameters:

    • Use the WBTC liquidation threshold/collateral factor for WBTC positions, not WETH’s.

    • Centralize parameter lookup by token address to avoid cross‑asset leakage.

  • Strengthen tests:

    • Unit tests for 8‑decimals (WBTC, USDC/USDT) and 18‑decimals (WETH, DAI) assets.

    • Property/invariant tests: assert that lowering collateral or raising debt can only decrease HF and eventually fail the check.

    • Differential tests comparing correct vs. buggy implementations on randomized inputs.

  • Runtime assertions / monitoring:

    • Add a sanity check: if an asset’s decimals ≠ 18, assert that a normalization step has been applied.

    • Telemetry/metrics for extreme HF values to detect abnormal inflation.

# Consistent fixed-point is 1e18 across the board.
struct AssetConfig:
decimals: uint256
liq_threshold_bps: uint256 # e.g., 8250
assetConfig: HashMap[address, AssetConfig]
priceFeed: HashMap[address, uint256] # USD price scaled to 1e18
MIN_HEALTH_FACTOR: constant(uint256) = 1e18
@internal
def _normalized_amount_1e18(token: address, raw: uint256) -> uint256:
d = self.assetConfig[token].decimals # e.g., 8 for WBTC
assert d <= 18, "UnsupportedDecimals"
return raw * 10 ** (18 - d)
@internal
def _health_factor(user: address) -> uint256:
total_collateral_usd_1e18 = 0
total_debt_usd_1e18 = self._user_debt_usd_1e18(user)
for token in self._user_collateral_tokens(user):
cfg = self.assetConfig[token]
amt_raw = self._user_balance(token, user) # in token.decimals
amt_1e18 = self._normalized_amount_1e18(token, amt_raw)
price_1e18 = self.priceFeed[token] # USD * 1e18
value_usd_1e18 = amt_1e18 * price_1e18 / 1e18
liq_threshold_1e18 = cfg.liq_threshold_bps * 1e14 # bps -> 1e18
total_collateral_usd_1e18 += value_usd_1e18 * liq_threshold_1e18 / 1e18
if total_debt_usd_1e18 == 0:
return MAX_UINT256 # or a defined "infinite" HF sentinel
return total_collateral_usd_1e18 * 1e18 / total_debt_usd_1e18
@internal
def _revert_if_health_factor_is_broken(user: address):
hf = self._health_factor(user)
assert hf >= MIN_HEALTH_FACTOR, "DSCEngine__BreaksHealthFactor"
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 14 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-01] In the function \_revert_if_health_factor_is_broken constatnt variable MIN_HEALTH_FACTOR is only for WETH.

## Description The `_revert_if_health_factor_is_broken` function is responsible for ensuring that a user's health factor meets the minimum required standard. There is only implementation for WETH. ## Vulnerability Details In the function, there is only implementation for WETH. ```Solidity @internal def _revert_if_health_factor_is_broken(user: address): user_health_factor: uint256 = self._health_factor(user) assert ( user_health_factor >= MIN_HEALTH_FACTOR ), "DSCEngine__BreaksHealthFactor" ``` Value of the `MIN_HEALTH_FACTOR=10^18`is higher than the Satoshi factor which is 10^8. As a result, for WBTC, the `user_health_factor` can be inflated to more than 101010^{10} times its normal value. ## Impact Bigger value of MIN_HEALTH_FACTOR for WBTC allows on bigger value of `user_health_factor`and wrong value when function should revert. ## Recommendations Add MIN_HEALTH_FACTOR also for WBTC. ```Solidity @internal def _revert_if_health_factor_is_broken(user: address): user_health_factor: uint256 = self._health_factor(user) # Check if the user's token is WBTC and adjust health factor accordingly if user_health_factor >= (MIN_HEALTH_FACTOR * 10**10): # If user health factor is higher due to WBTC precision, still ensure it meets the minimum assert user_health_factor >= MIN_HEALTH_FACTOR, "DSCEngine__BreaksHealthFactor" else: assert user_health_factor >= MIN_HEALTH_FACTOR, "DSCEngine__BreaksHealthFactor" ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!