Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 47 additions & 4 deletions src/consensus/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,34 @@ impl FullConsensus<BerachainPrimitives> for BerachainBeaconConsensus {
// First run the standard validation
<EthBeaconConsensus<BerachainChainSpec> as FullConsensus<BerachainPrimitives>>::validate_block_post_execution(&self.inner, block, result, receipt_root_bloom)?;

// Check for Prague3 blocked address transfers if the hardfork is active
// ============================================================================
// Prague3 historical-window validation
// ----------------------------------------------------------------------------
// The block below enforces the Prague3 emergency rules that were live on
// Berachain mainnet for the timestamp window [1762164459, 1762963200) — from
// 2025-11-03 to 2025-11-12, when Prague4 ended the restrictions.
//
// This code is NOT dead. It is gated on `is_prague3_active_at_timestamp`,
// which returns true only inside that historical window. Outside the window
// (i.e. on the live tip post-Prague4, and pre-Prague3) the gate short-circuits
// and none of the loops run. The path remains a hard requirement for any node
// re-executing the chain from genesis.
Comment on lines +128 to +131
//
// Note on the three log checks in this module and `deposits.rs`:
// * Deposit parser (deposits.rs) — filters by `log.address` because authority over
// `Deposit(...)` is contract-scoped to the deposit contract.
// * InternalBalanceChanged (below) — filters by `log.address` because the event's
// semantic meaning is BEX-vault-internal accounting; another contract emitting the same
// topic is a hash-shape coincidence, not the same event.
// * Transfer (below) — intentionally does NOT filter by `log.address`.
// The rule blocks any ERC20 transfer involving a blocked address; ERC20 Transfer events
// are authored by each token contract, so scoping to a single address would defeat the
// rule.
//
// For bug-bounty triage: findings against this block targeting the live tip
// are out of scope — Prague4 made the gate inactive on all production
// timestamps.
// ============================================================================
let timestamp = block.header().timestamp();
if let Some(blocked_addresses) =
self.chain_spec.prague3_blocked_addresses_at_timestamp(timestamp)
Expand Down Expand Up @@ -412,9 +439,8 @@ mod tests {
}

/// Build a header that satisfies upstream `EthBeaconConsensus::validate_header`
/// for the given timestamp on the bepolia chainspec, so any failure in
/// `BerachainBeaconConsensus::validate_header` is attributable to the
/// Berachain-specific gate.
/// for the given timestamp, so any failure in `BerachainBeaconConsensus::validate_header`
/// is attributable to the Berachain-specific gate.
fn upstream_valid_header(timestamp: u64, post_prague: bool) -> BerachainHeader {
BerachainHeader {
number: 1,
Expand Down Expand Up @@ -489,4 +515,21 @@ mod tests {
.validate_header(&sealed)
.expect("pre-Prague1 header without prev_proposer_pubkey should validate");
}

#[test]
fn test_validate_header_accepts_post_prague1_proposer_pubkey() {
let chain_spec = bepolia_chainspec();
let consensus = BerachainBeaconConsensus::new(chain_spec.clone());

let timestamp = 1_754_496_000;
let mut header = upstream_valid_header(timestamp, true);
header.prev_proposer_pubkey = Some(mock_bls_pubkey());

assert!(chain_spec.is_prague1_active_at_timestamp(header.timestamp));

let sealed = SealedHeader::new(header, BlockHash::ZERO);
consensus
.validate_header(&sealed)
.expect("post-Prague1 header with prev_proposer_pubkey should validate");
}
}
12 changes: 9 additions & 3 deletions src/node/evm/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,21 @@ pub enum BerachainExecutionError {
/// Missing POL transaction at index 0 in Prague1 block
#[error("First transaction in Prague1 block must be a POL transaction")]
MissingPolTransactionAtIndex0,
/// Prague3: Block contains invalid ERC20 transfer from/to blocked address
/// Prague3: Block contains invalid ERC20 transfer from/to blocked address.
/// Only reachable inside the historical Prague3 window
/// `[1762164459, 1762963200)` on mainnet; see `consensus/mod.rs`.
#[error(
Comment on lines +31 to 34
"Prague3 violation: Blocked address {blocked_address} can only send to rescue address or cannot receive transfers"
)]
Prague3BlockedAddressTransfer { blocked_address: Address },
/// Prague3: Block contains InternalBalanceChanged event from BEX vault
/// Prague3: Block contains InternalBalanceChanged event from BEX vault.
/// Only reachable inside the historical Prague3 window
/// `[1762164459, 1762963200)` on mainnet; see `consensus/mod.rs`.
#[error("Prague3 violation: InternalBalanceChanged event from BEX vault {vault_address}")]
Comment on lines +38 to 41
Prague3BexVaultEvent { vault_address: Address },
/// Prague3: Block contains ERC20 transfer from/to BEX vault
/// Prague3: Block contains ERC20 transfer from/to BEX vault.
/// Only reachable inside the historical Prague3 window
/// `[1762164459, 1762963200)` on mainnet; see `consensus/mod.rs`.
#[error("Prague3 violation: ERC20 transfer from/to BEX vault {vault_address}")]
Comment on lines +43 to 46
Prague3BexVaultTransfer { vault_address: Address },
}
Expand Down
8 changes: 7 additions & 1 deletion tests/e2e/prague3_empty_block_test.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
//! Pins the Prague3 empty-block builder behavior that ran on Berachain mainnet
//! during the closed historical window `[1762164459, 1762963200)`. The genesis
//! fixture lives under `tests/fixtures/historical/` (see the README there) to
//! signal that it models a non-live fork window. The consensus rules being
//! exercised are documented in `src/consensus/mod.rs`.
Comment on lines +1 to +5

use crate::e2e::berachain_payload_attributes_generator;
use bera_reth::{
chainspec::BerachainChainSpec, node::BerachainNode, transaction::BerachainTxEnvelope,
Expand All @@ -15,7 +21,7 @@ async fn test_prague3_builds_empty_block() -> eyre::Result<()> {
let runtime = Runtime::with_existing_handle(tokio::runtime::Handle::current())?;

let genesis_path =
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/eth-genesis-prague3.json");
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/historical/eth-genesis-prague3.json");
let genesis_json = std::fs::read_to_string(genesis_path)?;
let genesis = parse_genesis(&genesis_json)?;
let chain_spec = Arc::new(BerachainChainSpec::from(genesis));
Expand Down
31 changes: 31 additions & 0 deletions tests/fixtures/historical/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Historical Fixtures

This directory holds chain-spec / genesis fixtures that model **closed historical
fork windows** on Berachain mainnet. They are not representative of the live tip
and should not be used as templates for new networks.

They are kept because consensus rules from those windows must remain
re-executable: any node syncing from genesis (and any reorg back into the
window) re-runs those rules. Fixtures here back the e2e tests that pin that
behavior.

## Index

### `eth-genesis-prague3.json`

Models the **Prague3 emergency window**, active on Berachain mainnet for
timestamps `[1762164459, 1762963200)` — 2025-11-03 to 2025-11-12, when Prague4
ended the restrictions. Used by `tests/e2e/prague3_empty_block_test.rs` to
exercise the empty-block builder path that ran while Prague3 was the live tip.
Comment on lines +16 to +19

The Prague3 consensus rules enforced by this fixture are validated in
`src/consensus/mod.rs` (`validate_block_post_execution`); see the doc block
above the Prague3 section there for design rationale, including the
intentional asymmetry between the address-scoped checks
(`InternalBalanceChanged`, deposit parser) and the unscoped ERC20 `Transfer`
check.

Bug-bounty findings targeting this fixture (or the rules it covers) against the
**live tip** are out of scope — the gate is inactive on all production
timestamps post-Prague4. Findings about a behavioral split with bera-geth's
`ValidatePrague3Transaction` inside the historical window remain in scope.