For the complete documentation index, see llms.txt. This page is also available as Markdown.

75488 bc low off by one in batcher default frame size causes permanent livelock in submit pending halting l2 finalization and freezing all pending withdrawals

Submitted on Apr 29th 2026 at 12:41:02 UTC by @GiuseppeDeLaZara for Audit Comp | Base Azul

  • Report ID: #75488

  • Report Type: Blockchain/DLT

  • Report severity: Low

  • Target: https://github.com/base/base/tree/v0.8.0-rc.28

  • Impacts:

    • Network not being able to confirm new transactions (total network shutdown)

Description

Brief/Intro

The Base batcher's default max_frame_size (130,044) doesn't account for the 1-byte DERIVATION_VERSION_0 prefix added by BlobEncoder::encode_packed(). This causes every full frame to overflow BLOB_MAX_DATA_SIZE by 1 byte. The failed frame is requeued and retried in a tight synchronous loop with zero async yield points — submit_pending() never returns, permanently blocking the batcher driver.

Vulnerability Details

Three components interact to create this livelock:

1. Frame sizing (channel_out.rs:140-168)

output_frame(max_size=130044) produces frames with data.len() = 130021:

data budget = max_size - FRAME_V0_OVERHEAD = 130044 - 23 = 130021

2. Blob encoding (blobs/encoder.rs:52-68)

encode_packed() prepends 1 byte that output_frame doesn't account for:

3. Infinite retry in submit_pending() (core/submissions.rs:52-169)

On DataTooLarge, the code requeues the frame and continues:

requeue() rewinds the channel cursor (encoder.rs:594-618), so next_submission() returns the same immutable Arc<Frame>. The semaphore permit was just dropped so it's immediately re-acquirable. The entire error path is synchronous — the tokio task never yields.

Why this is the default behavior

  • config.rs:97-98: max_frame_size = 130044 (the default)

  • cli.rs:227: max_frame_size = target_frame_size (no separate flag)

  • Any channel with compressed output ≥ 130,021 bytes triggers it

  • At Base mainnet throughput, channels fill well past this on every cycle

The developers know the correct math

Their own test helper blob_filling_submission() at driver.rs:441:

But the production config doesn't apply this subtraction.

Impact Details

The batcher is the sole DA submitter for the L2. When submit_pending() livelocks:

  • No batch data reaches L1 → derivation pipeline stalls

  • TEE/ZK provers have no data to prove → no proposals finalized

  • All pending L2→L1 withdrawals are frozen (require finalized state)

  • Restarting with defaults reproduces immediately — not a transient failure

This is "Network not being able to confirm new transactions (total network shutdown)" per the scope definition.

Funds at risk: All assets pending withdrawal through the L2 bridge are frozen for the duration of the outage. On Base mainnet this includes all ETH and ERC-20 tokens in the bridge contracts and any in-flight withdrawal messages.

References

  • crates/batcher/encoder/src/config.rs:97-98 — Default max_frame_size = 130044

  • bin/batcher/src/cli.rs:96-97,227 — CLI default, hardcodes max = target

  • crates/batcher/comp/src/channel_out.rs:140-168 — Frame data budget

  • crates/batcher/blobs/src/encoder.rs:52-68 — Blob encoding + size check

  • crates/batcher/core/src/submissions.rs:52-169submit_pending() loop

  • crates/batcher/encoder/src/encoder.rs:594-618 — Requeue cursor rewind

  • crates/batcher/core/src/driver.rs:157 — Driver blocked at submit_pending().await

  • crates/batcher/core/src/driver.rs:441 — Test helper with correct math

Proof of Concept

This PoC calls the real production SubmissionQueue::submit_pending() from base-batcher-core and proves it enters a permanent infinite loop. It is not a unit test — it's a standalone binary that exercises the actual production code path.

The PoC uses a LivelockPipeline that models the real FrameEncoder's requeue behavior: calling requeue() makes the frame available again via next_submission(), just like the cursor rewind in production.

What it demonstrates:

  1. submit_pending() spawned with a production-sized frame (130021 bytes) never returns

  2. After 2 seconds, the iteration counter shows hundreds of thousands of synchronous retries

  3. The TxManager is never calledencode_packed fails every time before reaching send_async

  4. The same test with the fixed size (130020 bytes) completes immediately

How to run

No network access, no environment variables, no external services.

Dependencies

  • base/base repository at tag v0.8.0-rc.28

  • Rust toolchain (same as the workspace requires)

  • No external services or mainnet forking needed (this is an offchain Rust component, not a smart contract)

crates/batcher/poc-f1001/Cargo.toml

crates/batcher/poc-f1001/src/main.rs

Expected output

Expected output

Was this helpful?