ethereum-optimism / ethereum-optimism/optimism

kona/derive: Holocene frame-queue prune over-drains on channel ID reuse, dropping channels op-node keeps

Open
#22,833 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
6.5k
Forks
4k
Avg merge
2d 15h
Merged PRs (30d)
145

Description

## Summary

kona's Holocene frame-queue prune drops more frames than op-node's when a channel ID is **reused** inside the queue. Where op-node discards a single frame, kona drains from the *first* queue entry carrying that channel ID — so if an earlier channel with the same ID has already closed, kona destroys that complete channel and everything between it and the current position.

Given identical L1 data, op-node derives batches kona never sees. Reachable only by a malicious batcher (frame IDs are batcher-chosen), so the impact is a derivation divergence — in the direction where a kona-based verifier or fault-proof program derives a *shorter* safe chain than op-node.

## The two implementations

**op-node** — [`pruneFrameQueue`, `op-node/rollup/derive/frame_queue.go#L104-L141`](https://github.com/ethereum-optimism/optimism/blob/85f7c13d7470a66254081f0177fc297201c07459/op-node/rollup/derive/frame_queue.go#L104-L141):

```go
// first frames discard previously unclosed channels
if next.FrameNumber == 0 && !current.IsLast {
discard(0) // discard current
// make sure we backwards invalidate more frames of unclosed channel
if i > 0 {
i--
}
continue
}
```

It removes **one** frame and steps back one index. The backward walk then re-applies the pairwise rules, so it only keeps removing while the preceding frames actually belong to the still-open channel — a closed `is_last` frame stops it.

**kona** — [`FrameQueue::prune`, `rust/kona/crates/protocol/derive/src/stages/frame_queue.rs#L90-L101`](https://github.com/ethereum-optimism/optimism/blob/85f7c13d7470a66254081f0177fc297201c07459/rust/kona/crates/protocol/derive/src/stages/frame_queue.rs#L90-L101):

```rust
// If the frames are in different channels, and the current channel is not last, walk
// back the channel and drop all prev frames.
if !extends_channel && !prev_frame.is_last && next_frame.number == 0 {
// Find the index of the first frame in the queue with the same channel ID
// as the previous frame.
let first_frame =
self.queue.iter().position(|f| f.id == prev_frame.id).expect("infallible");

// Drain all frames from the previous channel.
let drained = self.queue.drain(first_frame..=i);
i = i.saturating_sub(drained.len());
continue;
}
```

`position(..)` scans from the **front of the queue** and matches the *first* frame with that ID. It is not "the first frame of the channel `prev_frame` belongs to" — with a reused ID it lands on the earlier, unrelated channel, and `drain(first_frame..=i)` then takes out every frame in between.

## Separating input

One L1 batcher transaction, Holocene active, three distinct channel IDs `P`, `Q`, `R`:

```
P#0(is_last), Q#0(is_last), P#0(not last), R#0(not last)
```

`P` is reused *after* its own single-frame channel already closed. Run against the real production code on both sides:

```
before: [P#0(last), Q#0(last), P#0, R#0]

op-node after prune: [P#0(last), Q#0(last), R#0] ← channels P and Q intact
kona after prune: [R#0] ← both destroyed
```

op-node keeps two complete, well-formed channels; kona drops them along with the offending frame. Every batch in `P` and `Q` enters op-node's derivation and never enters kona's.

The intervening frame matters: the bare `P#0(last), P#0, R#0` case is handled identically by both. The bug needs a *different* channel's frame sitting between the reused ID's two occurrences, so the drain range spans it.

## Impact

Derivation divergence only. Deposits are unaffected — they are derived from L1 receipts in the attributes builder ([`DeriveDeposits`](https://github.com/ethereum-optimism/optimism/blob/85f7c13d7470a66254081f0177fc297201c07459/op-node/rollup/derive/attributes.go#L93) / [`derive_deposits`](https://github.com/ethereum-optimism/optimism/blob/85f7c13d7470a66254081f0177fc297201c07459/rust/kona/crates/protocol/derive/src/attributes/stateful.rs#L131)), never from channel data, and both clients drop any batch containing a `0x7E` transaction. Frames carry only batch data, so forced inclusion via the sequencing window is untouched: the dropped channels become deposits-only blocks in kona's view.

Not honest-batcher reachable — an honest batcher does not reuse a channel ID it has already closed.

Holocene and later only; there is no frame-queue prune before Holocene.

## Suggested fix

Mirror op-node: remove the single frame at `i` and step back one index, letting the pairwise rules do the backward walk.

```rust
self.queue.remove(i);
i = i.saturating_sub(1);
continue;
```

This needs a cross-client parity test pinning the frame sequence above on both sides, in the style of the parity tests added for the span-batch channel-retention fix.

## Related

- #21793 — the same class of Go↔Rust derivation divergence, in the brotli decoder.
- Companion issue on zlib decompression conformance (filed alongside this one).

---
🤖 *Co-created with Claude Opus 5 (1M context)*

Contributor guide

Open the contributing guide

Research direction

Start with op-node/rollup/derive/frame_queue.go and rust/kona/crates/protocol/derive/src/stages/frame_queue.rs, comparing prune behavior for reused channel IDs. Add a cross-client parity test for the P, Q, P, R frame sequence and verify that kona retains the same channels as op-node after pruning.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, rust
Domain
blockchain, distributed-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.