ethereum-optimism / ethereum-optimism/optimism
kona/derive: Holocene frame-queue prune over-drains on channel ID reuse, dropping channels op-node keeps
- 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
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