An inline reference guard (the proposed fix, not part of the protocol) shows which actions a grace period would have blocked.
"""
PoC AS-05: no L2 sequencer-uptime check / grace period before Chainlink prices
are used (README: "Chains: ZKsync Era").
oracle_lib._stale_check_latest_round_data only checks
updated_at != 0, answered_in_round >= round_id, age <= 72h.
Nothing in src/ reads a sequencer uptime feed or applies a grace period after a
sequencer restart, and the engine constructor has no slot for such a feed.
Model of the outage (boa has no sequencer, so it is modelled):
* a Chainlink-style sequencer uptime feed (MockV3Aggregator, answer 0 = up,
1 = down, startedAt = time of the status change);
* a 5-hour gap in which nobody transacts on L2;
* the restart block, at timestamp t1 == uptime.startedAt.
A reference guard (inline below; the standard fix: require answer == 0 and
block.timestamp - startedAt > 3600) is evaluated at the same moments to show
what a grace period would have blocked. The engine itself is never modified.
Part A (limb 1): at the restart block the liquidator's queued liquidate() runs
before the borrower can top up; borrower loses the 10% bonus. Counterfactual:
if the borrower's top-up lands first, the same liquidate() reverts.
Part B (limb 2): at the restart block, before the ETH/USD feed's next update
lands, the 5h-old pre-outage price is accepted and a mint that the current
price would reject succeeds.
Run (from the repo root): .venv/Scripts/python poc/poc_AS-05.py
"""
import re
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"
DSC_SRC = "src/decentralized_stable_coin.vy"
ORACLE = "src/oracle_lib.vy"
MOCK_TOKEN = "src/mocks/mock_token.vy"
MOCK_FEED = "src/mocks/MockV3Aggregator.vy"
GUARD_SRC = """
interface AggregatorV3Interface:
def latestRoundData() -> (uint80, int256, uint256, uint256, uint80): view
GRACE_PERIOD: constant(uint256) = 3600
FEED: immutable(address)
@deploy
def __init__(feed: address):
FEED = feed
@external
@view
def check():
r: uint80 = 0
answer: int256 = 0
started_at: uint256 = 0
u: uint256 = 0
a: uint80 = 0
(r, answer, started_at, u, a) = staticcall AggregatorV3Interface(FEED).latestRoundData()
assert answer == 0, "SequencerDown"
assert block.timestamp - started_at > GRACE_PERIOD, "GracePeriodNotOver"
"""
E18 = 10**18
P_PRE = 2_000 * 10**8
P_POST = 1_700 * 10**8
HOUR = 3600
OUTAGE = 5 * HOUR
def now():
return boa.env.evm.patch.timestamp
def usd(x):
return x / E18
print("== 0. static: no sequencer / grace logic in scope ==")
for path in (ORACLE, ENGINE):
src = open(path, encoding="utf-8").read()
hits = re.findall(r"(?i)sequencer|uptime|grace", src)
print(f" {path}: sequencer/uptime/grace mentions = {len(hits)}")
assert hits == []
eng_src = open(ENGINE, encoding="utf-8").read()
ctor = re.search(r"def __init__\((.*?)\):", eng_src, re.S).group(1)
ctor_args = [a.split(":")[0].strip() for a in ctor.split(",") if a.strip()]
print(f" engine constructor args = {ctor_args}")
assert ctor_args == ["token_addresses", "price_feed_addresses", "dsc_address"]
dsc = boa.load(DSC_SRC)
weth = boa.load(MOCK_TOKEN)
wbtc = boa.load(MOCK_TOKEN)
eth_feed = boa.load(MOCK_FEED, 8, P_PRE)
btc_feed = boa.load(MOCK_FEED, 8, 100_000 * 10**8)
engine = boa.load(ENGINE, [wbtc.address, weth.address], [btc_feed.address, eth_feed.address], dsc.address)
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
uptime = boa.load(MOCK_FEED, 0, 0)
guard = boa.loads(GUARD_SRC, uptime.address, name="sequencer_guard")
borrower = boa.env.generate_address("borrower")
liquidator = boa.env.generate_address("liquidator")
minter = boa.env.generate_address("minter")
boa.env.time_travel(seconds=2 * HOUR)
eth_feed.updateAnswer(P_PRE)
btc_feed.updateAnswer(100_000 * 10**8)
guard.check()
print("\n== 1. pre-outage: positions opened at ETH = $2000 ==")
with boa.env.prank(borrower):
weth.mint_amount(12 * E18)
weth.approve(engine.address, 2**256 - 1)
engine.deposit_collateral_and_mint_dsc(weth.address, 10 * E18, 9_000 * E18)
with boa.env.prank(liquidator):
weth.mint_amount(100 * E18)
weth.approve(engine.address, 2**256 - 1)
engine.deposit_collateral_and_mint_dsc(weth.address, 100 * E18, 9_000 * E18)
dsc.approve(engine.address, 2**256 - 1)
with boa.env.prank(minter):
weth.mint_amount(10 * E18)
weth.approve(engine.address, 2**256 - 1)
hf0 = engine.health_factor(borrower)
print(f" borrower: 10 WETH, 9000 DSC debt, HF = {hf0 / E18:.4f} (safe)")
assert hf0 >= E18
t_last_price = eth_feed.latestRoundData()[3]
boa.env.time_travel(seconds=60)
uptime.updateRoundData(2, 1, now(), now())
with boa.reverts("SequencerDown"):
guard.check()
boa.env.time_travel(seconds=OUTAGE)
t1 = now()
uptime.updateRoundData(3, 0, t1, t1)
print(f"\n== 2. restart block t1: sequencer down {OUTAGE // HOUR}h, uptime.startedAt = t1 ==")
age = t1 - t_last_price
print(f" ETH/USD last updated {age} s ({age / HOUR:.2f} h) ago")
assert uptime.latestRoundData()[1] == 0 and uptime.latestRoundData()[2] == t1
restart = boa.env.anchor
print("\n== 3. Part B (limb 2): stale pre-outage price accepted at restart ==")
with restart():
with boa.reverts("GracePeriodNotOver"):
guard.check()
stale_px = engine.get_usd_value(weth.address, E18)
print(f" engine values 1 WETH at ${usd(stale_px):,.0f} with a {age / HOUR:.2f}h-old answer (oracle age limit 72h)")
assert stale_px == 2_000 * E18
with boa.env.anchor():
eth_feed.updateAnswer(P_POST)
with boa.env.prank(minter):
with boa.reverts("DSCEngine__BreaksHealthFactor"):
engine.deposit_collateral_and_mint_dsc(weth.address, 10 * E18, 10_000 * E18)
print(" baseline: at the current $1700 price, minting 10000 DSC on 10 WETH reverts BreaksHealthFactor")
with boa.env.prank(minter):
engine.deposit_collateral_and_mint_dsc(weth.address, 10 * E18, 10_000 * E18)
hf_m_stale = engine.health_factor(minter)
eth_feed.updateAnswer(P_POST)
hf_m = engine.health_factor(minter)
print(f" stale-price mint of 10000 DSC succeeded: HF {hf_m_stale / E18:.4f} at $2000 -> {hf_m / E18:.4f} at $1700")
assert dsc.balanceOf(minter) == 10_000 * E18
assert hf_m_stale == E18 and hf_m < E18
print("\n== 4. Part A (limb 1): liquidation in the restart block, no grace period ==")
eth_feed.updateAnswer(P_POST)
assert now() == t1
hf1 = engine.health_factor(borrower)
print(f" ETH -> $1700; borrower HF = {hf1 / E18:.4f}; seconds since restart = {now() - t1}")
assert hf1 < E18
with boa.env.anchor():
with boa.env.prank(borrower):
engine.deposit_collateral(weth.address, 2 * E18)
hf_saved = engine.health_factor(borrower)
with boa.env.prank(liquidator):
with boa.reverts("DSCEngine__HealthFactorOk"):
engine.liquidate(weth.address, borrower, 9_000 * E18)
print(f" baseline: if the borrower's 2 WETH top-up executes first, HF = {hf_saved / E18:.4f} and liquidate reverts HealthFactorOk")
with boa.reverts("GracePeriodNotOver"):
guard.check()
col_before = engine.get_collateral_balance_of_user(borrower, weth.address)
liq_weth_before = weth.balanceOf(liquidator)
with boa.env.prank(liquidator):
engine.liquidate(weth.address, borrower, 9_000 * E18)
col_after = engine.get_collateral_balance_of_user(borrower, weth.address)
seized = col_before - col_after
assert weth.balanceOf(liquidator) - liq_weth_before == seized
debt_equiv = engine.get_token_amount_from_usd(weth.address, 9_000 * E18)
bonus = seized - debt_equiv
bonus_usd = engine.get_usd_value(weth.address, bonus)
print(f" liquidate() executed at t1 (+0 s): seized {seized / E18:.6f} WETH "
f"(debt-equivalent {debt_equiv / E18:.6f} + bonus {bonus / E18:.6f})")
print(f" borrower's avoidable loss = bonus = ${usd(bonus_usd):,.2f} (10% of $9000 debt covered)")
assert engine.user_to_dsc_minted(borrower) == 0
assert bonus == debt_equiv * 10 // 100
assert abs(bonus_usd - 900 * E18) < E18
boa.env.time_travel(seconds=HOUR + 1)
guard.check()
print(" reference guard passes only after t1 + 3601 s (the window the borrower never got)")
print("\nPASS: AS-05 - oracle/engine accept prices and allow liquidate/mint in the "
"sequencer-restart block with no uptime check or grace period")
Give the engine the ZKsync sequencer-uptime feed and check it, with a grace period, before any price is used.
@@ constants @@
TIMEOUT: constant(uint256) = 72 * 3600
+GRACE_PERIOD: constant(uint256) = 3600
@@ def stale_check_latest_round_data @@
def stale_check_latest_round_data(
chainlink_feed: address,
+ sequencer_uptime_feed: address,
) -> (uint80, int256, uint256, uint256, uint80):
- return self._stale_check_latest_round_data(chainlink_feed)
+ return self._stale_check_latest_round_data(chainlink_feed, sequencer_uptime_feed)
@@ def _stale_check_latest_round_data @@
def _stale_check_latest_round_data(
price_price_address: address,
+ sequencer_uptime_feed: address,
) -> (uint80, int256, uint256, uint256, uint80):
+ s_round: uint80 = 0
+ s_answer: int256 = 0
+ s_started_at: uint256 = 0
+ s_updated_at: uint256 = 0
+ s_answered: uint80 = 0
+ (
+ s_round, s_answer, s_started_at, s_updated_at, s_answered
+ ) = staticcall AggregatorV3Interface(sequencer_uptime_feed).latestRoundData()
+ assert s_answer == 0, "DSCEngine_SequencerDown"
+ assert block.timestamp - s_started_at > GRACE_PERIOD, "DSCEngine_GracePeriodNotOver"
+
price_price: AggregatorV3Interface = AggregatorV3Interface(
price_price_address
)
@@ immutables @@
DSC: public(immutable(i_decentralized_stable_coin))
COLLATERAL_TOKENS: public(immutable(address[2]))
+SEQUENCER_UPTIME_FEED: public(immutable(address))
@@ def __init__ @@
def __init__(
token_addresses: address[2],
price_feed_addresses: address[2],
dsc_address: address,
+ sequencer_uptime_feed: address,
):
DSC = i_decentralized_stable_coin(dsc_address)
COLLATERAL_TOKENS = token_addresses
+ SEQUENCER_UPTIME_FEED = sequencer_uptime_feed
@@ def _get_usd_value and def _get_token_amount_from_usd (both call sites) @@
(
round_id, price, started_at, updated_at, answered_in_round
- ) = oracle_lib._stale_check_latest_round_data(price_feed.address)
+ ) = oracle_lib._stale_check_latest_round_data(
+ price_feed.address, SEQUENCER_UPTIME_FEED
+ )