Algo Ssstablecoinsss

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

No ZKsync sequencer-uptime check or post-restart grace period before Chainlink prices are used, so liquidations run in the sequencer-restart block before borrowers can react and they lose the 10% liquidation bonus

Root + Impact

Description

  • The README's only target chain is ZKsync Era, an L2 whose transactions depend on a single sequencer. On L2s, Chainlink's recommended pattern is to read the sequencer-uptime feed and, after a restart, wait a grace period before acting on prices. That gives users time to repay or top up positions they could not manage while the sequencer was down.

  • _stale_check_latest_round_data (oracle_lib.vy:28-50) reads only the asset feed. It checks updated_at != 0, answered_in_round >= round_id and the 72h age. Nothing in scope reads a sequencer-uptime feed or applies a grace period, and the engine constructor has no parameter for one. In the first block after a restart, a liquidator whose transaction is ordered first can liquidate positions whose owners had no chance to react. A pre-outage price that is still inside the 72h bound is also accepted for minting.

# src/oracle_lib.vy:40-50 - the only checks applied before a price is used
(
round_id, price, started_at, updated_at, answered_in_round
@> ) = staticcall price_price.latestRoundData()
​
@> assert updated_at != 0, "DSCEngine_StalePrice"
@> assert answered_in_round >= round_id, "DSCEngine_StalePrice"
​
seconds_since: uint256 = block.timestamp - updated_at
@> assert seconds_since <= TIMEOUT, "DSCEngine_StalePrice"
​
return (round_id, price, started_at, updated_at, answered_in_round)
​
# src/dsc_engine.vy:59-64 - no slot for a sequencer-uptime feed
@deploy
@>def __init__(
token_addresses: address[2],
price_feed_addresses: address[2],
dsc_address: address,
):

Risk

Likelihood:

  • This occurs when the ZKsync Era sequencer halts and the market moves enough during the halt to push positions below HF 1. Sequencer halts are rare but have happened on L2s.

  • At restart, liquidation bots with queued transactions are ordered ahead of borrowers' top-ups, because nothing in the engine gives borrowers a window.

Impact:

  • Unfair liquidation at restart. In the PoC, after a modelled 5h outage and an ETH move from $2,000 to $1,700, liquidate runs in the restart block, 0 s after the uptime feed's startedAt. It seizes 5.823529 WETH: a 5.294118 debt-equivalent plus a 0.529412 bonus. The borrower loses $900, the 10% bonus on $9,000 of debt covered. If the borrower's 2 WETH top-up had executed first, HF would be 1.1333 and the same liquidate would revert DSCEngine__HealthFactorOk. A reference 1h grace-period guard passes only at restart + 3601 s. The victim is genuinely below HF 1 at the post-restart price; the harm is the lost chance to react, not a wrong price.

  • Stale-price minting at restart. A 5.02h-old $2,000 answer is accepted, so a mint of 10,000 DSC on 10 WETH succeeds although the current $1,700 price rejects it (HF 1.00 at $2,000, 0.85 at $1,700). This limb is narrow:

    • The minter gains nothing and no bad debt arises unless the price moved more than about 50% during the outage.

    • Accepting the old answer also depends on the separate 72h TIMEOUT issue.

Proof of Concept

The PoC deploys the real dsc_engine.vy, decentralized_stable_coin.vy and oracle_lib.vy with the repo mocks, and leaves the engine unmodified. Boa has no sequencer, so the outage is modelled with three pieces:

  • A 0-decimal MockV3Aggregator acts as a Chainlink-style uptime feed (0 = up, 1 = down, startedAt = time of the status change).

  • A 5h time_travel with no L2 transactions.

  • A restart block at t1 == startedAt.

An inline reference guard (the proposed fix, not part of the protocol) shows which actions a grace period would have blocked.

Run from the repo root: .venv/Scripts/python poc/poc_AS-05.py

"""
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
​
# Windows non-UTF-8 locales cannot read snekmate's UTF-8 sources with the
# default codec; re-run once in Python UTF-8 mode.
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"
​
# Reference guard = the fix the finding proposes (NOT part of the protocol).
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 # ETH/USD before the outage
P_POST = 1_700 * 10**8 # ETH/USD once the feed updates after restart
HOUR = 3600
OUTAGE = 5 * HOUR
​
​
def now():
return boa.env.evm.patch.timestamp
​
​
def usd(x):
return x / E18
​
​
# ---------------------------------------------------------------- 0. static
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"]
​
# ---------------------------------------------------------------- deploy
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) # sequencer uptime feed: answer 0 = up
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) # sequencer has been up for 2h
eth_feed.updateAnswer(P_PRE)
btc_feed.updateAnswer(100_000 * 10**8)
guard.check() # baseline: sequencer up, grace long over -> guard passes
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]
​
# ---------------------------------------------------------------- outage
boa.env.time_travel(seconds=60)
uptime.updateRoundData(2, 1, now(), now()) # sequencer DOWN
with boa.reverts("SequencerDown"):
guard.check()
boa.env.time_travel(seconds=OUTAGE) # 5h: no L2 transactions possible
t1 = now()
uptime.updateRoundData(3, 0, t1, t1) # sequencer back UP at 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
# ---------------------------------------------------------------- Part B
print("\n== 3. Part B (limb 2): stale pre-outage price accepted at restart ==")
with restart():
with boa.reverts("GracePeriodNotOver"):
guard.check() # the fix would block every action now
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
​
# counterfactual: the feed's post-restart update lands first -> mint rejected
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")
​
# actual: mint at the stale price before the feed catches up
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) # same block: the feed catches up
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
​
# ---------------------------------------------------------------- Part A
print("\n== 4. Part A (limb 1): liquidation in the restart block, no grace period ==")
eth_feed.updateAnswer(P_POST) # first post-restart Chainlink update, same block t1
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
​
# counterfactual: borrower's top-up (prepared during the outage) lands first
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")
​
# the fix would have held the liquidator back for the grace period
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): # queued bot tx ordered first at restart
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 # ~$900, rounding only
​
# after a 1h grace period the guard would pass and the borrower could have acted
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")

The script ends with: PASS: AS-05 - oracle/engine accept prices and allow liquidate/mint in the sequencer-restart block with no uptime check or grace period

Recommended Mitigation

Give the engine the ZKsync sequencer-uptime feed and check it, with a grace period, before any price is used.

--- a/src/oracle_lib.vy
+++ b/src/oracle_lib.vy
@@ 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
)
--- a/src/dsc_engine.vy
+++ b/src/dsc_engine.vy
@@ 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
+ )
Updates

Lead Judging Commences

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