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

75623 bc medium post holocene span batch prefix validation can allow partially derived batches from a span rejected by full validation

Submitted on Apr 30th 2026 at 05:51:36 UTC by @Petrate for Audit Comp | Base Azul

  • Report ID: #75623

  • Report Type: Blockchain/DLT

  • Report severity: Medium

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

  • Impacts:

    • Unintended chain split (network partition)

Description

Base's post-Holocene span batch derivation flow relies on check_batch_prefix for span acceptance and get_singular_batches for conversion to single batches, without an intervening full check_batch validation. Two bugs interact in this flow:

1

get_singular_batches uses a relative slice index as if it were absolute

In SpanBatch::get_singular_batches (span.rs:301):

let mut origin_index = 0;
for batch in &self.batches {
    let origin_epoch_hash = l1_origins[origin_index..l1_origins.len()]
        .iter()
        .enumerate()
        .find_map(|(i, b)| {
            if b.number == batch.epoch_num {
                origin_index = i;  // <-- BUG: i is slice-relative, not absolute
                Some(b.hash)
            } else {
                None
            }
        })
        ...
}

The slice l1_origins[origin_index..l1_origins.len()] produces relative indices via enumerate(). Storing i (relative) as origin_index (absolute) desynchronizes the cursor.

The same file's full check_batch (span.rs:427) uses the correct pattern:

let Some((offset, l1_origin)) =
    l1_blocks[origin_index..].iter().enumerate().find(|(_, b)| batch_epoch == b.number)
else {
    return BatchValidity::Drop(BatchDropReason::MissingL1Origin);
};
origin_index += offset;  // <-- correct: relative offset advances absolute index

The two functions in the same file use inconsistent index logic. The conversion path can move the cursor backward when the relative index is smaller than the absolute index it overwrites — producing a (epoch_num, epoch_hash) pair where the hash is for a block earlier than expected.

2

check_batch_prefix does not enforce interior monotonicity

In SpanBatch::check_batch_prefix (span.rs:591–750), the L1 origin check loops through l1_origins and validates only the END of the span:

let end_epoch_num = self.batches.last().unwrap().epoch_num;
let mut origin_checked = false;
for l1_block in l1_origins {
    if l1_block.number == end_epoch_num {
        if !self.check_origin_hash(l1_block.hash) {
            return (BatchValidity::Drop(BatchDropReason::EpochHashMismatch), None);
        }
        origin_checked = true;
        break;
    }
}

This validates that the FINAL span element's L1 origin hash matches self.l1_origin_check. It does NOT walk middle elements to verify monotonicity. A non-monotonic interior sequence (e.g., 11 → 13 → 12) where the final element correctly matches l1_origin_check will pass.

The full check_batch walks every element and rejects on MissingL1Origin when the cursor can't find the next epoch in the forward-only slice — catching non-monotonicity by construction.

Root cause 3 — BatchStream calls prefix only before conversion

In BatchStream::next_batch (batch_stream.rs:144–183):

match batch_with_inclusion.batch {
    Batch::Single(b) => return Ok(Batch::Single(b)),
    Batch::Span(b) => {
        let (validity, _) = b.check_batch_prefix(...).await;
        match validity {
            BatchValidity::Accept => self.span = Some(b),  // <-- stored without full validation
            ...
        }
    }
}

In BatchStream::try_hydrate_buffer (batch_stream.rs:88–102):

The flow is: prefix accept → store span → convert via get_singular_batches → emit singles. No full check_batch invocation between accept and conversion.

Attack path

1

Malicious batcher submits a malformed span

A malicious batcher submits a Holocene-era span batch with non-monotonic L1 origins, e.g., elements with epoch_num = [11, 13, 12]. Sets parent_check and l1_origin_check (over the LAST element's L1 hash) correctly.

2

Prefix validation accepts the span

BatchStream::next_batch calls check_batch_prefix. The function:

  • Checks parent_check matches L2 safe head ✓

  • Checks l1_origin_check matches the last element's (epoch=12) L1 hash ✓

  • Does not walk interior elements

  • Returns Accept

3

The span is stored and converted

BatchStream stores the span. Later try_hydrate_buffer calls get_singular_batches.

4

get_singular_batches emits malformed single batches

get_singular_batches walks elements:

  • Element 0 (epoch 11): finds at slice [0..] index 1. Sets origin_index = 1. Emits SingleBatch(epoch=11, hash=l1_origins[1].hash). ✓

  • Element 1 (epoch 13): finds at slice [1..] index 2. Sets origin_index = 2 (should be 3 absolute). Emits SingleBatch(epoch=13, hash=l1_origins[3].hash). The cursor is now stale.

  • Element 2 (epoch 12): finds at slice [2..] index 0. Sets origin_index = 0. Emits SingleBatch(epoch=12, hash=l1_origins[2].hash). Cursor moved backward.

5

Downstream validation accepts a prefix of the emitted singles

Three single batches are pushed downstream. BatchValidator::next_batch validates them independently. The first emits successfully (epoch 11 advances from L2 safe head's L1 origin 10). Subsequent emissions are validated against SingleBatch::check_batch's own rules (which don't reproduce the span-level monotonicity check).

6

Malformed suffix is rejected only after partial state has advanced

With the longer malformed pattern [11, 12, 14, 13] (PoC test 4), the first two emissions (11, 12) pass downstream validation before the suffix (14, 13) is rejected. State has already advanced two blocks based on the malformed span.

7

Honest nodes diverge

An honest implementation following the spec rejects the whole span at full check_batch, produces deposit-only blocks for the L1 origin epoch (Holocene fast-channel-invalidation), and maintains a different L2 state than Base's flow.

Affected files

  • crates/consensus/protocol/src/batch/span.rs lines 301–335 (get_singular_batches, the relative-index bug)

  • crates/consensus/protocol/src/batch/span.rs lines 416–428 (full check_batch, the correct reference for += offset)

  • crates/consensus/protocol/src/batch/span.rs lines 591–750 (check_batch_prefix, the incomplete prefix validation)

  • crates/consensus/derive/src/stages/batch/batch_stream.rs lines 88–102 (try_hydrate_buffer calls get_singular_batches without intervening full validation)

  • crates/consensus/derive/src/stages/batch/batch_stream.rs lines 144–183 (next_batch accepts spans on prefix only)

  • crates/consensus/derive/src/stages/batch/batch_validator.rs lines 240–263 (downstream single-batch validation)

  • https://github.com/base/base/blob/main/crates/consensus/protocol/src/batch/span.rs#L301–L335

  • https://github.com/base/base/blob/main/crates/consensus/protocol/src/batch/span.rs#L416–L428

  • https://github.com/base/base/blob/main/crates/consensus/protocol/src/batch/span.rs#L591–L750

  • https://github.com/base/base/blob/main/crates/consensus/derive/src/stages/batch/batch_stream.rs#L88–L102

  • https://github.com/base/base/blob/main/crates/consensus/derive/src/stages/batch/batch_stream.rs#L144–L183

  • https://github.com/base/base/blob/main/crates/consensus/derive/src/stages/batch/batch_validator.rs#L240–L263

Root cause 1 — get_singular_batches uses relative index as absolute

In SpanBatch::get_singular_batches (span.rs:301):

The slice l1_origins[origin_index..l1_origins.len()] produces relative indices via enumerate(). Storing i (relative) as origin_index (absolute) desynchronizes the cursor.

The same file's full check_batch (span.rs:427) uses the correct pattern:

The two functions in the same file use inconsistent index logic. The conversion path can move the cursor backward when the relative index is smaller than the absolute index it overwrites — producing a (epoch_num, epoch_hash) pair where the hash is for a block earlier than expected.

Root cause 2 — check_batch_prefix does not enforce interior monotonicity

In SpanBatch::check_batch_prefix (span.rs:591–750), the L1 origin check loops through l1_origins and validates only the END of the span:

This validates that the FINAL span element's L1 origin hash matches self.l1_origin_check. It does NOT walk middle elements to verify monotonicity. A non-monotonic interior sequence (e.g., 11 → 13 → 12) where the final element correctly matches l1_origin_check will pass.

The full check_batch walks every element and rejects on MissingL1Origin when the cursor can't find the next epoch in the forward-only slice — catching non-monotonicity by construction.

Root cause 3 — BatchStream calls prefix only before conversion

In BatchStream::next_batch (batch_stream.rs:144–183):

In BatchStream::try_hydrate_buffer (batch_stream.rs:88–102):

The flow is: prefix accept → store span → convert via get_singular_batches → emit singles. No full check_batch invocation between accept and conversion.

Two changes are needed; either alone is insufficient.

Fix 1: Correct the index in get_singular_batches.

This makes get_singular_batches consistent with full check_batch (which uses origin_index += offset). Non-monotonic L1 origins will then cause a find failure (no element matches in the forward-cursor view), and the conversion will fail rather than silently emit malformed singles.

Fix 2: Run full check_batch before conversion.

In BatchStream::next_batch (or before try_hydrate_buffer calls get_singular_batches), invoke check_batch after check_batch_prefix accepts. This catches any structural span issue that the prefix doesn't enforce.

Alternatively, extend check_batch_prefix to walk interior elements for monotonicity. But the most robust fix is to call full validation before treating the span as ready for conversion — this matches the OP-stack reference implementation's behavior and ensures any future divergence between prefix and full validation does not produce derivation bugs.

A regression test should cover:

  • Span with non-monotonic L1 origins (e.g., [11, 13, 12]) — must be rejected before any single batch is emitted.

  • Span with skip-then-backtrack (e.g., [11, 12, 14, 13]) — must be rejected before any single batch is emitted.

  • Span with strictly monotonic but non-contiguous origins (e.g., [11, 12, 14] — skipping 13) — confirm correct handling.

https://gist.github.com/devpetrate/430240456b983059465d8abae30f9d66

Proof of Concept

The PoC is a standalone integration test and should be placed at:

Run it with:

PoC:

The PoC contains four tests.

The first test shows that SpanBatch::get_singular_batches() accepts a non monotonic L1 origin sequence because origin_index is assigned from a relative slice index.

The second test shows that check_batch_prefix() accepts the malformed span, while full check_batch() rejects the same span, and get_singular_batches() still emits single batches.

The third test shows that the malformed span can emit a first single batch that passes downstream single batch validation before the malformed suffix is rejected.

The fourth test shows that a longer malformed span can emit multiple downstream valid prefix batches before the malformed suffix is rejected.

Expected output:

Was this helpful?