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

74569 bc medium base consensus client process exits on safe invalid payloads because is deposits only misclassifies every derived block and bypasses holocene fallback

Submitted on Apr 23rd 2026 at 14:14:07 UTC by @coffee_boi for Audit Comp | Base Azul

  • Report ID: #74569

  • Report Type: Blockchain/DLT

  • Report severity: Medium

  • Target: https://github.com/base/base/releases/tag/v0.8.0-rc.15

  • Impacts:

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

Description

Summary

AttributesWithParent::is_deposits_only (crates/consensus/protocol/src/attributes.rs:65-70) calls .iter() on an Option<Vec<Bytes>>, iterating the Option wrapper instead of the inner Vec. The closure then checks only transactions[0]; every tx after the first is ignored.

Every derived L2 block begins with the L1-info deposit (crates/consensus/derive/src/attributes/stateful.rs:184-186), so the predicate returns true for every normal payload including payloads carrying user transactions.

The predicate gates a match arm at crates/consensus/engine/src/task_queue/tasks/seal/task.rs:160-192 that precedes the Holocene deposits-only fallback. When the EL returns PayloadStatusEnum::Invalid, the fatal arm is always taken, the fallback never runs, and SealTaskError::DepositOnlyPayloadFailed propagates to std::process::exit(1). The node does not degrade and the process terminates.

In a homogeneous Base fleet, the same L1-derived input drives every node through the same deterministic branch. The result is consensus-layer process exit across the fleet until operators patch and redeploy. In mixed-client environments, spec-conformant peers continue via the deposits-only fallback while Base Rust halts, creating chain-split conditions.

Root cause

// crates/consensus/protocol/src/attributes.rs:65-70
pub fn is_deposits_only(&self) -> bool {
    self.attributes
        .transactions
        .iter()                                     // Option<Vec<Bytes>>::iter — yields &Vec<Bytes> once
        .all(|tx| tx.first().is_some_and(|tx| tx[0] == OpTxType::Deposit as u8))
        //        ^^^^^^^^^^^ tx is &Vec<Bytes>; tx.first() is the first Bytes only
}

Effective check: "transactions is None, or transactions[0][0] == Deposit." The only case where this disagrees with a correct implementation is [deposit, user_tx, …], the shape of every normal L2 block.

Fatal-reachability chain (process exit, not degradation)

#
File:line
Effect

1

derive/src/attributes/stateful.rs:184-186

Derivation always produces [L1-info deposit, …].

2

protocol/src/attributes.rs:65-70

is_deposits_only() returns true on mixed batches.

3

engine/src/task_queue/tasks/seal/task.rs:160-192

Match arm A fires before Holocene-fallback arm B — arm B is dead.

4

engine/src/task_queue/tasks/seal/error.rs:81-85, 97-101

DepositOnlyPayloadFailedCritical, is_fatal = true.

5

engine/src/task_queue/tasks/task.rs:265-268

Critical severity returns from the engine driver.

6

service/src/actors/engine/engine_request_processor.rs:164-173, 514-516

Processor task completes with error.

7

service/src/actors/engine/actor.rs:62-86

Error escapes the actor boundary.

8

service/src/service/util.rs:46-72

spawn_and_wait! cancels all sibling actors.

9

service/src/service/node.rs:285-290, bin/consensus/src/main.rs:14-16

RollupNode::start() returns error → std::process::exit(1).

There is no in-process recovery: the Critical branch returns rather than looping. A supervisor that respawns the binary re-hits the same L1-derived input and re-triggers the same exit. Every Base node in a homogeneous fleet evaluates the predicate identically and exits simultaneously.

Impact explanation

  • Critical - " Network not being able to confirm new transactions (total network shutdown)." One natural L1-derived input halts the entire Base fleet. Recovery requires patching the binary and coordinating a fleet-wide redeploy.

  • The Holocene spec mandates deposits-only replacement on INVALID; op-node Go conforms, Base Rust does not. Any spec-conformant peer advances while Base halts. Holocene is live on every production Base network and has been for 15+ months.

Likelihood explanation

The EL returns Invalid for derivation payloads in exactly the scenarios Holocene was introduced to handle:

  • L1 reorg shifts an L2 block's origin, making a previously-valid user tx invalid under re-derivation (nonce/balance/storage drift).

  • Post-Holocene Interop dependency invalidation.

  • Sequencer/verifier nondeterminism or hardfork-boundary edge cases.

Base's own source documents the deposits-only replacement as the designed remediation at service/src/actors/engine/engine_request_processor.rs:180-183:

// This error is encountered when the payload is marked INVALID by the engine api. Post-holocene, the payload is replaced by a "deposits-only" block and re-executed.

The bug disables the remediation the authors themselves documented as load-bearing.

Recommendation

Plus:

  1. Audit other Option<Vec<_>>::iter() sites; clippy misses this idiom.

  2. Reconsider DepositOnlyPayloadFailed at Critical + is_fatal. Gating std::process::exit(1) on a single predicate is fragile even with the predicate correct.

Additional Notes

The fatal path is not gated on privileged or authenticated input. Any sequencer-built or batcher-supplied block whose user txs re-execute invalidly under derivation reaches it. Upstream input validation does not limit blast radius - the bug is in the response logic.

Proof of Concept

A. Predicate (4 tests, crates/consensus/protocol/src/attributes.rs)

Three additional tests cover: multiple deposits followed by one user tx, all-deposits agreement with correct impl, and first-tx-is-user agreement with correct impl.

B. Fatal-arm routing (crates/consensus/engine/src/task_queue/tasks/seal/task_test.rs)

Mirrors the match at seal/task.rs:160-192 using the real is_deposits_only, real is_holocene_active, and real SealTaskError::severity(). Arm B (Holocene fallback) is wired to panic! — any fix immediately fails the test:

Run:

Both suites pass on current main. Tier A + B prove steps 1–4 of the fatal chain. Steps 5–9 are direct source citations.

C. Live devnet verifier exit (end-to-end)

The deterministic live PoC is to force the EL to return PayloadStatusEnum::Invalid only when the verifier is importing a safe mixed payload. This does not create the bug; it only makes the natural Holocene trigger deterministic. The bug is the control flow after Invalid: Base takes the fatal is_deposits_only() branch instead of the deposits-only fallback.

Minimal PoC patch

Add a temporary env-gated injector in crates/consensus/engine/src/task_queue/tasks/insert/task.rs:

Pass the env only to base-client-cl, and disable restart so Docker preserves the non-zero exit:

Reproduction steps

1

Start the single-node devnet

2

Create a PoC env file

3

Rebuild only the verifier consensus container with the PoC env

Note: the injector at crates/consensus/engine/src/task_queue/tasks/insert/task.rs gates on std::env::var_os(INVALID_MIXED_PAYLOAD_POC_ENV) at runtime, not build time. The compose snippet above passes the variable into the container via environment: - BASE_POC_INJECT_INVALID_MIXED_PAYLOAD (value interpolated from --env-file). If a reviewer reproduces these steps from a shell that does not load /tmp/devnet-env-poc, or drops the --env-file flag, the variable will not reach the container runtime and the injector will no-op — the verifier will keep running and the PoC will appear to fail. The --env-file flag must be present on every docker compose invocation that (re)creates base-client-cl.

4

Disconnect the verifier from its only unsafe CL peer

Sanity check:

Expected result: connected = 0, banned = 1.

5

Send user transactions to the builder

6

Watch the verifier logs

7

Confirm the verifier exits non-zero

Observed result on 2026-04-23

Safe derivation eventually produced a mixed payload:

That txs=1 user-batch became a 2-transaction execution payload once the mandatory L1-info deposit was prepended (tx_count=2 in the next log). The PoC injector then forced Invalid only for that safe mixed payload:

Base immediately took the fatal branch instead of the Holocene fallback:

The verifier process then exited non-zero and stayed down because restart was disabled:

Why this is sufficient

  • The live run reaches the exact buggy control-flow decision under a real devnet verifier.

  • The only artificial input is the deterministic Invalid response, which merely substitutes for the natural EL rejection Holocene was explicitly designed to handle.

  • The bug itself is unchanged: a safe mixed payload still routes to DepositOnlyPayloadFailed instead of the deposits-only fallback.

  • The terminal effect is demonstrated, not inferred: the verifier container exits with code 1.

Was this helpful?