Algo Ssstablecoinsss

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

Missing ZKsync Era L2 Sequencer Uptime Check — Stale Prices Accepted After Sequencer Recovery

Missing ZKsync Era L2 Sequencer Uptime Check — Stale Prices Accepted After Sequencer Recovery

Scope

  • src/oracle_lib.vy

Description

The oracle_lib._stale_check_latest_round_data() function is expected to ensure prices are fresh and valid before allowing the protocol to proceed. On Ethereum Layer 2 networks like ZKsync Era, there is an additional oracle concern beyond simple staleness: the L2 sequencer can go offline, and when it recovers, initial price data may not reflect the true market price during the downtime period.

Chainlink maintains a dedicated L2 Sequencer Uptime Feed for ZKsync Era. The protocol does not query this feed. When the ZKsync sequencer is offline and returns, there is a "grace period" during which the Chainlink price data is unreliable — the L1 aggregator contracts may not have updated prices while the sequencer was down, meaning the first prices published after recovery are effectively stale even though their updated_at timestamp is recent. An attacker who monitors the sequencer can trade against this window in the first block after sequencer recovery.

# oracle_lib.vy: Complete staleness checkNO sequencer uptime check
@internal
def _stale_check_latest_round_data(price_feed: AggregatorV3Interface) -> ...:
(round_id, price, started_at, updated_at, answered_in_round) = staticcall price_feed.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"
@> # ❌ No check: Is the ZKsync L2 sequencer live?
@> # ❌ No check: Has the sequencer been live long enough after recovery (grace period)?
return round_id, price, started_at, updated_at, answered_in_round

Risk

Likelihood: Low

  • ZKsync Era sequencer downtime is uncommon but has occurred during the network's operational history.

  • The exploitation window requires monitoring the sequencer status, but the attack is deterministic and front-runnable.

Impact: High

  • Attacker mints DSC at a stale price not reflecting market conditions during sequencer outage.

  • The resulting DSC is backed by overvalued collateral — instantly undercollateralized once oracle updates.

  • Protocol accumulates irrecoverable bad debt; DSC peg is threatened.

Severity: Medium

Proof of Concept

An attacker monitors the ZKsync Era sequencer status. When the sequencer goes down for 2 hours (during which ETH drops 20%), the attacker positions themselves to be first to transact when the sequencer returns. The first Chainlink update after recovery uses the pre-downtime price (the aggregator on L1 didn't update during sequencer outage). The attacker mints DSC at the higher pre-downtime price, creating an instantly undercollateralized position.

import boa
eth_usd.updateAnswer(2000 * 10**8) # fresh timestamp, sequencer just recovered but price stale
boa.env.time_travel(seconds=0) # sequencer just came back online
with boa.env.prank(attacker):
weth.approve(dsce, to_wei(1, "ether"))
dsce.deposit_collateral(weth, to_wei(1, "ether"))
dsce.mint_dsc(to_wei(900, "ether")) # succeeds at stale $2000; real market is $1600
# after oracle update: health = (1600*0.5)/900 = 0.89 < 1 → liquidatable

Recommended Mitigation

Add a sequencer uptime check to oracle_lib.vy following Chainlink's recommended pattern for L2 deployments. The sequencer feed address for ZKsync Era should be set as an immutable at deployment.

+ SEQUENCER_FEED: immutable(address)
+ GRACE_PERIOD_TIME: constant(uint256) = 3600 # 1 hour
+ @internal
+ def _check_sequencer_uptime():
+ (_, answer, _, started_at, _) = staticcall AggregatorV3Interface(SEQUENCER_FEED).latestRoundData()
+ assert answer == 0, "DSCEngine_SequencerDown"
+ assert block.timestamp - started_at >= GRACE_PERIOD_TIME, "DSCEngine_SequencerGracePeriod"
@internal
def _stale_check_latest_round_data(price_feed: AggregatorV3Interface) -> ...:
+ self._check_sequencer_uptime()
(round_id, price, started_at, updated_at, answered_in_round) = staticcall price_feed.latestRoundData()
Updates

Lead Judging Commences

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