Skip to content
← Consensus Engineering — Building L1 Consensus on Reth
Reading real consensus code
Lesson 7 of 12·CONTENT15 min40 XP
Course
Consensus Engineering — Building L1 Consensus on Reth
Lesson role
CONTENT
Sequence
7 / 12

Lesson 7 — Reading bera-reth — Proof-of-Liquidity as consensus customization

Question

Berachain runs Proof-of-Liquidity (PoL) — an incentive mechanism layered on a CometBFT-based PoS chain — and its execution client, bera-reth, is built on the Reth SDK. What does a production chain actually put in Reth's Consensus trait, and what stays out of it? Reading bera-reth answers that with real code.

Principle (minimum model)

  • Two clients, two jobs. Berachain nodes pair BeaconKit (consensus client: CometBFT-based, picks proposers, votes, finalises) with bera-reth (execution client), talking over the Engine API. Validator selection and BFT voting live in BeaconKit, not in bera-reth.
  • What PoL is. Validators stake BERA; the active set is capped (69) and proposal probability is proportional to stake. Each block emits WBERA: part to the proposer's operator, the rest to Reward Vaults chosen by that validator's reward allocation. (Until July 8, 2026 the emission token was BGT; Berachain has since moved to a single-token WBERA model.)
  • Where PoL touches the EL. From the Prague1 fork, every block's first transaction must be a PoL transaction: a system call to the PoL Distributor's distributeFor(pubkey) crediting the previous block's proposer. The header carries that proposer's BLS pubkey (prev_proposer_pubkey).
  • bera-reth's Consensus impl. BerachainBeaconConsensus wraps Reth's EthBeaconConsensus, delegates every standard check to it, and adds fork-gated rules: PoL tx present, first, unique and byte-for-byte what the node would build itself; prev_proposer_pubkey present after Prague1 and absent before it; plus post-execution transfer checks while Prague3 is active.
  • SDK, not fork. bera-reth is one crate depending on upstream paradigmxyz/reth crates (tag v1.11.4 at the commit read here) and plugs its components in through NodeBuilder.
  • Pattern for custom L1s. Wrap the stock consensus, add only the chain-specific rules, gate each one on a hardfork, and keep the matching block-building logic in the executor.

Worked example + steps

Reading bera-reth — Proof-of-Liquidity as consensus customization

All code below is from berachain/bera-reth at commit 1eb38091b837fdecb349f74b3b449a677e711744 (2026-09-22). The repo describes itself as "a high-performance Rust execution client for Berachain, built with the Reth SDK" and marks its status as production.

1. What Proof-of-Liquidity changes

PoL is not a different way of reaching agreement. According to the BeaconKit README and the PoL overview:

  • Who validates: validators stake BERA and join a capped active set (69 at the time of writing) by stake; proposal probability is proportional to BERA stake.
  • How agreement happens: BeaconKit runs a modified CometBFT — timeout-based rounds and single-slot finality once 2/3+ of validators commit.
  • What PoL adds: an emissions mechanism. Each block produces a fixed WBERA emission. The BlockRewardController pays part to the validator's operator; the rest (after any Dedicated Emission Stream carve-out) goes to the Reward Vaults in that validator's reward allocation, managed by BeraChef; applications attach liquidity and activity to those vaults to compete for emissions.
  • Token history: before July 8, 2026 the emission token was BGT, in a dual-token BGT + BERA model. The docs now describe a single-token WBERA model with BGT deprecated.

So PoL changes where block rewards go, not who decides the next block. That decision is the CL's.

2. What stays the same as Ethereum PoS

FeatureEthereum PoSBerachainSame?
Client splitCL + EL over Engine APIBeaconKit (CL) + bera-reth (EL) over Engine APISame
Stake tokenETHBERASame structure
Validator setOpen-endedCapped (69), ranked by BERA stakeDifferent
Agreement / finalityLMD-GHOST + Casper FFG (~13 min)CometBFT rounds, single-slot finalityDifferent engine
Block timingFixed 12s slotsTimeout-based rounds, no missed heightsDifferent
Block reward routingIssuance to validatorsPer-block WBERA emission → operator + Reward Vaults (BGT until July 2026)Different

The client split is unchanged, which is exactly why Berachain can reuse Reth: the EL still receives payloads over the Engine API and validates them. What the EL must learn is the PoL distribution step each block, plus a header field to make it checkable.

3. The Reth-side changes

bera-reth is a single crate. The modules under src/ map onto Reth SDK extension points:

src/
├── consensus/mod.rs    ← BerachainConsensusBuilder + BerachainBeaconConsensus
├── chainspec/          ← BerachainChainSpec (Berachain fork config, PoL distributor address)
├── hardforks/mod.rs    ← Prague1..Prague4, Osaka1
├── primitives/         ← BerachainHeader (adds prev_proposer_pubkey), BerachainBlock
├── transaction/        ← PoLTx type, create/validate_pol_transaction (pol.rs)
├── node/evm/           ← block executor: runs the PoL tx as a system call
├── engine/             ← Engine API types/validation (parent proposer pubkey)
├── pool/, rpc/         ← tx pool and RPC types for the extra tx/header shapes
└── node/mod.rs         ← NodeBuilder wiring

3.1 src/consensus/mod.rs

BerachainBeaconConsensus holds an inner: EthBeaconConsensus<BerachainChainSpec> and calls it first in every method; the Berachain-specific checks run only after the standard Ethereum ones pass. The added rules:

// src/consensus/mod.rs (bera-reth @ 1eb38091), lines 231–252
fn validate_block_pre_execution(
    &self,
    block: &SealedBlock<BerachainBlock>,
) -> Result<(), ConsensusError> {
    <EthBeaconConsensus<BerachainChainSpec> as Consensus<BerachainBlock>>::validate_block_pre_execution(
        &self.inner,
        block,
    )?;

    if self.chain_spec.is_prague1_active_at_timestamp(block.header().timestamp) {
        self.validate_pol_transaction(block)?;
    } else if let Some(index) = block
        .body()
        .transactions()
        .position(|tx| matches!(tx, BerachainTxEnvelope::Berachain(_)))
    {
        return Err(ConsensusError::Other(format!(
            "PoL transaction found at position {index} before Prague1 fork activation"
        )));
    }
    Ok(())
}
  • validate_block_pre_execution — after Prague1, validate_pol_transaction requires: the block is non-empty, transaction 0 is a PoL tx, no other PoL tx appears later, and the PoL tx's hash equals the one the node rebuilds itself from the header's prev_proposer_pubkey, block number and base fee (validate_pol_transaction in src/transaction/pol.rs). Before Prague1, any PoL tx is rejected.
  • validate_header — calls validate_proposer_pubkey_prague1 (src/engine/mod.rs): prev_proposer_pubkey must be present once Prague1 is active and absent before. A source comment explains why it is enforced here and not only at the Engine API: otherwise peer-imported headers could bypass the invariant.
  • validate_block_post_execution — runs the Ethereum receipt/state checks, then, while Prague3 is active (from Prague3 until Prague4), rejects blocks whose receipts contain Transfer event logs from or to configured blocked addresses (except transfers to a rescue address), or Transfer / internal-balance-change events touching the configured BEX vault.
  • validate_body_against_header, validate_header_against_parent — pure delegation to EthBeaconConsensus.

Note what is absent: no proposer-eligibility check, no signature aggregation, no validator-set lookup. Those belong to BeaconKit/CometBFT.

3.2 src/node/evm/ — the matching executor hook

A consensus rule needs a matching block-building/execution rule. In src/node/evm/executor.rs, apply_pre_execution_changes runs the standard EIP-2935 block-hash and EIP-4788 beacon-root system calls, then execute_pol_transaction_with_receipt, which builds the same PoL tx via create_pol_transaction and executes it with transact_system_call from SYSTEM_ADDRESS to the PoL distributor.

The contract side confirms the design: IDistributor.distributeFor(bytes pubkey) in berachain/contracts is documented as "called for block N at the top of block N+1" and "only the execution layer client can call this function." That is why the header carries the previous proposer's pubkey.

🔍 Find in repo. Open src/node/evm/executor.rs and find apply_pre_execution_changes. Which system calls run before the first user transaction? Then find where the proposer pubkey enters the node: look for parent_proposer_pub_key in src/engine/.

4. The chain spec — what makes a chain "different"

BerachainChainSpec (src/chainspec/mod.rs) wraps Reth's chain spec and reads Berachain-specific fork config from the genesis file's extra fields:

  • Berachain hardforks (src/hardforks/mod.rs): Prague1, Prague2, Prague3, Prague4, Osaka1, each timestamp-activated, exposed as is_prague1_active_at_timestamp etc. Every custom consensus rule above is gated on one of these.
  • PoL distributor address: taken from the Prague1 config (pol_distributor_address) and returned by pol_contract().
  • Minimum base fees per fork (Prague1, Prague2, Osaka1 configs) and the Prague3 blocked-address / rescue / BEX-vault settings.
  • Genesis header: if Prague1 is active at genesis, prev_proposer_pubkey is set to zero.

Structurally it is Ethereum's chainspec plus an extra fork table. Your custom L1 will have its own version.

5. Reading the consensus integration

Read in this order (all paths in the single bera-reth crate):

  1. src/consensus/mod.rs — the ConsensusBuilder and the Consensus / HeaderValidator / FullConsensus impls
  2. src/transaction/pol.rs — create_pol_transaction and validate_pol_transaction
  3. src/node/mod.rs — ComponentsBuilder::default()...consensus(BerachainConsensusBuilder) (line 85)
  4. src/hardforks/mod.rs and src/chainspec/mod.rs — the fork gates the rules depend on
  5. src/node/evm/executor.rs — where the PoL tx is executed

The Consensus file is ~490 lines, about 285 of them before the test module. The whole crate's src/ is ~13.9k lines of Rust including tests — on top of upstream Reth crates it depends on, not a modified copy of Reth.

6. The lesson for your L1

Studying bera-reth as a reference:

  • Your custom chain crate ~= bera-reth's: one crate, Reth crates as dependencies
  • Your Consensus impl ~= BerachainBeaconConsensus: wrap EthBeaconConsensus, delegate first, then add your fork-gated rules
  • Your executor hooks ~= bera-reth's PoL system call, enforcing the same rule your Consensus checks
  • Your NodeBuilder wiring ~= BerachainNode's components_builder, calling your components
  • Your validator selection lives in your consensus client, not in the Reth Consensus trait

Tempo's node (tempoxyz/tempo) is public, so you can compare a second production example of the same compose-don't-fork shape with different business logic.

7. Practice

  1. Browse bera-reth on GitHub at the commit above
  2. Compare to the directory structure of op-reth from the expert course's op-stack-on-reth lesson. It now lives at rust/op-reth/ in ethereum-optimism/optimism; it was reth's crates/optimism/ until reth commit 372802d06d (PR #21532, 2026-02-06) moved it out
  3. Identify the files most specific to PoL (hint: consensus, transaction/pol, executor)
  4. For each custom rule in BerachainBeaconConsensus, name the hardfork that gates it and the code path that builds blocks satisfying it

Final check: in one sentence, what does bera-reth put in Reth's Consensus trait? Fork-gated validity rules for Berachain-specific block contents (PoL tx, proposer pubkey, Prague3 transfer checks) on top of the stock Ethereum checks — never validator selection.

Summary (3 lines)

  • Berachain = BeaconKit (CometBFT-based CL: BERA-staked, capped validator set, proposer choice, finality) + bera-reth (Reth SDK EL). PoL is the per-block emissions mechanism, not the agreement protocol.
  • BerachainBeaconConsensus wraps EthBeaconConsensus and adds fork-gated checks: PoL tx first and exact (from Prague1), prev_proposer_pubkey required from Prague1, Prague3 transfer restrictions; the executor runs the matching PoL system call.
  • Template for custom L1s: wrap stock consensus + fork-gated rules + matching executor hook + NodeBuilder wiring. Next: quiz on reading consensus internals.