Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8ba559f
security: fix GRETH-002/003/004 — mint precompile, block validation, …
Richard1048576 Feb 23, 2026
524920a
security: fix GRETH-001,005-019 — RPC fallback address, CLI validatio…
Richard1048576 Feb 23, 2026
c426063
security: fix GRETH-007/010/012/017 — state root warning, oracle filt…
Richard1048576 Feb 23, 2026
2dc1767
docs: add security fix checklist with GRETH-001–019 tracking
Richard1048576 Feb 23, 2026
84c751c
fix(GRETH-011): receipt proof verification for relayer log validation
Richard1048576 Feb 23, 2026
10ccefc
docs: update GRETH-011 status to receipt proof fix (b7ef203525)
Richard1048576 Feb 23, 2026
2edcb1d
docs: mark GRAV-005 as fixed (f4c8325 in contracts repo)
Richard1048576 Feb 23, 2026
402d527
docs: add security audit report and design documents for 19 findings
Richard1048576 Feb 23, 2026
12e1928
ci: add Claude Code security review GitHub Action
Richard1048576 Feb 24, 2026
c99e0ae
ci: trigger security review workflow
Richard1048576 Feb 24, 2026
780092b
ci: enable run-every-commit and full fetch depth for security review
Richard1048576 Feb 24, 2026
bba3bf0
ci: trigger full audit security review
Richard1048576 Feb 24, 2026
d9004f6
docs: add round 2 security audit design documents for 9 findings (#272)
nekomoto911 Feb 28, 2026
e0b7566
add review comments for GRETH-011 ~ GRETH-018 (#273)
nekomoto911 Feb 28, 2026
fff3f02
add review comments to security fixes design doc round 2 (#274)
nekomoto911 Mar 1, 2026
e59a4a0
docs: add lightman review comments for GRETH-025 and GRETH-026
Lchangliang Mar 3, 2026
4c1a166
update greth 023 review state
ByteYue Mar 3, 2026
8812597
fix: remove rejected and already-merged security fixes per review
Richard1048576 Mar 4, 2026
a92a2fc
docs: add Phase 2 security audit report (2026-03-05)
Richard1048576 Mar 5, 2026
e62277a
fix: implement Phase 3 security audit fixes (GRETH-029~075)
Richard1048576 Mar 5, 2026
157b071
fix: comments on Phase 3 security audit fixes
AshinGau Mar 9, 2026
d636a0d
add review comments for GRETH-001 ~ GRETH-010
AshinGau Feb 26, 2026
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
31 changes: 31 additions & 0 deletions .github/workflows/claude-security-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Claude Security Review

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read
pull-requests: write

jobs:
security-review:
# Only run after maintainer approval for external PRs
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Run Claude Security Review
uses: anthropics/claude-code-security-review@main
with:
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
claude-model: claude-sonnet-4-6
claudecode-timeout: 25
run-every-commit: true
exclude-directories: "docs,tests,benches,.github"
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 36 additions & 2 deletions crates/engine/tree/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,18 @@ where
// and storage_db internally. For fault tolerance, stage checkpoints ensure
// idempotency - each stage's checkpoint is verified before writing, guaranteeing
// exactly-once execution even if the process crashes mid-block.
// GRETH-069: ORDERING NOTE — State and trie writes execute concurrently
// in separate threads with independent commits. This is safe because:
//
// 1. Each stage uses per-stage checkpoints (StageId::Execution,
// StageId::MerkleExecute, etc.) stored atomically with its data.
// 2. On crash, StorageRecoveryHelper re-executes only incomplete stages
// by comparing checkpoint block numbers.
// 3. All stage writes are idempotent — re-executing produces identical data.
//
// The acceptable inconsistency window is: between the first thread's commit
// and the second thread's commit (~milliseconds). During this window, a crash
// would leave one stage ahead, which recovery handles correctly.
thread::scope(|scope| -> Result<(), PersistenceError> {
let state_handle = scope.spawn(|| -> Result<(), PersistenceError> {
let start = Instant::now();
Expand Down Expand Up @@ -341,8 +353,30 @@ where
.record(start.elapsed());
Ok(())
});
state_handle.join().unwrap()?;
trie_handle.join().unwrap()
// GRETH-005: The two write stages (state and trie) are idempotent via per-stage
// checkpoints (StageId::Execution, StageId::MerkleExecute, etc.), so if one
// succeeds and the other fails, the failed stage will be retried on the next
// attempt. However, cross-DB atomicity is not guaranteed at the OS level.
// If the process crashes mid-write, recovery will redo only the incomplete stage.
if let Err(e) = state_handle.join().unwrap() {
error!(target: "persistence::save_block",
block_number = block_number,
error = ?e,
"State write failed — trie write may have already committed; \
next startup will re-run only incomplete stages via checkpoint recovery"
);
return Err(e);
}
if let Err(e) = trie_handle.join().unwrap() {
error!(target: "persistence::save_block",
block_number = block_number,
error = ?e,
"Trie write failed — state write already committed; \
next startup will re-run trie stage from checkpoint"
);
return Err(e);
}
Ok(())
})?;
PERSIST_BLOCK_CACHE.persist_tip(block_number);
}
Expand Down
27 changes: 27 additions & 0 deletions crates/engine/tree/src/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,27 @@ impl<'a, N: ProviderNodeTypes> StorageRecoveryHelper<'a, N> {
let provider_ro = self.factory.database_provider_ro()?;
let recover_block_number = provider_ro.recover_block_number()?;
let best_block_number = provider_ro.best_block_number()?;

// GRETH-070: Validate that the execution checkpoint points to a block
// that actually exists and has consistent data.
let recover_block_number = if recover_block_number > 0 {
let header_exists = provider_ro
.header_by_number(recover_block_number)?
.is_some();
if !header_exists {
warn!(
target: "engine::recovery",
recover_block_number = ?recover_block_number,
"Execution checkpoint references non-existent block header, \
falling back to previous block"
);
recover_block_number.saturating_sub(1)
} else {
recover_block_number
}
} else {
recover_block_number
};
drop(provider_ro);

if recover_block_number == best_block_number {
Expand All @@ -141,6 +162,12 @@ impl<'a, N: ProviderNodeTypes> StorageRecoveryHelper<'a, N> {
provider_rw.update_pipeline_stages(recover_block_number, false)?;
provider_rw.commit()?;
info!(target: "engine::recovery", recover_block_number = ?recover_block_number, "Recovery completed successfully");
// GRETH-008 NOTE: This recovery trusts that the persisted execution state at
// recover_block_number is consistent with the canonical block's expected state_root.
// If blocks were accepted without state root validation (see GRETH-007), corrupted
// state can be re-committed here. A future improvement should verify:
// actual_state_root == canonical_block.header.state_root
// before accepting the recovered state.
Ok(())
}

Expand Down
54 changes: 44 additions & 10 deletions crates/engine/tree/src/tree/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,17 @@ where
block_number=%block_number,
block_hash=%executed_block.recovered_block.hash(),
"Received make canonical event");
self.make_executed_block_canonical(executed_block);
// GRETH-068: Propagate error instead of panicking
if let Err(err) = self.make_executed_block_canonical(executed_block) {
error!(
target: "on_pipe_exec_event",
block_number=%block_number,
error=%err,
"make_executed_block_canonical failed, node should shut down"
);
// Do not send on tx — the caller will detect the failure via channel drop.
return;
}
tx.send(()).expect("Failed to send make canonical event");
}
PipeExecLayerEvent::WaitForPersistence(WaitForPersistenceEvent {
Expand All @@ -514,7 +524,11 @@ where
}
}

fn make_executed_block_canonical(&mut self, block: ExecutedBlockWithTrieUpdates<N>) {
/// GRETH-068: Returns ProviderResult instead of panicking on make_canonical failure
fn make_executed_block_canonical(
&mut self,
block: ExecutedBlockWithTrieUpdates<N>,
) -> ProviderResult<()> {
let block_number = block.recovered_block.number();
let block_hash = block.recovered_block.hash();
let sealed_header = block.recovered_block.clone_sealed_header();
Expand All @@ -530,15 +544,29 @@ where
ForkchoiceStatus::Valid,
);

self.make_canonical(block_hash).unwrap_or_else(|err| {
panic!(
"Failed to make canonical, block_number={block_number} block_hash={block_hash}: {err}",
)
});
if let Err(err) = self.make_canonical(block_hash) {
error!(
target: "engine::tree",
block_number=%block_number,
block_hash=%block_hash,
error=%err,
"Failed to make block canonical — triggering graceful shutdown"
);
return Err(err);
}

// deterministic consensus means canonical block is immediately safe and finalized
// ARCHITECTURAL NOTE (GRETH-009): Gravity's deterministic BFT consensus (AptosBFT with
// 2/3+ quorum) provides finality guarantees equivalent to Ethereum's finalized state.
// Blocks are only delivered via push_ordered_block() after achieving BFT consensus,
// so immediate finalization is the correct behavior for this consensus model.
//
// SECURITY ASSUMPTION: The gravity-sdk consensus layer is trusted to only deliver
// committed blocks. If a bug in gravity-sdk delivers an invalid block, it becomes
// immediately irrevocable from the execution layer's perspective.
// Defense: validate_block() (called above) provides a secondary check on block structure.
self.canonical_in_memory_state.set_safe(sealed_header.clone());
self.canonical_in_memory_state.set_finalized(sealed_header);
Ok(())
}

/// Returns a new [`Sender`] to send messages to this type.
Expand All @@ -558,8 +586,14 @@ where
}

fn pipe_run_inner(mut self) {
let pipe_event_rx =
get_pipe_exec_layer_event_bus::<N>().event_rx.lock().unwrap().take().unwrap();
// GRETH-037: Event bus is now typed as EthPrimitives.
// Safety: gravity-reth always instantiates N = EthPrimitives.
// The transmute is sound because PipeExecLayerEvent<EthPrimitives> and
// PipeExecLayerEvent<N> have identical layout when N = EthPrimitives.
let pipe_event_rx: std::sync::mpsc::Receiver<PipeExecLayerEvent<N>> = unsafe {
let eth_rx = get_pipe_exec_layer_event_bus().event_rx.lock().unwrap().take().unwrap();
std::mem::transmute(eth_rx)
};
loop {
match self.try_recv_pipe_exec_event(&pipe_event_rx) {
Ok(Some(event)) => self.on_pipe_exec_event(event),
Expand Down
7 changes: 7 additions & 0 deletions crates/ethereum/evm/src/parallel_execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,13 @@ where
BundleRetention::Reverts,
);
}
// NOTE (GRETH-007): The state root is not verified at execution time here.
// State root verification happens later when consensus confirms the block hash
// (verify_executed_block_hash in lib.rs). If consensus provides a hash, it is
// asserted equal to the EL-computed block hash (which includes the state root).
// A discrepancy caused by a Grevm parallel dependency detection bug would be
// caught at that point. When consensus passes None, the check is skipped and a
// warning is logged. See verify_executed_block_hash for the implementation.
Ok(BlockExecutionResult { receipts, gas_used, requests })
}

Expand Down
22 changes: 18 additions & 4 deletions crates/gravity-storage/src/block_view_storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ where
}

fn state_root(&self, hashed_state: &HashedPostState) -> ProviderResult<(B256, TrieUpdatesV2)> {
// TODO(GRETH-034): Use a snapshot-backed read transaction to ensure cache misses
// fall back to the same DB state that was current at execution time,
// not a potentially newer state written by the persistence layer.
// Requires: database_provider_ro_with_snapshot() that captures RocksDB snapshots
// from all 3 DB instances (state_db, account_db, storage_db) atomically.
//
// TODO(GRETH-035): Coordinate RocksDB snapshots across all 3 DB instances
// under a shared mutex to ensure cross-DB consistency.
let tx = self.client.database_provider_ro()?.into_tx();
let nested_hash = NestedStateRoot::new(&tx, Some(self.cache.clone()));
nested_hash.calculate(hashed_state)
Expand Down Expand Up @@ -147,8 +155,13 @@ impl<Tx: DbTx> DatabaseRef for RawBlockViewProvider<Tx> {
.unwrap_or_default())
}

fn block_hash_ref(&self, _number: u64) -> Result<B256, Self::Error> {
unimplemented!("not support block_hash_ref in BlockViewProvider")
fn block_hash_ref(&self, number: u64) -> Result<B256, Self::Error> {
// GRETH-031: Implement BLOCKHASH opcode support via CanonicalHeaders DB lookup.
// Previously panicked with unimplemented!(), enabling trivial validator DoS.
match self.tx.get_by_encoded_key::<tables::CanonicalHeaders>(&number)? {
Some(hash) => Ok(hash),
None => Ok(B256::ZERO),
}
}
}

Expand Down Expand Up @@ -207,7 +220,8 @@ impl DatabaseRef for BlockViewProvider {
self.db.storage_ref(address, index)
}

fn block_hash_ref(&self, _number: u64) -> Result<B256, Self::Error> {
unimplemented!("not support block_hash_ref in BlockViewProvider")
fn block_hash_ref(&self, number: u64) -> Result<B256, Self::Error> {
// GRETH-031: Delegate to underlying DB for BLOCKHASH opcode support.
self.db.block_hash_ref(number)
}
}
9 changes: 9 additions & 0 deletions crates/node/core/src/args/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,15 @@ fn parse_sharding_directories(raw: &str) -> Result<ShardingDirectories, String>
if trimmed.is_empty() {
return Err("Sharding directories cannot be empty".to_string());
}
for part in trimmed.split(';').map(str::trim).filter(|s| !s.is_empty()) {
let p = std::path::Path::new(part);
if !p.is_absolute() {
return Err(format!("Shard path must be absolute, got: '{part}'"));
}
if p.components().any(|c| c == std::path::Component::ParentDir) {
return Err(format!("Shard path must not contain '..': '{part}'"));
}
}
// Leak once to obtain a static str; acceptable because values are process-scoped config.
Ok(Box::leak(trimmed.to_owned().into_boxed_str()))
}
Expand Down
2 changes: 1 addition & 1 deletion crates/pipe-exec-layer-ext-v2/event-bus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ workspace = true

[dependencies]
reth-primitives.workspace = true
reth-ethereum-primitives.workspace = true
reth-chain-state.workspace = true
alloy-primitives.workspace = true
once_cell.workspace = true
tokio.workspace = true
tracing.workspace = true
41 changes: 27 additions & 14 deletions crates/pipe-exec-layer-ext-v2/event-bus/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,42 @@
//! Event bus for the pipe execution layer.

use alloy_primitives::TxHash;
use once_cell::sync::OnceCell;
use reth_chain_state::ExecutedBlockWithTrieUpdates;
use reth_ethereum_primitives::EthPrimitives;
use reth_primitives::NodePrimitives;
use std::{any::Any, thread::sleep, time::Duration};
use tokio::sync::{mpsc::UnboundedReceiver, oneshot};
use std::{sync::OnceLock, thread::sleep, time::Duration};
use tokio::sync::{mpsc::Receiver, oneshot};
use tracing::info;

// GRETH-037: Use typed OnceLock instead of Box<dyn Any> to eliminate runtime downcast panics.
// GRETH-038: Add maximum wait timeout to prevent infinite busy-wait.

/// A static instance of `PipeExecLayerEventBus` used for dispatching events.
pub static PIPE_EXEC_LAYER_EVENT_BUS: OnceCell<Box<dyn Any + Send + Sync>> = OnceCell::new();
/// GRETH-037: Typed as `PipeExecLayerEventBus<EthPrimitives>` — no dynamic downcast needed.
pub static PIPE_EXEC_LAYER_EVENT_BUS: OnceLock<PipeExecLayerEventBus<EthPrimitives>> =
OnceLock::new();

/// Get a reference to the global `PipeExecLayerEventBus` instance.
pub fn get_pipe_exec_layer_event_bus<N: NodePrimitives>() -> &'static PipeExecLayerEventBus<N> {
let mut wait_time = 0;
/// GRETH-037: No generic parameter needed — always returns `EthPrimitives` variant.
/// GRETH-038: Will panic after MAX_WAIT_SECS instead of blocking forever.
pub fn get_pipe_exec_layer_event_bus() -> &'static PipeExecLayerEventBus<EthPrimitives> {
const MAX_WAIT_SECS: u64 = 120;
let start = std::time::Instant::now();
loop {
let event_bus = PIPE_EXEC_LAYER_EVENT_BUS
.get()
.map(|ext| ext.downcast_ref::<PipeExecLayerEventBus<N>>().unwrap());
if let Some(event_bus) = event_bus {
break event_bus;
} else if wait_time % 5 == 0 {
if let Some(event_bus) = PIPE_EXEC_LAYER_EVENT_BUS.get() {
return event_bus;
}
if start.elapsed().as_secs() >= MAX_WAIT_SECS {
panic!(
"GRETH-038: PipeExecLayerEventBus not initialized after {}s — \
likely a startup ordering bug",
MAX_WAIT_SECS
);
}
if start.elapsed().as_secs() % 5 == 0 {
info!("Wait PipeExecLayerEventBus ready...");
}
sleep(Duration::from_secs(1));
wait_time += 1;
}
}

Expand Down Expand Up @@ -61,5 +73,6 @@ pub struct PipeExecLayerEventBus<N: NodePrimitives> {
/// Receive events from `PipeExecService`
pub event_rx: std::sync::Mutex<Option<std::sync::mpsc::Receiver<PipeExecLayerEvent<N>>>>,
/// Receive discarded txs from `PipeExecService`
pub discard_txs: tokio::sync::Mutex<Option<UnboundedReceiver<Vec<TxHash>>>>,
/// GRETH-036: Bounded channel for backpressure
pub discard_txs: tokio::sync::Mutex<Option<Receiver<Vec<TxHash>>>>,
}
6 changes: 4 additions & 2 deletions crates/pipe-exec-layer-ext-v2/execute/src/bls_precompile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ const BLS_POP_LEN: usize = 96;
/// Expected input length: pubkey (48) + pop (96) = 144 bytes
const EXPECTED_INPUT_LEN: usize = BLS_PUBKEY_LEN + BLS_POP_LEN;

/// Gas cost for PoP verification (2 bilinear pairings + hash-to-curve)
const POP_VERIFY_GAS: u64 = 45_000;
/// GRETH-065: Gas cost for PoP verification (2 bilinear pairings + hash-to-curve).
/// Aligned with EIP-2537: ~131k (2 pairings) + ~32k (hash-to-curve) = ~163k.
/// Set to 150,000 as a conservative lower bound.
const POP_VERIFY_GAS: u64 = 150_000;

/// Domain separation tag for BLS PoP verification
/// Matches the IETF standard for BLS12-381 PoP
Expand Down
Loading